1. Dos responsabilidades: «como lo transferimos» vs «que significa»
Si acabas de empezar, apetece mucho pensar: «Los datos son solo datos». Pero en una aplicacion real los datos viven en dos mundos: el interno (tu codigo, las reglas del dominio) y el externo (JSON, clientes, integraciones, red). Estos mundos se parecen, pero no son iguales. Como minimo porque al mundo exterior le encantan las cadenas y null, mientras que al interno le encantan los invariantes y los tipos claros.
Fijemos los terminos:
Domain model (modelo de dominio) — estructuras y tipos que describen el significado del dominio y sus reglas. Por ejemplo, «una tarea debe tener un titulo no vacio», «el ID de una tarea es un numero positivo», «una tarea puede completarse».
DTO (Data Transfer Object) — estructuras para transferir datos a traves del limite de la aplicacion. En nuestro modulo HTTP, el limite es el contrato JSON del API: que campos hay y como se llaman, cuales son obligatorios y cuales opcionales.
Un esquema util que conviene tener en la cabeza:
flowchart LR
A["Peticion JSON"] --> B["DTO (solicitud)"]
B --> C["Dominio (comando/modelo)"]
C --> D["Resultado del dominio"]
D --> E["DTO (respuesta)"]
E --> F["Respuesta JSON"]
Suena aburrido, pero es justo ese diseno «aburrido» el que de repente hace que tu proyecto sea resistente.
2. Por que «una estructura para todo» resulta tan tentadora y en que acaba
Seamos sinceros: la idea de «una estructura para todo» parece genial. Escribimos type Task struct { ... json:"..." }, hacemos json.Marshal/json.Unmarshal y la vida es maravillosa. Ademas, puedes guardar esa misma estructura en memoria, devolversela al cliente y mostrarla al usuario… ¡una navaja suiza universal!
El problema es que esto es una navaja suiza sin hoja, pero con ocho sacacorchos.
Mezclar dominio y DTO suele llevar a tres tipos de dolor.
El primer dolor es que el dominio empieza a depender del contrato externo. De pronto no puedes renombrar un campo en Go porque «los clientes esperan el nombre antiguo en JSON». O no puedes cambiar el tipo porque «en JSON llegan cadenas».
El segundo dolor es que las reglas del dominio se difuminan. Aparecen campos como Title string sin validacion, ID int con valor cero, Done bool sin una transicion de estado clara, y la logica de negocio se desparrama por el codigo.
El tercer dolor es que no puedes hacer evolucionar el API. Hay que anadir un campo nuevo en la respuesta, pero no debe formar parte del dominio. O al reves: en el dominio aparece un nuevo campo interno, pero no se puede exponer hacia fuera. Cuando todo esta en una sola estructura, empiezan las danzas con json:"-", punteros, omitempty y el «simplemente no lo rellenemos».
Y en ese momento la estructura se convierte en «una maleta sin asa»: es incomodo llevarla, pero da miedo tirarla porque medio codigo depende de ella.
3. Modelo de dominio: reglas, invariantes y «tipos normales»
El modelo de dominio debe ser el lugar donde vive el significado. Aqui queremos que el estado sea valido y que los cambios de estado esten controlados. Y es muy deseable que el dominio no sepa nada de etiquetas JSON, HTTP ni de «como se llama el campo para el cliente». Al dominio le da igual como lo empaqueten para la red. El dominio va de que es esto.
Tomemos nuestro dominio de ejemplo tasks. Empecemos por lo simple: TaskID, Task y unas reglas minimas.
package main
import (
"errors"
"strings"
)
type TaskID int
type Task struct {
ID TaskID
Title string
Done bool
}
func NewTask(title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, errors.New("title must not be empty")
}
return Task{Title: title, Done: false}, nil
}
Fijate en un detalle importante: el dominio no acepta las cosas «tal cual», sino que las limpia y las valida. Eso son precisamente los invariantes: creamos solo tareas validas.
Ahora anadamos una «accion de dominio»: marcar una tarea como completada. Aunque sea solo Done = true, es util expresarlo con un metodo, porque mas adelante pueden aparecer reglas (por ejemplo, no se puede completar una tarea sin Title, no se puede «volver a completar», hay que registrar la hora, etc.).
package main
import "errors"
func (t *Task) MarkDone() error {
if t.Done {
return errors.New("task already done")
}
t.Done = true
return nil
}
Si, ahora mismo esto parece «demasiado formal para un solo bool». Pero justo asi te proteges de requisitos futuros, sin convertir el proyecto en «pasta de if».
4. DTO: contrato externo y «reglas de la calle»
El DTO es como un traductor. Habla el idioma del mundo exterior: JSON, cadenas, omitempty, a veces null, a veces «el campo puede faltar». No tiene por que ser bonito desde el punto de vista del dominio: tiene que ser estable y comprensible para el cliente.
Imaginemos el contrato minimo del API (a nivel de estructuras) que ya empezamos a fijar antes:
- creacion de tarea: el cliente envia { "title": "buy milk" }
- respuesta de la tarea: { "id": 1, "title": "buy milk", "done": false }
Hagamos los DTO:
package main
type CreateTaskRequest struct {
Title string `json:"title"`
}
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
Idea importante: el DTO obedece al contrato, no a la belleza del dominio. Por ejemplo, el dominio puede querer TaskID como tipo separado, mientras que el DTO casi seguro devolvera id como un numero normal, porque JSON no sabe nada de tu type TaskID int.
Ahora anadamos un ejemplo de «opcionalidad». A menudo en un API hay campos que pueden faltar. En el dominio esto puede estar estrictamente prohibido, mientras que en el DTO es aceptable. Por ejemplo, en la respuesta queremos devolver description, pero es opcional.
package main
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
Description *string `json:"description,omitempty"`
}
¿Por que *string y no string? Porque string tiene el zero value "", y no podras distinguir entre «se envio una cadena vacia» y «el campo no existia». Y el DTO a menudo esta obligado a distinguir estas cosas. El dominio, al contrario, suele intentar vivir sin «logica ternaria» («hay/no hay/vacio»), porque si no el cerebro del desarrollador empieza a echar humo.
5. Mapeo explicito y ejemplo
Cuando separas el dominio y el DTO, aparece la pregunta: «Vale, ¿y como convertimos?»
La respuesta en estilo Go: de forma explicita.
Ni mediante reflexion, ni con un «mapper universal», ni con 17 anotaciones. Simplemente funciones. Son cortas, legibles y se encuentran muy bien por todo el proyecto.
Empecemos con dominio → DTO (lo que devolvemos al cliente):
package main
func ToTaskDTO(t Task) TaskDTO {
return TaskDTO{
ID: int(t.ID),
Title: t.Title,
Done: t.Done,
}
}
Ahora DTO de la solicitud → comando de dominio. Aqui hay un punto importante: una solicitud de creacion todavia no es una Task. La tarea puede tener un ID que asignara el almacenamiento, y puede tener ademas un monton de campos internos. Por eso es util tener un tipo separado para la «entrada al dominio».
package main
import "strings"
type CreateTaskInput struct {
Title string
}
func ToCreateTaskInput(req CreateTaskRequest) CreateTaskInput {
return CreateTaskInput{
Title: strings.TrimSpace(req.Title),
}
}
Y despues el dominio ya recibe CreateTaskInput y decide que hacer con el. Por ejemplo:
package main
func CreateTask(input CreateTaskInput) (Task, error) {
return NewTask(input.Title)
}
Si, esto es casi un «proxy» para NewTask. Pero a cambio tienes los roles claramente separados: DTO — transporte, CreateTaskInput — limite del dominio, Task — entidad de dominio.
Mini‑ejemplo: creacion de una tarea sin servidor
No vamos a arrancar un servidor HTTP ahora mismo, pero podemos montar una pequena «simulacion» del limite: como si el JSON ya se hubiera parseado a un DTO, luego lo pasaramos por el dominio y obtuvieramos como respuesta otro DTO.
package main
import (
"fmt"
)
func simulateCreate(req CreateTaskRequest) (TaskDTO, error) {
input := ToCreateTaskInput(req)
task, err := CreateTask(input)
if err != nil {
return TaskDTO{}, err
}
// Imaginemos que el almacenamiento asigno ID=1
task.ID = TaskID(1)
return ToTaskDTO(task), nil
}
func main() {
dto, err := simulateCreate(CreateTaskRequest{Title: " buy milk "})
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("%+v\n", dto) // {ID:1 Title:buy milk Done:false}
}
Lo importante de este ejemplo es que podemos probar facilmente el dominio y el mapeo sin HTTP. Y cuando aparezca un transporte real (HTTP), simplemente «alimentara» a CreateTaskRequest y «consumira» TaskDTO.
6. Validacion: como evitar que las capas se peleen
La validacion es el lugar donde empiezan las discusiones filosoficas. Alguien dice «validamos en la entrada HTTP», otro dice «validamos solo en el dominio». La realidad suele ser esta: se puede validar en dos lugares, pero por razones distintas.
La capa de transporte (DTO) valida lo que pertenece al formato y al contrato: «el campo existe», «el tipo es correcto», «la cadena no es demasiado larga» (a veces), «el json se parseo bien». El dominio valida el significado: «el titulo no esta vacio», «no se puede completar una tarea ya completada», «no se puede borrar algo que no existe» (esto ya esta mas cerca de los errores de dominio).
Mostremos un tipo simple de error de validacion del dominio, que luego resulta comodo usar en el limite (por ejemplo, para rellenar fields en un error envelope).
package main
type ValidationError struct {
Fields map[string]string
}
func (e ValidationError) Error() string {
// Breve y seguro: el texto del error no es el contrato; el contrato es la estructura.
return "validation failed"
}
Ahora el dominio puede devolver un error asi:
package main
import "strings"
func NewTask(title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, ValidationError{
Fields: map[string]string{"title": "must not be empty"},
}
}
return Task{Title: title, Done: false}, nil
}
¿Por que es util? Porque arriba (en el limite) podras convertirlo en una respuesta comprensible para el cliente. Y lo mas importante: no necesitas parsear el texto del error (que ya de por si es una forma especial de castigo). Los errores en Go son valores, y perfectamente pueden llevar contexto. Esta idea (errores como valores + errores tipados) es muy caracteristica de Go y esta respaldada por las practicas estandar.
7. Cuando separar es obligatorio y cuando se puede simplificar
En este punto, a quienes empiezan les suele entrar miedo: «Si DTO y dominio son distintos, ¡entonces es mas codigo! ¡Significa que lo estoy complicando demasiado!»
Si, hay mas codigo. Pero aparece justo donde aparece una complejidad real: el limite. Y si casi no hay limite, se puede simplificar.
Lo de abajo no es una lista de «mandamientos», sino mas bien una sensacion que llega con la practica.
Si tienes un programa pequeno de «leer JSON → sacar JSON», sin reglas, sin almacenamiento, sin evolucion del contrato, entonces el DTO y el dominio a menudo coinciden. Ahi casi no hay dominio: solo tienes datos, y una estructura «para todo» no matara el proyecto, porque al proyecto no hay mucho que matar.
Pero en cuanto aparece al menos una de estas cosas: reglas del dominio, varias formas distintas de representar los datos, varios clientes, versiones del API, campos internos que no se pueden exponer, o simplemente el deseo de escribir codigo comprobable sin HTTP, la separacion empieza a rentabilizarse muy rapido.
Para no dejarlo «en sensaciones», aqui va una tabla‑chuleta corta:
| Situacion | ¿Se puede usar una sola estructura? | Por que |
|---|---|---|
| Prototipo rapido, una sola accion, sin almacenamiento | A veces si | Casi no hay reglas de dominio, el contrato no vive mucho tiempo |
| Hay reglas («title no esta vacio», «done no se puede revertir») | Mejor separar | El dominio debe proteger los invariantes con independencia del JSON |
| En el API aparecen campos opcionales (omitempty, null) | Casi siempre conviene separar | El DTO empieza a vivir en «logica ternaria», y eso molesta al dominio |
| Hay campos internos (por ejemplo, InternalNote, auditoria, flags tecnicos) | Separacion obligatoria | Las fugas de datos internos hacia fuera son un bug clasico |
| Queremos versionar el API (v1, v2) | Separacion obligatoria | El dominio es comun, pero los DTO de distintas versiones son distintos |
8. Errores tipicos al separar DTO y dominio
Error n.º 1: modelo de dominio con json‑tags «por si acaso».
Esto empieza de forma inocente: «bueno, es que asi me resulta comodo». Y luego, de repente, el dominio ya no se puede usar en ningun sitio salvo en HTTP, porque esta completamente impregnado de decisiones de transporte. Al final, cualquier cambio en el contrato externo empieza a romper estructuras internas y tests que, en teoria, iban de logica de negocio y no de JSON.
Error n.º 2: el DTO empieza a “pensar” y a guardar reglas del dominio.
A veces el desarrollador escribe metodos en CreateTaskRequest del tipo func (r CreateTaskRequest) ValidateBusinessRules() error. Esto parece logico, pero rompe el limite: las reglas de negocio deben vivir en el dominio; si no, se untan por distintas entradas (HTTP, CLI, importacion desde archivo) y acaban divergiendo inevitablemente.
Error n.º 3: los convertidores hacen «correcciones silenciosas» de los datos sin una decision explicita.
Por ejemplo, en el mapeo coges Title, recortas espacios, limitas la longitud a 128, cambias tabuladores por espacios, y todo ello en silencio. A veces normalizar la entrada es util, pero es importante no convertir el mapeo en una «lavadora del significado». Si los datos se corrigen, eso debe hacerse de forma consciente y, preferiblemente, en un solo lugar (a menudo en el dominio); si no, luego no entenderas por que “el usuario envio una cosa y se guardo otra”.
Error n.º 4: intentar hacer un “mapper universal” mediante reflection demasiado pronto.
Sobre el papel eso ahorra lineas; en la practica, roba comprension. Para quienes empiezan, esto es especialmente doloroso: los errores de mapeo se vuelven menos obvios, el IDE ayuda peor y la depuracion se convierte en arqueologia. Las funciones explicitas de 5 a 8 lineas casi siempre se leen y se mantienen mejor.
Error n.º 5: el error del dominio se convierte en un “protocolo textual”.
Cuando arriba empiezan a hacer if strings.Contains(err.Error(), "not found") { ... }, en realidad estas construyendo un contrato sobre un texto que ni siquiera tiene por que ser estable. En Go es habitual reconocer clases de errores por valores y tipos (sentinel/typed errors, errors.Is/As).
GO TO FULL VERSION