Introducción y el “por qué”: el problema del mundo real

Imagina que estás construyendo el backend de un banco. Tienes una struct Cuenta con un campo Saldo. Cualquier parte de tu aplicación —un handler HTTP mal escrito, un test apurado, una goroutine con un bug de concurrencia— puede hacer esto en una sola línea:

cuenta.Saldo = -999999

Sin validación, sin control, sin registro de qué pasó. El programa compila perfectamente y el desastre llega directo a producción.

Este es el problema central que resuelve el encapsulamiento: proteger las invariantes del sistema. Una invariante es una regla de negocio que jamás, bajo ninguna circunstancia, debe romperse. “El saldo de una cuenta nunca puede ser negativo” es una invariante. “El stock de un producto nunca puede bajar de cero” es otra. Si tu código permite que cualquiera modifique el estado interno sin pasar por una validación, no tienes invariantes: tienes sugerencias que esperas que nadie ignore.

Aquí es donde muchos programadores que vienen de Java o C# caen en un reflejo casi automático: “necesito un getter y un setter para cada campo”. Y así terminamos con clases de 200 líneas donde 150 son código repetitivo tipo:

public double getSaldo() { return this.saldo; }
public void setSaldo(double saldo) { this.saldo = saldo; }

El problema de este dogma es que un setter automático no protege nada. Sigue permitiendo poner cualquier valor, solo que ahora con una capa extra de sintaxis que aparenta seguridad sin ofrecerla realmente. Es teatro de encapsulamiento, no encapsulamiento real.

Go propone algo distinto: en lugar de blindar todo por inercia, te da una herramienta simple (la capitalización de nombres) para que decidas, campo por campo, qué necesita protección real y qué no. Nada de ceremonia. Nada de código generado automáticamente que no aporta valor. Vamos a construir esto paso a paso.


Paso 1: Visibilidad por capitalización (la regla de oro)

En Go no existen las palabras clave public, private o protected. La regla es brutalmente simple y se basa en la primera letra del identificador:

  • Mayúscula inicial → el identificador es exportado: visible y usable desde cualquier otro paquete que importe el tuyo.
  • Minúscula inicial → el identificador es no exportado: solo visible dentro del mismo paquete donde fue declarado.

Esto aplica a todo: nombres de structs, campos, funciones, métodos, variables y constantes.

type Cuenta struct {
    Titular string  // exportado
    saldo   float64 // no exportado
}

Por qué esto cambia la forma en que estructuramos el código

En Java o C#, la unidad de encapsulamiento es la clase. Un campo private está protegido incluso de otras instancias de la misma clase en el mismo archivo, pero el control real lo ejerces método por método.

En Go, la unidad de encapsulamiento es el paquete. Esto significa que saldo no está protegido solo desde fuera de la struct — está protegido desde fuera del paquete. Cualquier función dentro del mismo paquete gestion puede leer y escribir saldo libremente, sin necesidad de getters internos.

¿Por qué es esto una ventaja y no una debilidad? Porque te obliga a pensar en términos de API pública del paquete, no de la clase aislada. El paquete completo (constructor, métodos, validaciones) actúa como un único guardián coherente del campo privado. No necesitas blindarte de tu propio código dentro del mismo paquete — solo necesitas blindarte de quien lo consume desde afuera. Esa es la frontera que realmente importa.


Paso 2: Diseñando el struct de campos mixtos

Vamos a crear el paquete gestion con la struct central del sistema.

package gestion

import "errors"

// Cuenta representa una cuenta bancaria con saldo protegido.
type Cuenta struct {
    Titular string  // Público: cualquiera puede leerlo y modificarlo.
    saldo   float64 // Privado: solo el paquete "gestion" puede tocarlo.
}

