1. POST sobre la colección: creación en /reading-list
Cuando una API no solo se convierte en un «escaparate», sino también en una «caja registradora», aparece una nueva responsabilidad: cambiar el estado del sistema de forma predecible y uniforme para todos los clientes. El primer paso más común es crear un nuevo recurso dentro de la colección, y en HTTP esto tradicionalmente se expresa mediante POST sobre la URI de la colección. En nuestro caso, la colección es la lista de lectura, por lo tanto, creamos un elemento precisamente a través de POST /api/v1/reading-list.
Es importante captar la imagen conceptual. La URI /api/v1/reading-list es la «lista completa» (colección). Si el cliente hace un GET, pide que se muestre el contenido. Si el cliente hace un POST, dice: «agrega un nuevo elemento a esta lista». No «reemplaza toda la lista», no «actualiza algo por id», sino precisamente «crea un nuevo objeto, y los detalles te los envío en el body».
Así es como se ve la solicitud a nivel humano (en Postman verías aproximadamente lo mismo, solo que sin el formato HTTP bruto):
POST /api/v1/reading-list HTTP/1.1
Content-Type: application/json
{
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": "Encontrar edición impresa"
}
Nótese un detalle muy característico: en la solicitud no hay id. Esto no es un descuido del cliente, es nuestro acuerdo: el identificador se crea en el servidor, porque es el servidor el responsable de la unicidad y la integridad interna de los datos. Si el id viniera del cliente, muy pronto llegaríamos al divertido juego de «adivina quién sobrescribió accidentalmente el libro de otro» (spoiler: normalmente quien lo adivina es el desarrollador backend a las 3 de la madrugada).
2. CreateReadingItemRequest
Cuando haces una operación create por primera vez, la mano se estira sola: «Bueno, tenemos ReadingListItem, vamos a deserializarlo directamente desde JSON». Es un impulso normal, pero rápidamente genera confusión: el modelo de dominio empieza a vivir según las reglas de la capa HTTP, y la capa HTTP de repente conoce detalles del dominio que no debería saber. Por eso creamos un request DTO separado: CreateReadingItemRequest.
La idea es muy simple: un request DTO describe exactamente lo que el cliente tiene derecho a enviar al crear. El cliente envía datos sobre el libro y el estado deseado, y el servidor agrega lo que pertenece a la «cocina interna»: id, posibles valores por defecto y reglas de aplicación. En nuestro proyecto, es conveniente implementar el request DTO como un record, porque es un «contenedor de datos», no un objeto con un comportamiento complejo.
package com.example.readlater.readinglist.dto;
import com.example.readlater.readinglist.domain.ReadingStatus;
// Request DTO: describe solo lo que el cliente tiene derecho a enviar al crear
public record CreateReadingItemRequest(
String title, // Título del libro (obligatorio por significado)
String author, // Autor (obligatorio por significado)
ReadingStatus status, // Estado deseado al inicio
String externalId, // Identificador en el catálogo externo (puede ser null)
String comment // Comentario del usuario (puede ser null)
) {}
Aquí hay otro matiz práctico importante. Los campos externalId y comment son opcionales, es decir, pueden ser null. En el nivel de esta lección no profundizamos en la validación detallada de cadenas vacías y «¿qué pasa si el estado no llegó en absoluto?», porque ahora nuestro objetivo es construir un flujo create comprensible y delimitar las fronteras de las capas. Pero incluso sin una validación completa, el request DTO ya es útil: separa el contrato del JSON entrante de cómo almacenamos el objeto internamente.
3. Handler: body → CreateReadingItemRequest
En un mundo sin Spring MVC, el handler es el lugar donde de repente te conviertes en «controlador», «enlazador», y un poco en «esa persona que lee bytes de un InputStream». Esto es normal para un proyecto de aprendizaje, pero es importante mantener el control: el handler debe encargarse de la parte HTTP (leer la solicitud → obtener los datos → llamar al servicio), no convertirse en una bolsa de lógica de negocio y reglas de almacenamiento. En nuestro proyecto, este es el mismo ReadingListHttpHandler, en el que luego vivirán el resto de las rutas de escritura; por ahora solo nos interesa la rama create.
Empecemos con una pequeña utilidad: leer el body de la solicitud cuidadosamente como una cadena. Sí, este es el momento en el que te encuentras de nuevo con InputStream y entiendes por qué a los frameworks les gusta tanto ocultarlo de las personas (se preocupan por tu salud psicológica).
package com.example.readlater.common.util;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public final class HttpBodyReader {
private HttpBodyReader() {
// Clase de utilidad: no se necesitan instancias
}
public static String readBody(HttpExchange exchange) throws IOException {
// Es importante cerrar el InputStream: usamos try-with-resources
try (var is = exchange.getRequestBody()) {
// Leemos el cuerpo de la solicitud por completo y lo decodificamos como UTF-8
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
Ahora en el handler realizamos tres acciones simples: leer el body, deserializar el JSON en CreateReadingItemRequest, pasar el DTO al servicio. Nótese: aquí todavía no hay «cómo almacenar», «cómo generar el id», «cómo verificar la unicidad». El handler no está obligado a saber esto; simplemente pasa la solicitud más adelante.
package com.example.readlater.readinglist.http;
import com.example.readlater.common.util.HttpBodyReader;
import com.example.readlater.readinglist.domain.ReadingListItem;
import com.example.readlater.readinglist.dto.CreateReadingItemRequest;
import com.example.readlater.readinglist.service.ReadingListService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
public class ReadingListHttpHandler {
private final ObjectMapper objectMapper;
private final ReadingListService readingListService;
public ReadingListHttpHandler(ObjectMapper objectMapper, ReadingListService readingListService) {
// Las dependencias se pasan explícitamente desde fuera: el handler no «crea» nada por sí mismo
this.objectMapper = objectMapper;
this.readingListService = readingListService;
}
public ReadingListItem handleCreate(HttpExchange exchange) throws IOException {
// 1) Obtenemos el body como cadena
String body = HttpBodyReader.readBody(exchange);
// 2) Convertimos el JSON en un request DTO (contrato de datos de entrada)
CreateReadingItemRequest request = objectMapper.readValue(body, CreateReadingItemRequest.class);
// 3) Delegamos el significado de la aplicación al servicio
return readingListService.create(request);
}
}
Nótese: handleCreate aquí solo nos lleva hasta el objeto de dominio creado. La respuesta HTTP hacia el exterior aún no se construye aquí: primero es importante obtener un created correcto, sin mezclar la lógica de transporte con el servicio y el almacenamiento.
4. Service: creamos el item y verificamos externalId
El servicio es el lugar donde finalmente empezamos a hacer cosas «de aplicación», no «de transporte». Si el handler es la recepción (atiende amablemente la solicitud y la dirige más adelante), entonces el servicio es el gestor del proceso: decide qué significa exactamente «crear un elemento de la lista de lectura» en términos de nuestra aplicación. El servicio no debe leer JSON ni escribir bytes en el body de la respuesta, pero debe ser el dueño de las reglas de negocio.
Para empezar, mostramos cómo se ve la entidad de dominio en una forma simplificada. Tenemos id, campos obligatorios, estado y un par de valores opcionales. A diferencia del DTO, el objeto de dominio casi siempre vive «más tiempo» y puede tener métodos para cambiar su estado.
package com.example.readlater.readinglist.domain;
public class ReadingListItem {
private final long id;
private String title;
private String author;
private ReadingStatus status;
private String externalId;
private String comment;
public ReadingListItem(long id, String title, String author, ReadingStatus status, String externalId, String comment) {
// id se asigna en la creación y no cambia después
this.id = id;
// El resto de campos son el estado del objeto de dominio
this.title = title;
this.author = author;
this.status = status;
this.externalId = externalId;
this.comment = comment;
}
public long getId() {
// id es un identificador técnico, necesario para el almacenamiento/referencias
return id;
}
}
Ahora el método create del servicio. Hace exactamente lo que promete: verifica la regla de aplicación de unicidad de externalId (si es que externalId está definido), pide al repository que emita un nuevo id, ensambla el objeto de dominio y lo guarda. Nada de HTTP, nada de anotaciones Jackson, solo significado de aplicación.
package com.example.readlater.readinglist.service;
import com.example.readlater.readinglist.domain.ReadingListItem;
import com.example.readlater.readinglist.dto.CreateReadingItemRequest;
import com.example.readlater.readinglist.repository.ReadingListRepository;
public class ReadingListService {
private final ReadingListRepository repository;
public ReadingListService(ReadingListRepository repository) {
// El servicio depende de la abstracción del repositorio, no de una implementación concreta
this.repository = repository;
}
public ReadingListItem create(CreateReadingItemRequest request) {
// Regla de aplicación: si externalId está definido, debe ser único
if (request.externalId() != null && repository.existsByExternalId(request.externalId())) {
// En un proyecto de aprendizaje, una excepción simple; más tarde se puede reemplazar por un error de dominio/HTTP
throw new IllegalStateException("externalId ya existe: " + request.externalId());
}
// La generación del id es parte de la estrategia de almacenamiento, por lo tanto se hace a través del repository
long id = repository.nextId();
// El mapeo DTO -> entidad de dominio se realiza en el servicio
ReadingListItem item = new ReadingListItem(
id,
request.title(),
request.author(),
request.status(),
request.externalId(),
request.comment()
);
// El guardado es una responsabilidad separada del repositorio
return repository.save(item);
}
}
¿Por qué el id se genera aquí (a través del repositorio) y no en el handler? Porque la generación del identificador es parte de las «reglas de almacenamiento». Hoy tenemos un AtomicLong en memoria, mañana podría ser un UUID, pasado mañana una base de datos con su propia estrategia de generación. El handler no debería saber en absoluto qué mecanismo específico emite nuevos id; su tarea es recibir solicitudes HTTP, no gestionar la numeración interna.
Por separado, sobre externalId: es una «referencia» a un catálogo externo de libros. Acordamos de antemano un invariante: si externalId está definido, debe ser único dentro de la lista de lectura. En la práctica, esto evita duplicados como «agregué el mismo libro tres veces y ahora no sé cuál es FINISHED». Sí, el factor humano no se puede vencer, pero al menos no hay que ayudarlo.
5. Repository: almacenamiento en memoria
El repositorio es la capa que hace lo desagradable pero necesario: «poner el objeto en algún lugar para luego poder recuperarlo». En nuestro proyecto de aprendizaje, es un Map dentro del proceso, sin base de datos ni magia. Pero incluso en una forma tan simple, el repositorio es útil: separa el servicio de la forma específica de almacenamiento y permite mantener un único punto de escritura/lectura.
Empecemos con la interfaz. En ella es importante ver dos cosas: el método nextId y el método save(...). Es decir, nuestro repositorio no es solo una «tabla», sino también una fuente de identificadores dentro de la estrategia de almacenamiento elegida.
package com.example.readlater.readinglist.repository;
import com.example.readlater.readinglist.domain.ReadingListItem;
import java.util.Optional;
public interface ReadingListRepository {
// Fuente de nuevos ids (la estrategia puede cambiar: seq, UUID, BD, etc.)
long nextId();
// Guardamos la entidad de dominio (no el request DTO)
ReadingListItem save(ReadingListItem item);
// Consulta al almacenamiento: obtener por id interno
Optional<ReadingListItem> findById(long id);
// Consulta al almacenamiento: verificar invariante por externalId
boolean existsByExternalId(String externalId);
}
Ahora una implementación simple en memoria. No hay nada «de producción» aquí, pero hay una claridad importante: AtomicLong se encarga de la secuencia de id, Map se encarga del almacenamiento, y el servicio no sabe en absoluto cómo funciona esto exactamente.
package com.example.readlater.readinglist.repository;
import com.example.readlater.readinglist.domain.ReadingListItem;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
public class InMemoryReadingListRepository implements ReadingListRepository {
// Almacenamiento en memoria: clave — id interno
private final Map<Long, ReadingListItem> storage = new HashMap<>();
// Secuencia para la generación de ids
private final AtomicLong seq = new AtomicLong(0);
@Override
public long nextId() {
// Incrementamos el contador y devolvemos el nuevo valor
return seq.incrementAndGet();
}
@Override
public ReadingListItem save(ReadingListItem item) {
// Guardamos/actualizamos por id
storage.put(item.getId(), item);
return item;
}
}
Y aquí la verificación de externalId es un ejemplo de que incluso un repositorio en memoria puede contener «consultas» útiles al almacenamiento. Sí, la implementación es lineal (O(n)), pero para un proyecto de aprendizaje y un número pequeño de elementos es normal, y la ganancia semántica es grande: la regla de unicidad está expresada explícitamente.
package com.example.readlater.readinglist.repository;
public class InMemoryReadingListRepository implements ReadingListRepository {
// storage y seq como en el fragmento anterior
@Override
public boolean existsByExternalId(String externalId) {
// Búsqueda lineal sobre los valores: suficiente para un proyecto de aprendizaje
return storage.values().stream()
.anyMatch(item -> externalId.equals(item.getExternalId()));
}
}
Si ahora estás pensando «¿por qué no mantenemos un segundo Map por externalId?», te felicito: has empezado a pensar como un ingeniero que un día optimizará todo… a veces incluso sin necesidad. Para el curso, es más importante la transparencia y un conjunto mínimo de construcciones. Cuando aparezcan bases de datos y volúmenes reales, entonces habrá otras reglas del juego.
6. Flujo de datos y contrato de las capas
Cuando hay más código, al cerebro le gusta mucho tener una imagen: quién llama a quién y dónde termina la responsabilidad de una capa y comienza la de otra. Esto es normal: al cerebro le gusta el orden, y al caos le gusta Main.java de 2000 líneas. Por lo tanto, ahora es útil fijar el «contrato» entre handler/service/repository y ver el flujo de datos desde el JSON hasta el objeto de dominio guardado.
Aquí hay una tabla compacta que normalmente salva del deseo de «voy a agregar un poquito de lógica de negocio en el handler, solo un poquito»:
| Capa | Qué recibe como entrada | Qué entrega después | De qué es responsable en significado |
|---|---|---|---|
| handler | HttpExchange (method, path, headers, body) | CreateReadingItemRequest → service | HTTP: leer la solicitud, parsear JSON, llamar al código de aplicación |
| service | CreateReadingItemRequest | ReadingListItem → repository | Significado de la aplicación: reglas de creación, generación de id, invariantes |
| repository | ReadingListItem | ReadingListItem (guardado) | Almacenamiento: dónde poner, cómo encontrar, cómo verificar la unicidad |
Y ahora la misma idea en forma de diagrama. Si te gustan los diagramas, este es tu momento; si no te gustan, solo debes saber que a los diagramas les gustas tú (y los encontrarás en proyectos ajenos).
flowchart TD
%% Flujo de datos desde el cliente hasta el almacenamiento y viceversa
C["Postman / Cliente"] -->|POST /api/v1/reading-list JSON body| H[ReadingListHttpHandler]
H -->|CreateReadingItemRequest| S[ReadingListService]
S -->|nextId, save| R[ReadingListRepository]
R -->|Saved ReadingListItem| S
S -->|Created ReadingListItem| H
H -->|HTTP response, por ahora mínimo| C
Y, para que todo esto funcione realmente en la aplicación, necesitamos «conectar» los objetos en el composition root (normalmente es ReadLaterApplication o una clase de wiring separada, como hemos hecho antes). El ejemplo es extremadamente directo, sin contenedores ni magia:
import com.example.readlater.readinglist.http.ReadingListHttpHandler;
import com.example.readlater.readinglist.repository.InMemoryReadingListRepository;
import com.example.readlater.readinglist.service.ReadingListService;
import com.fasterxml.jackson.databind.ObjectMapper;
// 1) Infraestructura de almacenamiento
InMemoryReadingListRepository repo = new InMemoryReadingListRepository();
// 2) Capa de aplicación
ReadingListService service = new ReadingListService(repo);
// 3) Infraestructura de serialización/deserialización JSON
ObjectMapper objectMapper = new ObjectMapper();
// 4) Capa HTTP, que llama al servicio
ReadingListHttpHandler handler = new ReadingListHttpHandler(objectMapper, service);
Sí, es «ensamblaje manual». Sí, en proyectos grandes esto cansa rápidamente. Y sí, es precisamente con este cansancio que luego se explica muy bien para qué existe la DI y por qué a la gente le gusta que el wiring lo haga una herramienta y no el programador manualmente. Pero ahora es más importante para nosotros ver la mecánica explícitamente y entender qué está conectado con qué.
7. Errores típicos en la operación create
Los errores en las operaciones create casi siempre nacen de una causa: mezclamos niveles de abstracción. Un poco de dominio en el handler, un poco de HTTP en el servicio, una pizca de JSON en el repositorio, y ya no tienes un proyecto, sino un programa de cocina «mezcla todo lo que encontraste en la nevera». Al principio parece «más rápido», pero luego destroza la cabeza con cada cambio.
Error nº 1: generar el id en el handler porque «bueno, ya estoy aquí».
Es muy tentador: has leído el JSON, y justo ahí quieres hacer long id = ..., ensamblar el objeto y ponerlo en el Map. Pero así la capa HTTP empieza a saber cómo está organizado el almacenamiento, y pierdes flexibilidad. Hoy AtomicLong, mañana otra estrategia, y de repente necesitas reescribir la capa web debido a un cambio en la capa de almacenamiento: eso es un mal acoplamiento.
Error nº 2: guardar CreateReadingItemRequest en el repositorio en lugar de ReadingListItem.
Un DTO es una forma de transporte. Si pones un DTO en un Map, estás diciendo efectivamente: «mi modelo de dominio es lo que el cliente envió». Entonces, cualquier cambio en el contrato JSON rompe inmediatamente el almacenamiento y la lógica de negocio. Además, pierdes la posibilidad de agregar cuidadosamente campos internos (como el id) y métodos de dominio, porque todo empieza a ser «simplemente datos de la solicitud».
Error nº 3: hacer la verificación de unicidad de externalId después de save(...).
A veces te encuentras con una lógica del tipo «primero guardamos, luego verificamos». Esto es especialmente peligroso, porque puedes alcanzar a estropear el estado del almacenamiento y luego intentar «deshacerlo». En nuestro mundo simplificado no hay deshacer, por lo que la regla es más simple: primero verificamos, luego guardamos, y solo entonces consideramos la operación exitosa.
Error nº 4: dispersar la lógica de mapeo por todo el código.
Hoy escribes new ReadingListItem(id, request.title(), ...) una vez, mañana se copia en otros dos lugares, luego se añade un nuevo campo, y olvidas actualizar uno de los fragmentos. Como resultado, algunos objetos se crean con un conjunto de campos, otros con otro. Incluso en un proyecto pequeño, es mejor mantener el mapeo en un solo lugar (normalmente en el servicio o en un pequeño método mapper junto a él).
Error nº 5: considerar externalId como «obligatorio» en la cabeza, pero no en el código.
Si en tu request DTO externalId es nullable, entonces el código debe comportarse honestamente con eso. Una avería frecuente es request.externalId().equals(...), que lanza un NullPointerException tan pronto como el usuario decide agregar un libro «manualmente», sin un catálogo externo. Incluso si agregamos la validación completa más tarde, el trabajo básico con null debe tenerse en cuenta ya desde ahora.
GO TO FULL VERSION