flowchart LR
S["Servicio devuelve<br/>Task interno<br/>(todos los campos)"] --> F{"Contrato de salida<br/>TaskPublic"}
F -->|"campos públicos"| J["JSON 200<br/>{ id, title, done, created_at }"]
F -->|"campos privados"| X["Descartados:<br/>owner_id, internal_notes, deleted_at"]
14 5. Necesitamos controlar lo que sale
Si devuelves el objeto tal cual, filtras datos
Vas a aprender a definir qué campos salen de tu API: un contrato de salida (TaskPublic) actúa como aduana — lo no declarado no sale, así tu password_hash futuro nunca se filtre por accidente.
14.1 El problema
La tarea en memoria ahora tiene más campos de los que el cliente debe ver:
La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.
Task interno = {
id, title, done,
owner_id, // quién la creó — el cliente no necesita saberlo
internal_notes, // notas del equipo
created_at, updated_at,
deleted_at, // soft-delete interno
}
La tentación universal del que viene de frontend: res.json(task) y ya — total, “es mi objeto”. El problema: todo lo que devuelves queda grabado en el contrato. Si hoy sale owner_id, mañana alguien escribe frontend contra ese campo, y cuando quieras quitarlo rompes clientes. Peor: si el objeto interno contiene password_hash (Cap. 14), acabas de publicar tu base de datos.
Un objeto es una colección de datos con nombre: {name: "Ana", age: 30} — cada dato es una propiedad (clave → valor). Python los llama dict, Go los arma con struct, TS con objetos/interface.
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
14.2 Cómo lo resuelve un equipo
La regla es el espejo del capítulo anterior:
Nada sale del servidor sin pasar por el contrato de salida.
El equipo define explícitamente qué campos son públicos — el TaskPublic — y el serializador filtra todo lo demás. Así, aunque el servicio devuelva el objeto interno completo (con campos privados), la capa de salida actúa como aduana: solo sale lo declarado.
Ejemplo concreto de la frontera — mismo objeto interno, dos salidas:
// Objeto interno (nunca sale)
{ "id": 7, "title": "deploy", "done": false,
"owner_id": 3, "internal_notes": "rev 2 del plan",
"deleted_at": null, "created_at": "2026-09-22T10:00:00Z" }
// GET /tasks/7 → TaskPublic (detalle)
{ "id": 7, "title": "deploy", "done": false,
"created_at": "2026-09-22T10:00:00Z" }
// GET /tasks → TaskSummary (lista)
{ "id": 7, "title": "deploy", "done": false }
Hay una consecuencia arquitectónica importante: llegamos a tres formas distintas del mismo concepto:
| Forma | Rol | Vive en |
|---|---|---|
TaskCreate |
lo que entra | schema de validación (Cap. 4) |
Task (interno) |
lo que se guarda | modelo de datos (Cap. 10) |
TaskPublic |
lo que sale | contrato de respuesta |
Un junior mezcla las tres en una sola clase “Task” — y paga el precio cada vez que el contrato debe cambiar sin tocar la base de datos (o viceversa).
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
14.3 Conceptos nuevos
- U Serialización: convertir el objeto interno a JSON de respuesta — y decidir qué campos cruzan la frontera.
- U DTO/contrato de salida:
TaskPublicdeclara la forma pública; el filtrado no es undelete obj.secretmanual sino una whitelist declarada. - Py
response_model: FastAPI filtra el objeto ORM/dominio contra el schema declarado —password_hashnunca sale aunque el servicio lo devuelva.
El esquema es el plano de la base de datos: qué tablas hay, qué columnas tiene cada una y de qué tipo. Es contrato: una fila que no cumple el esquema no entra. Se cambia con migraciones, no a mano.
Un ORM (Object-Relational Mapper) traduce entre objetos del código y filas de la tabla: task.save() en vez de INSERT. Cómodo para el CRUD, peligroso si no sabes qué SQL genera (el N+1 nace ahí).
- TS Mapeo explícito o schema de salida con Zod: la forma pública se construye a mano (
pick, funcióntoPublic). - Go Structs públicos con tags
json:— el filtro es el tipo: una structTaskPublicdistinta, convertida explícitamente. - U Serialización no trivial: fechas, UUIDs, enums,
Decimal— qué se convierte solo a JSON y qué necesita ayuda en cada lenguaje.
14.4 La explicación visual
La dirección importa: es whitelist (“sale solo lo declarado”), no blacklist (“quito lo que sé que es secreto”). La blacklist falla el día que alguien agrega un campo nuevo y olvida filtrarlo; la whitelist no filtra nada que no se haya declarado público.
14.5 Implementación
// La tentación: devolver el objeto tal cual
app.get("/tasks/:id", (req, res) => {
const task = findTask(Number(req.params.id));
res.json(task); // ← owner_id e internal_notes salen gratis 😬
});
// El "arreglo" ingenuo — blacklist manual:
const { owner_id, internal_notes, deleted_at, ...pub } = task;
res.json(pub); // funciona… hasta que llega el próximo campo privado// La forma pública se construye explícitamente — función o pick de zod
interface Task {
id: number;
title: string;
done: boolean;
owner_id: number; // interno
internal_notes: string; // interno
created_at: string;
}
interface TaskPublic {
id: number;
title: string;
done: boolean;
created_at: string;
}
function toPublic(t: Task): TaskPublic {
return {
id: t.id,
title: t.title,
done: t.done,
created_at: t.created_at,
};
}
app.get("/tasks/:id", (req, res) => {
const task = findTask(Number(req.params.id));
if (!task) return res.status(404).json({ error: "task not found" });
res.json(toPublic(task)); // la aduana, antes de salir
});// class-transformer — la whitelist son decoradores sobre la clase
class TaskPublic {
@Expose() id: number;
@Expose() title: string;
@Exclude() owner_id: number; // o simplemente: no declararlo
}
res.json(plainToInstance(TaskPublic, task));Qué te cuesta: NestJS lo activa global con ClassSerializerInterceptor — la aduana automática estilo FastAPI, pero pagas el framework entero. Con Express puro, la función toPublic honesta es lo que verás en la mayoría de codebases.
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
task = find_task(task_id)
# whitelist manual — funciona pero se desincroniza del contrato
return {"id": task.id, "title": task.title, "done": task.done}class TaskPublic(BaseModel):
id: int
title: str
done: bool
created_at: datetime
@app.get("/tasks/{task_id}", response_model=TaskPublic)
def get_task(task_id: int):
task = find_task(task_id) # devuelve el objeto interno completo
if not task:
raise HTTPException(404, "task not found")
return task # FastAPI lo filtra contra TaskPublic antes de responderresponse_model es la aduana: aunque task tenga owner_id y internal_notes, el JSON de salida solo lleva los campos declarados.
# Django REST Framework — el serializer ES la clase que filtra
class TaskPublicSerializer(serializers.Serializer):
id = serializers.IntegerField()
title = serializers.CharField()
done = serializers.BooleanField()
# return Response(TaskPublicSerializer(task).data)Qué te cuesta: DRF/Marshmallow son la generación anterior — mismo concepto de whitelist declarada, más verboso y sin type hints gratis. El patrón es idéntico: declarar lo público, filtrar lo demás.
// Dos structs: la interna y la pública. El filtro ES el tipo.
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
OwnerID int `json:"-"` // nunca se serializa
InternalNotes string `json:"-"`
CreatedAt string `json:"created_at"`
}
// Alternativa explícita cuando las formas divergen de verdad:
type TaskPublic struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt string `json:"created_at"`
}
func toPublic(t Task) TaskPublic {
return TaskPublic{ID: t.ID, Title: t.Title, Done: t.Done, CreatedAt: t.CreatedAt}
}json:"-" es el caso simple (“este campo jamás sale”); una struct separada es el caso general (“la forma pública difiere de la interna”).
// jsoniter / goccy — drop-in más rápido, mismos tags:
// import jsoniter "github.com/json-iterator/go"
// json.NewEncoder(w) → jsoniter.ConfigFastest.NewEncoder(w)Qué te cuesta: los encoders alternativos solo cambian velocidad, no el modelo — la frontera sigue siendo la struct con tags. En Go no hay “serializers mágicos” populares: el tipo es el contrato, y eso es una decisión de diseño del lenguaje.
Crea un contrato de salida para mi API de tareas en
[Express+TypeScript / FastAPI+Python / Go]. El objeto interno Task tiene
campos privados (owner_id, internal_notes, deleted_at) que NUNCA deben
salir en el JSON. Requisitos: forma pública TaskPublic con solo
{id, title, done, created_at}, whitelist declarativa (no blacklist),
aplicada a GET /tasks/{id} y a la lista GET /tasks con una forma
resumida TaskSummary. Muéstrame el JSON que sale vs el objeto interno
para comprobar el filtrado.
14.6 ¿Por qué cada stack lo hace así?
Lo único que necesitas llevarte: “qué sale” es una whitelist declarada en los tres — FastAPI la pone en la firma (response_model), Express te deja construirla a mano (toPublic), Go la graba en el tipo (json:"-"). El detalle:
Una firma (HMAC) prueba que un mensaje es auténtico y no fue tocado: se calcula con un secreto sobre el contenido. Los webhooks la usan para que verifiques “esto realmente vino de Stripe”.
- FastAPI hace la frontera declarativa y automática:
response_modeles tan visible en la firma del endpoint que es difícil olvidarlo — y de regalo genera el schema en OpenAPI (la documentación). - Express/Node no tiene esa capa: la disciplina es 100% tuya. La función
toPublices el patrón estándar; algunos equipos usanclass- transformero Zod.pick()para lo mismo. - Go hace la frontera estructural: como los tags
jsonson parte del tipo, “qué sale” lo decide el compilador — pero por eso mismo necesitas una struct distinta por forma pública. Verborrea a cambio de que sea imposible filtrar por accidente.
La lección universal: entrada, almacenamiento y salida son tres contratos distintos. Cualquier atajo que los fusione (“devuelvo el objeto de BD directo”) es un agujero de seguridad esperando un campo nuevo.
Esta decisión es estructural: la frontera “qué sale” debe existir — es carga del edificio, no decoración. Lo intercambiable es dónde vive:
| Alternativa | Qué es | Qué te cuesta | Cuándo elegirla |
|---|---|---|---|
| Schema/DTO en código (este capítulo) | TaskPublic/response_model/struct |
Una declaración extra por forma | El default — explícito y testeable |
Selección en la query (SQL SELECT id, title) |
La BD nunca devuelve lo privado | No protege si otro código usa la fila completa | Excelente complemento, insuficiente solo |
| Codegen desde OpenAPI | El contrato genera los tipos | Toolchain + codegen en CI | Contratos publicados a terceros |
| GraphQL (cliente pide campos) | El filtro lo hace la query del cliente | Todo el stack GraphQL; auth por campo | Cuando los clientes necesitan formas muy variables |
14.7 Errores comunes
res.json(row)directo del ORM/driver: publicas el esquema interno — incluyendo campos que aún no existen pero existirán (el futuropassword_hash).- Blacklist en vez de whitelist:
delete task.internal_notesfunciona hasta que llegainternal_secret_2. - Un solo modelo para todo:
Taskusado como entrada, fila de BD y respuesta — imposible de evolucionar sin romper clientes. - Olvidar los endpoints de lista:
GET /taskstambién debe pasar por la aduana — filtrar mil objetos es el mismo contrato, solo en plural.
14.8 Buenas prácticas
- Nombra la frontera:
TaskPublic,toPublic(),response_model— que sea visible en el código que hay una frontera. - El contrato de salida define la documentación: si usas FastAPI, tu
response_modeles el OpenAPI; si no, mantén la docu alineada a mano. - Campos internos prefijados o segregados (
internal_*) — que el nombre mismo avise. - Versionar la forma, no el campo: si la salida cambia de forma, es una decisión de contrato (¿
v2? ¿campo nuevo opcional?), no un refactor invisible.
14.9 Ejercicio
- Agrega a tu
Taskinterno los camposowner_id,internal_notes,deleted_at; creaTaskPublicy verifica queGET /tasks/{id}no los filtra. - Haz que
GET /tasks(lista) devuelva una forma resumida (TaskSummary: soloid,title,done) yGET /tasks/{id}la completa. Dos contratos de salida, un mismo recurso.
Un array es una colección ordenada de elementos accedidos por posición: ["a","b","c"][0] es "a" (se cuenta desde 0). Python las llama listas, Go slices — misma idea, distinto acento.
14.10 Mini reto
Devuelve la misma tarea en dos formas según quién pregunta: el owner ve internal_notes, los demás no. ¿Dónde debería vivir esa decisión — en el serializador o antes? Diseña la solución; la implementamos en el capítulo de autorización.
La autorización responde “¿qué puedes hacer?”: eres usuario válido (autenticado), pero ¿puedes borrar esta tarea? Se decide por rol o por ownership — y se verifica en cada request, no se recuerda.
Ejercicio 1. TaskPublic con id, title, done, created_at filtra los tres campos internos — GET /tasks/7 responde solo lo público.
Ejercicio 2. Dos contratos: TaskSummary {id, title, done} para GET /tasks y TaskPublic completo para GET /tasks/{id}. La razón de diseño: la lista viaja miles de veces y se pinta en tarjetas — traer campos de detalle es peso muerto y exposición innecesaria.
Mini reto. La decisión “quién pregunta” es autorización, no serialización: vive en el servicio/handler (con el usuario del request ya resuelto — Cap. 16–17), que elige el contrato TaskOwnerView vs TaskPublic. El serializador solo filtra; decidir qué forma corresponde a cada quién es lógica de negocio. Si la decisión viviera en el serializador, éste tendría que conocer auth — capas invertidas.
14.11 Vocabulario técnico del capítulo
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.
Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.
Un booleano es un valor de dos estados: true o false. Es el resultado de toda comparación (age > 18) y lo que los if evalúan. Nombrado por George Boole, el matemático de la lógica.
null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.
Un diccionario (dict en Python, map en Go, objeto en JS) guarda parejas clave→valor: {"ana": 30}. Es la estructura que más aparece en JSON — un objeto JS es un diccionario.
Un framework es un esqueleto de aplicación ya decidido: te da la estructura (rutas, validación, errores) y tú llenas la lógica. Diferencia con librería: la librería la llamas tú; el framework te llama a ti.
Un compilador traduce código a otra forma: TypeScript → JavaScript (transpila), Go → binario (compila). Atrapa errores antes de ejecutar. tsc, esbuild, go build son compiladores.
Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.
La autenticación responde “¿quién eres?” — login, contraseña, token. Se confunde con autorización (“¿qué puedes hacer?”), que es la pregunta siguiente. Primero te identificas, luego te dejan o no pasar.