Guía práctica
El objeto que rompe su encapsulamiento para poder nacer
Decodificar JSON directamente en User obliga a exportar campos y permite estados inválidos; separar el DTO conserva la invariante y el encapsulamiento.
Un endpoint crea usuarios a partir de JSON. La solución más corta consiste en decodificar el cuerpo directamente en el objeto de dominio:
type User struct {
Email string `json:"email"`
Name string `json:"name"`
}
func createUser(w http.ResponseWriter, r *http.Request) {
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if err := user.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
save(user)
}Es un patrón común porque encoding/json rellena campos exportados. También crea un problema de diseño: durante la decodificación User puede existir con email vacío, nombre vacío o una combinación que el dominio rechaza.
Para que el decoder pueda construirlo hemos hecho públicos los mismos campos que queríamos proteger.
El transporte obtiene permiso para romper la invariante#
Supongamos que todo usuario necesita un email normalizado y un nombre no vacío. Validate detecta el problema al final, pero no controla todos los lugares donde se puede crear o modificar el valor:
user := User{}
user.Email = "not-an-email"
existing.Email = ""La API pública permite más estados que el contrato. Cualquier paquete puede saltarse la validación, modificar un usuario ya guardado o construir uno a medias.
El handler no es el único consumidor de esos campos. Tests, jobs, migraciones y otros módulos también los ven. Una necesidad del adaptador JSON se ha convertido en una promesa para todo el sistema.
La documentación oficial de encoding/json explica el mecanismo: los campos exportados de un struct participan en la representación JSON. Ese requisito del decoder no obliga a que la entidad de dominio tenga la misma forma.
Separa el mensaje de entrada del objeto válido#
El handler puede decodificar una representación propia del transporte:
type createUserJSON struct {
Email string `json:"email"`
Name string `json:"name"`
}
func createUser(w http.ResponseWriter, r *http.Request) {
var input createUserJSON
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
user, err := users.New(input.Email, input.Name)
if err != nil {
writeUserError(w, err)
return
}
save(user)
}createUserJSON puede estar incompleto. Su trabajo es representar lo que llegó por la red, incluida la ausencia de datos. No afirma ser un usuario.
users.New recibe la intención completa y solo entrega un valor que cumple la invariante:
package users
type User struct {
email string
name string
}
func New(email, name string) (User, error) {
normalized, err := normalizeEmail(email)
if err != nil {
return User{}, ErrInvalidEmail
}
name = strings.TrimSpace(name)
if name == "" {
return User{}, ErrEmptyName
}
return User{email: normalized, name: name}, nil
}
func (u User) Email() string { return u.email }
func (u User) Name() string { return u.name }Ahora la frontera de transporte traduce datos. El módulo de usuarios gobierna qué significa un usuario válido.
El DTO no es duplicación accidental#
createUserJSON y User contienen email y nombre, pero representan responsabilidades distintas:
| Tipo | Puede estar incompleto | Propietario | Motivo de cambio |
|---|---|---|---|
createUserJSON | sí | adaptador HTTP | cambia el formato de entrada |
users.User | no tras New | módulo users | cambian las reglas del usuario |
Un campo nuevo en el JSON no tiene por qué entrar en el dominio. Una normalización nueva no tiene por qué cambiar el payload. La traducción explícita evita que ambos contratos evolucionen como si fueran uno.
Eliminar el DTO reduce líneas hoy, pero fusiona dos APIs: la que acepta bytes externos y la que representa un usuario válido.
Actualizar no significa volver a abrir todos los campos#
Los setters genéricos reproducen el problema después de la creación:
func (u *User) SetEmail(email string) {
u.email = email
}El método permite un email vacío y no expresa por qué cambia. Una operación con intención conserva la regla:
func (u *User) ChangeEmail(email string) error {
normalized, err := normalizeEmail(email)
if err != nil {
return ErrInvalidEmail
}
u.email = normalized
return nil
}ChangeEmail puede añadir políticas, producir un evento o rechazar el cambio según el estado. El consumidor no repara el objeto desde fuera; pide una transición válida.
Persistencia necesita otra traducción#
Una base de datos también puede requerir campos planos. La misma regla se aplica: una fila no tiene por qué ser la entidad.
type userRow struct {
Email string
Name string
}
func restore(row userRow) (users.User, error) {
return users.New(row.Email, row.Name)
}Si los datos persistidos pueden ser históricos o corruptos, restore puede tener un contrato específico. Lo importante es no abrir los campos del dominio solo para facilitar Scan o un ORM.
El test protege el límite#
El caso útil no comprueba campos privados. Comprueba que no sale un usuario inválido:
func TestNewRejectsInvalidEmail(t *testing.T) {
_, err := users.New("not-an-email", "Ada")
if !errors.Is(err, users.ErrInvalidEmail) {
t.Fatalf("expected invalid email, got %v", err)
}
}
func TestChangeEmailKeepsPreviousValueOnFailure(t *testing.T) {
user, _ := users.New("old@example.com", "Ada")
err := user.ChangeEmail("invalid")
if !errors.Is(err, users.ErrInvalidEmail) {
t.Fatalf("expected invalid email, got %v", err)
}
if user.Email() != "old@example.com" {
t.Fatalf("email changed after rejection: %q", user.Email())
}
}La segunda prueba rechaza una implementación que asigna primero y valida después. La garantía conserva tanto el error como el estado anterior.
El transporte puede recibir datos inválidos. El objeto de dominio no necesita convertirse en esos datos para poder entenderlos.