Por qué esta mezcla y no todo público o todo privado

  • Titular es público porque cambiar el nombre del titular no rompe ninguna regla de negocio. No hay una invariante asociada a ese dato: es información descriptiva. Si alguien fuera del paquete necesita corregir un nombre mal escrito, no hay razón para obligarlo a pasar por un método. Ponerle un getter/setter a este campo sería precisamente el “código basura” que queremos evitar: ceremonia sin beneficio.
  • saldo es privado porque existe una invariante crítica: el saldo nunca debe ser negativo, y todo cambio debe quedar sujeto a reglas (por ejemplo, no permitir depósitos negativos o retiros que dejen el saldo por debajo de cero). Si este campo fuera público, la invariante existiría solo “de palabra” — cualquier línea de código externa podría violarla sin que el compilador se queje.

La ventaja concreta: en un lenguaje con getters/setters obligatorios, tendrías que escribir código repetitivo también para Titular, aunque no aporte ninguna protección real. En Go, simplemente lo dejas público y sigues adelante. El encapsulamiento se aplica con bisturí, no con brocha ancha.


Paso 3: Implementando el “portero” (el patrón constructor)

Como saldo es privado, nadie fuera del paquete gestion puede escribir gestion.Cuenta{saldo: 100} — ni siquiera puede nombrar el campo en un literal de struct desde otro paquete. Go directamente no lo permite. Esto significa que necesitamos una puerta de entrada controlada: una función constructora.

// NuevaCuenta crea una Cuenta validando el saldo inicial.
// Es el único punto de entrada para inicializar una cuenta desde fuera del paquete.
func NuevaCuenta(titular string, saldoInicial float64) (*Cuenta, error) {
    if titular == "" {
        return nil, errors.New("el titular no puede estar vacío")
    }
    if saldoInicial < 0 {
        return nil, errors.New("el saldo inicial no puede ser negativo")
    }

    return &Cuenta{
        Titular: titular,
        saldo:   saldoInicial,
    }, nil
}

Por qué es necesaria esta función

Sin NuevaCuenta, un usuario del paquete gestion no tendría ninguna forma válida de crear una cuenta con saldo inicial. La única puerta que dejamos abierta es esta función, y eso es exactamente el punto: nosotros decidimos qué condiciones deben cumplirse antes de que exista una Cuenta en memoria. No hay forma de rodear la validación, porque no hay otro camino de construcción disponible.

Por qué validamos ahí y no en otro lugar

La validación en el constructor es la última barrera antes de que el objeto exista. Si permitiéramos crear una Cuenta con saldo negativo y “arreglarla después”, ya sería demasiado tarde: el objeto inválido pudo haber sido usado, guardado en una base de datos o pasado a otra función durante esa ventana de tiempo. Validar en el punto de creación elimina esa ventana por completo.

Por qué devolvemos *Cuenta (puntero) y error

  • Puntero (*Cuenta): si devolviéramos Cuenta por valor, cada vez que la función retorne se copiaría toda la struct. Además, como los métodos de control que vamos a escribir necesitan modificar el campo saldo, es más consistente y eficiente trabajar siempre sobre la misma dirección de memoria en lugar de copias independientes que se desincronizan entre sí.
  • error como segundo valor de retorno: es el patrón idiomático de Go para reportar fallos sin usar excepciones. En lugar de que el programa “explote” ante un dato inválido, la función devuelve nil en el puntero y describe el problema en el error. El código que llama a NuevaCuenta está obligado por convención (y buena práctica) a revisar ese error antes de usar la cuenta, lo que hace explícito el manejo de casos inválidos en cada punto de la aplicación.

Paso 4: Métodos de control y getters explícitos

Ahora necesitamos formas seguras de leer y modificar saldo desde fuera del paquete.

// Saldo devuelve el saldo actual de la cuenta.
func (c *Cuenta) Saldo() float64 {
    return c.saldo
}

// Depositar incrementa el saldo, validando que el monto sea positivo.
func (c *Cuenta) Depositar(monto float64) error {
    if monto <= 0 {
        return errors.New("el monto a depositar debe ser positivo")
    }
    c.saldo += monto
    return nil
}

Por qué Saldo() no es un getter tradicional camuflado

