Diseño con structs e interfaces en Go: composición y contratos

Capítulo 4

Tiempo estimado de lectura: 10 minutos

+ Ejercicio

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/json no 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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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.Mutex u 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 Sender y AuditLog.
  • Crea un NotificationService que dependa de esas interfaces.
  • Implementa NopAuditLog para evitar nil.

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 Write respetando 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 switch del caso de uso.
  • Agrega una implementación FakePaymentMethod para pruebas.

Checklist de diseño para structs e interfaces

DecisiónPregunta prácticaRecomendació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

Ahora responde el ejercicio sobre el contenido:

¿Cuál es la razón por la que una variable de tipo interfaz puede no ser nil aunque contenga un puntero nil?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Una interfaz conceptualmente guarda (tipo dinámico, valor). Solo es nil si ambos son nil. Si contiene un tipo dinámico (por ejemplo, *T) pero su valor es un puntero nil, la interfaz no es nil.

Siguiente capítulo

Manejo de errores en Go: control explícito y robustez

Arrow Right Icon
Portada de libro electrónico gratuitaGo desde Cero: Programación Moderna, Rápida y Escalable
33%

Go desde Cero: Programación Moderna, Rápida y Escalable

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.