14  5. Necesitamos controlar lo que sale

Si devuelves el objeto tal cual, filtras datos

NotaEn una frase

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: TaskPublic declara la forma pública; el filtrado no es un delete obj.secret manual sino una whitelist declarada.
  • Py response_model: FastAPI filtra el objeto ORM/dominio contra el schema declarado — password_hash nunca 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ón toPublic).
  • Go Structs públicos con tags json: — el filtro es el tipo: una struct TaskPublic distinta, 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

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"]

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 responder

response_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_model es 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 toPublic es el patrón estándar; algunos equipos usan class- transformer o Zod .pick() para lo mismo.
  • Go hace la frontera estructural: como los tags json son 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 futuro password_hash).
  • Blacklist en vez de whitelist: delete task.internal_notes funciona hasta que llega internal_secret_2.
  • Un solo modelo para todo: Task usado como entrada, fila de BD y respuesta — imposible de evolucionar sin romper clientes.
  • Olvidar los endpoints de lista: GET /tasks tambié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_model es 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

  1. Agrega a tu Task interno los campos owner_id, internal_notes, deleted_at; crea TaskPublic y verifica que GET /tasks/{id} no los filtra.
  2. Haz que GET /tasks (lista) devuelva una forma resumida (TaskSummary: solo id, title, done) y GET /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.

14.12 Lo que deberías saber hacer ahora