En muchos lenguajes, un getter automático es simplemente return this.campo; sin ningún valor agregado — es una convención mecánica que existe porque el lenguaje o el framework lo exige, no porque resuelva un problema real.

Aquí Saldo() cumple una función distinta: expone el valor sin exponer la variable. Quien llama a cuenta.Saldo() obtiene una copia del número en ese instante, pero jamás obtiene una referencia que le permita escribir directamente sobre c.saldo. La variable original sigue completamente blindada dentro del paquete. Si en el futuro decidimos, por ejemplo, aplicar una conversión de moneda, redondeo o un cálculo derivado (saldo disponible menos retenciones), podemos cambiar la lógica interna de Saldo() sin que ningún código externo se entere ni se rompa. Eso es imposible si el campo fuera público desde el principio.

Por qué Depositar es un método y no un setter directo

Depositar no reemplaza el saldo: lo transforma bajo una regla. La diferencia es sutil pero fundamental. Un setter tradicional (SetSaldo(valor)) le entrega al llamador el control total sobre el resultado final. Depositar(monto) en cambio solo permite una operación específica y validada: sumar un monto positivo. El llamador nunca decide el valor final del saldo — solo participa en una transacción que el paquete gestion controla de principio a fin. Esa es la diferencia entre encapsular datos y encapsular comportamiento.


Paso 5: Poniéndolo a prueba (el paquete main)

Veamos cómo se comporta todo esto desde el punto de vista de un consumidor externo del paquete gestion.

package main

import (
    "fmt"
    "log"

    "tuproyecto/gestion"
)

func main() {
    // Creación exitosa: pasamos por la puerta controlada.
    cuenta, err := gestion.NuevaCuenta("Ana Torres", 500.0)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Cuenta creada. Saldo inicial:", cuenta.Saldo())

    // Depósito correcto: pasa por el método de control.
    if err := cuenta.Depositar(150.0); err != nil {
        log.Fatal(err)
    }
    fmt.Println("Saldo tras el depósito:", cuenta.Saldo())

    // Titular es público: esto SÍ compila, y está bien que así sea.
    cuenta.Titular = "Ana Torres Gómez"

    // Esto NO compila. Descomentar la siguiente línea produce un error
    // en tiempo de compilación:
    // cuenta.saldo = -1000
}

Por qué el compilador frena esto en seco

La línea cuenta.saldo = -1000 intenta acceder a un identificador no exportado (saldo) desde un paquete distinto (main) al que lo declaró (gestion). Go no resuelve esto en tiempo de ejecución ni con una excepción: lo resuelve en tiempo de compilación, con un error como:

cuenta.saldo undefined (cannot refer to unexported field or method saldo)

Esto es enormemente valioso porque el error nunca llega a producción, nunca llega a un code review, ni siquiera llega a ejecutarse en un test. El propio compilador actúa como la primera línea de defensa de la invariante. No dependemos de la disciplina del programador ni de que alguien recuerde “no tocar ese campo directamente” — el lenguaje lo hace estructuralmente imposible.


Conclusión: la filosofía Gopher

Lo que acabamos de construir no es un truco de sintaxis: es una forma de pensar el diseño de software. En Go, el encapsulamiento no se aplica por ritual ni porque “así se hace en POO” — se aplica exactamente donde hay una invariante real que proteger, una validación que ejecutar o un cómputo que ocultar.

  • Titular quedó público porque no había nada que proteger. Ponerle un getter/setter hubiera sido ceremonia vacía.
  • saldo quedó privado porque representa una regla de negocio crítica, y la única forma de tocarlo es a través de un constructor que valida y de métodos que controlan cada transformación.

Esa es la diferencia entre encapsular por costumbre y encapsular con criterio. Go te quita la excusa de la “buena práctica automática” y te obliga a preguntarte, campo por campo: ¿qué pasa si dejo esto abierto? Cuando la respuesta es “nada grave”, lo dejas público. Cuando la respuesta es “el sistema entero se corrompe”, ahí — y solo ahí — construyes el portero.

Menos código, más intención. Ese es el camino Gopher.