Modelar dominios con struct: campos, tags y métodos
En Go, un struct es la herramienta principal para modelar entidades del dominio: agrupa datos relacionados (campos) y permite asociar comportamiento mediante métodos. La idea práctica es: define datos estables del dominio como campos y agrega métodos para operaciones coherentes con esos datos (validar, calcular, transformar, persistir, etc.).
Campos y visibilidad
Los nombres en Go controlan la visibilidad: si el identificador empieza con mayúscula, es exportado (visible fuera del paquete). Esto afecta tanto a campos como a métodos.
package domain
type User struct {
ID string
email string // no exportado
}
func (u User) EmailMasked() string {
// método exportado si el nombre empieza con mayúscula
if len(u.email) < 3 {
return "***"
}
return u.email[:2] + "***"
}Tags: metadatos para serialización/validación
Los tags son cadenas asociadas a campos, usadas por librerías (por ejemplo, encoding/json) para mapear nombres, omitir campos o aplicar reglas. Los tags no cambian el comportamiento del lenguaje; son metadatos consultados por reflexión.
type UserDTO struct {
ID string `json:"id"`
Email string `json:"email"`
Age int `json:"age,omitempty"`
}json:"age,omitempty"omite el campo si tiene el valor cero.- Si un campo no es exportado (minúscula),
encoding/jsonno lo serializa aunque tenga tag.
Métodos y receptores: por valor vs por puntero
Un método en Go es una función con un receptor. Elegir receptor por valor o por puntero es una decisión de diseño que impacta mutabilidad, rendimiento y consistencia de la API.
Receptor por valor
Útil cuando el método no necesita modificar el estado y el tipo es pequeño y barato de copiar. El receptor recibe una copia.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
type Money struct {
Cents int64
Curr string
}
func (m Money) IsZero() bool {
return m.Cents == 0
}Receptor por puntero
Útil cuando el método modifica el estado, cuando copiar sería costoso o cuando quieres que el tipo tenga una identidad compartida (misma instancia). También es común usar puntero para mantener consistencia: si un método necesita puntero, suele recomendarse que la mayoría de métodos usen puntero.
type Cart struct {
Items []string
}
func (c *Cart) Add(item string) {
c.Items = append(c.Items, item)
}Guía práctica para elegir receptor
- ¿El método modifica el estado? Usa puntero.
- ¿El tipo contiene
sync.Mutexu otros campos no copiables? Usa puntero. - ¿El tipo es grande (muchos campos, slices, mapas, structs anidados)? Preferible puntero.
- ¿El tipo es pequeño e inmutable por diseño? Valor puede ser adecuado.
Composición (embedding) como alternativa a herencia
Go no tiene herencia clásica. En su lugar, fomenta composición: construir tipos a partir de otros, delegando comportamiento. El embedding (incrustación) permite “promover” campos y métodos del tipo embebido, logrando APIs expresivas sin jerarquías rígidas.
Embedding de structs
type Audit struct {
CreatedAt int64
UpdatedAt int64
}
func (a *Audit) Touch(now int64) {
a.UpdatedAt = now
}
type Order struct {
ID string
Audit // embebido
}Con esto, Order “tiene” un Audit y puede llamar o.Touch(now) directamente (método promovido).
Diseñar APIs claras con composición
Una API clara en Go suele preferir:
- Tipos pequeños con responsabilidades concretas.
- Dependencias explícitas (campos con interfaces, no con concretos).
- Constructores que validan invariantes.
type Clock interface {
NowUnix() int64
}
type SystemClock struct{}
func (SystemClock) NowUnix() int64 { return time.Now().Unix() }
type OrderService struct {
clock Clock
}
func NewOrderService(clock Clock) *OrderService {
if clock == nil {
panic("clock is required")
}
return &OrderService{clock: clock}
}El servicio depende de un contrato (Clock), no de una implementación. Esto facilita pruebas y reduce acoplamiento.
Interfaces en Go: contratos pequeños y práctica diaria
Una interfaz define un conjunto de métodos: un contrato. En Go, la implementación es implícita: un tipo “implementa” una interfaz si tiene los métodos requeridos, sin declarar nada especial. Esto permite diseñar con menos acoplamiento y más flexibilidad.
Definir contratos pequeños
Una regla práctica: interfaces pequeñas son más reutilizables. En vez de una interfaz enorme, define varias específicas.
type Saver interface {
Save(ctx context.Context, u User) error
}
type Finder interface {
FindByID(ctx context.Context, id string) (User, error)
}Luego puedes componerlas cuando lo necesites:
type UserRepository interface {
Saver
Finder
}Implementación implícita
type MemoryUserRepo struct {
data map[string]User
}
func (r *MemoryUserRepo) Save(ctx context.Context, u User) error {
r.data[u.ID] = u
return nil
}
func (r *MemoryUserRepo) FindByID(ctx context.Context, id string) (User, error) {
u, ok := r.data[id]
if !ok {
return User{}, fmt.Errorf("not found")
}
return u, nil
}*MemoryUserRepo implementa UserRepository automáticamente porque cumple los métodos.
nil en interfaces: el caso que rompe expectativas
Un punto clave: una interfaz en Go es (conceptualmente) un par: (tipo dinámico, valor). Una interfaz es nil solo si ambos son nil. Es posible tener una interfaz no-nil que contiene un puntero nil como valor.
type Logger interface {
Info(msg string)
}
type StdLogger struct{}
func (*StdLogger) Info(msg string) { fmt.Println(msg) }
func BuildLogger(disabled bool) Logger {
if disabled {
var l *StdLogger = nil
return l // interfaz NO es nil: tipo dinámico = *StdLogger, valor = nil
}
return &StdLogger{}
}Esto afecta comprobaciones como if logger == nil. Si necesitas representar “ausencia”, considera devolver (Logger, bool), o usar una implementación nula (Null Object) que no haga nada.
type NopLogger struct{}
func (NopLogger) Info(msg string) {}
func BuildLogger(disabled bool) Logger {
if disabled {
return NopLogger{}
}
return &StdLogger{}
}Comprobaciones de tipo: type assertions y type switches
Cuando trabajas con interfaces, a veces necesitas acceder a capacidades concretas. Go ofrece:
- Type assertion: extraer un tipo concreto.
- Type switch: ramificar según el tipo dinámico.
func FlushIfPossible(v any) {
if f, ok := v.(interface{ Flush() error }); ok {
_ = f.Flush()
}
}func Describe(v any) string {
switch x := v.(type) {
case string:
return "string: " + x
case fmt.Stringer:
return x.String()
default:
return "unknown"
}
}Diseño recomendado: usa assertions como “escape hatch”, no como base del diseño. Si necesitas muchas ramificaciones por tipo, probablemente el contrato está mal definido.
Patrones comunes: io.Reader y io.Writer
El paquete io es un ejemplo clásico de interfaces pequeñas y composables:
io.Reader:Read(p []byte) (n int, err error)io.Writer:Write(p []byte) (n int, err error)io.Closer:Close() error
Estas interfaces permiten que funciones trabajen con archivos, buffers, conexiones de red, compresores, etc., sin acoplarse a tipos concretos.
Ejemplo: función que copia datos sin saber el origen/destino
func CopyAll(dst io.Writer, src io.Reader) error {
buf := make([]byte, 32*1024)
for {
n, err := src.Read(buf)
if n > 0 {
if _, werr := dst.Write(buf[:n]); werr != nil {
return werr
}
}
if err == io.EOF {
return nil
}
if err != nil {
return err
}
}
}Decoradores con composición: envolver un io.Reader
Un patrón frecuente es crear un tipo que envuelve otro y añade comportamiento (logging, métricas, límites, etc.).
type CountingReader struct {
r io.Reader
n int64
}
func NewCountingReader(r io.Reader) *CountingReader {
return &CountingReader{r: r}
}
func (c *CountingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
func (c *CountingReader) BytesRead() int64 { return c.n }Esto mantiene el contrato (io.Reader) y añade capacidades sin herencia.
Guía paso a paso: diseñar un módulo desacoplado con structs + interfaces
Paso 1: define el modelo del dominio con invariantes
Evita exponer estados inválidos. Usa constructores que validen y devuelvan errores.
type Email string
type User struct {
ID string
Email Email
}
func NewUser(id string, email string) (User, error) {
if id == "" {
return User{}, fmt.Errorf("id required")
}
if !strings.Contains(email, "@") {
return User{}, fmt.Errorf("invalid email")
}
return User{ID: id, Email: Email(email)}, nil
}Paso 2: define contratos en el borde (lo que necesitas, no lo que tienes)
Piensa en lo mínimo que tu caso de uso requiere.
type UserStore interface {
Save(ctx context.Context, u User) error
FindByID(ctx context.Context, id string) (User, error)
}Paso 3: implementa el caso de uso como struct con dependencias por interfaz
type UserService struct {
store UserStore
}
func NewUserService(store UserStore) *UserService {
if store == nil {
panic("store is required")
}
return &UserService{store: store}
}
func (s *UserService) Register(ctx context.Context, id, email string) (User, error) {
u, err := NewUser(id, email)
if err != nil {
return User{}, err
}
if err := s.store.Save(ctx, u); err != nil {
return User{}, err
}
return u, nil
}Paso 4: crea implementaciones intercambiables (memoria, SQL, HTTP, etc.)
type InMemoryUserStore struct {
m map[string]User
}
func NewInMemoryUserStore() *InMemoryUserStore {
return &InMemoryUserStore{m: make(map[string]User)}
}
func (s *InMemoryUserStore) Save(ctx context.Context, u User) error {
s.m[u.ID] = u
return nil
}
func (s *InMemoryUserStore) FindByID(ctx context.Context, id string) (User, error) {
u, ok := s.m[id]
if !ok {
return User{}, fmt.Errorf("not found")
}
return u, nil
}Paso 5: prueba el diseño con un stub (sin acoplarte a infraestructura)
type StubUserStore struct {
saved []User
find map[string]User
}
func (s *StubUserStore) Save(ctx context.Context, u User) error {
s.saved = append(s.saved, u)
return nil
}
func (s *StubUserStore) FindByID(ctx context.Context, id string) (User, error) {
u, ok := s.find[id]
if !ok {
return User{}, fmt.Errorf("not found")
}
return u, nil
}Este enfoque te obliga a mantener contratos pequeños y a separar lógica de negocio de detalles de almacenamiento.
Ejercicios de diseño (para reforzar desacoplamiento)
Ejercicio 1: API de notificaciones con contratos pequeños
Diseña un módulo de notificaciones con estas condiciones:
- El caso de uso solo necesita enviar mensajes y registrar auditoría.
- Debe ser posible cambiar el proveedor (email, SMS, push) sin tocar el caso de uso.
Tareas:
- Define interfaces mínimas: por ejemplo
SenderyAuditLog. - Crea un
NotificationServiceque dependa de esas interfaces. - Implementa
NopAuditLogpara evitarnil.
Ejercicio 2: Decorador de io.Writer con límite de bytes
Crea un LimitWriter que envuelva un io.Writer y permita escribir como máximo N bytes en total.
- Define un struct con embedding o con un campo
w io.Writer. - Implementa
Writerespetando el límite. - Decide si el receptor debe ser puntero y justifica (necesitas estado acumulado).
Ejercicio 3: Evitar type assertions en un diseño
Se te entrega una función que hace type switch para decidir cómo procesar un pago según el tipo (tarjeta, transferencia, cripto). Rediseña para que el caso de uso dependa de una interfaz PaymentMethod con un método pequeño (por ejemplo Charge(amount Money) (Receipt, error)).
- Define la interfaz y structs concretos.
- Elimina el
type switchdel caso de uso. - Agrega una implementación
FakePaymentMethodpara pruebas.
Checklist de diseño para structs e interfaces
| Decisión | Pregunta práctica | Recomendación |
|---|---|---|
| Campos exportados | ¿Necesito que otros paquetes modifiquen/lean esto? | Exporta lo mínimo; protege invariantes con constructores/métodos |
| Receptor puntero | ¿Modifica estado o es costoso copiar? | Usa puntero; evita mezclar valor/puntero sin motivo |
| Interfaces | ¿Qué necesita mi consumidor? | Interfaces pequeñas, definidas cerca del uso |
| Composición | ¿Puedo reutilizar comportamiento sin jerarquías? | Embedding o wrapping para delegar y extender |
nil en interfaces | ¿Represento ausencia? | Prefiere Nop/Null Object o retorno explícito con ok |