API-DD

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.

Por Lautaro Mei

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:

go
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:

go
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:

go
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:

go
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:

TipoPuede estar incompletoPropietarioMotivo de cambio
createUserJSONadaptador HTTPcambia el formato de entrada
users.Userno tras Newmódulo userscambian 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:

go
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:

go
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.

go
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:

go
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.