Todo empezó con un único documento: la prueba técnica de OneBox (Ticket Distribution System), fechada en 2023. Un servicio pequeño de e-commerce —carritos, productos, expiración por inactividad— con una advertencia clara sobre qué se iba a valorar: que cumpliera los requisitos, una cobertura de tests mínima, y que fuera fácil de probar, revisar y desplegar. Nada de sistemas grandes ni de funcionalidad de sobra.
El reto
El enunciado pedía cinco comportamientos muy concretos:
- Crear un carrito, con el identificador generado por la propia aplicación.
- Que ese carrito pueda contener productos.
- Añadir uno o más productos a un carrito, cada uno con
idnumérico,descriptionalfanumérica yamountnumérico. - Consultar un carrito dado su id.
- Eliminar un carrito, tanto de forma explícita como automáticamente tras diez minutos de inactividad.
El único requisito técnico impuesto era usar Java. Todo lo demás —stack, arquitectura, alcance de cada pieza— quedaba en mi mano, y ahí es donde entran las decisiones que de verdad definen el resultado.
Construir de fuera hacia dentro
A diferencia de otras pruebas donde el contrato de entrada y salida viene
totalmente fijado por el enunciado y tiene sentido modelar primero el
dominio, aquí el enunciado describía comportamientos ("crear un carrito",
"añadir productos", "eliminarlo") sin imponer ninguna forma de API
concreta. Diseñar el dominio antes de saber exactamente qué necesitaba
exponer cada operación habría significado adivinar una forma para
Cart y Product antes de tener motivo real para
esa forma.
Así que invertí el orden: por cada operación, empecé por el punto de
entrada —el controller REST, o el scheduler en el caso de la expiración—
devolviendo una respuesta mínima construida ahí mismo, sin capa de
servicio todavía. Eso me daba de inmediato el contrato verificado por un
test: verbo HTTP, ruta, código de estado, forma del cuerpo. Solo una vez
fijado ese contrato, empujaba la lógica hacia dentro: primero a un
CartService que seguía sin tocar ninguna persistencia, y por
último a un CartRepository en memoria (un
ConcurrentHashMap, sin base de datos real, porque el
enunciado no pedía persistencia y añadirla habría sido peso de más).
El primer intento de este patrón se me fue de las manos: en vez de tocar solo el controller de un flujo, construí también el servicio y el repositorio de golpe. Sirvió para fijar la regla que seguí en todo lo demás: cada capa se entrega, se verifica y se confirma antes de tocar la siguiente, nunca varias a la vez.
La decisión que más pesé: cómo comunicar "el carrito no existe"
Con las cuatro operaciones y la expiración ya en marcha, el camino feliz
funcionaba y estaba testeado, pero consultar, modificar o borrar un
carrito inexistente no tenía un tratamiento explícito: en unos casos se
devolvía null, en otros se propagaba un error interno sin
control. Necesitaba una única forma consistente de comunicar ese caso en
las tres operaciones que dependen de que el carrito exista.
La alternativa más directa habría sido crear también un DTO de error
propio junto a la excepción, pero eso repite exactamente lo que había
evitado desde el principio: introducir estructuras nuevas sin necesidad
real. Preferí una CartNotFoundException de dominio, traducida
a 404 por un único @RestControllerAdvice, usando
el ProblemDetail que Spring ya trae de serie como cuerpo del
error en vez de inventar un tipo nuevo. Por la misma razón, los propios
Cart y Product —records simples— hacen de
request y de response directamente; no hay DTOs en ningún punto de la
API, porque ningún endpoint necesitaba una forma distinta de la que el
dominio ya tenía.
Verificarlo con tests
Cada capa se testea a su propio nivel. El controller, con
@WebMvcTest y MockMvc, mockeando el servicio:
lo que importa ahí es el contrato HTTP, no la lógica de negocio. El
servicio y el scheduler, con JUnit 5 y Mockito sobre el repositorio
mockeado: lo que importa es la regla (generar el id, fusionar productos,
lanzar CartNotFoundException cuando toca, calcular qué
carritos llevan más de diez minutos inactivos), no de dónde vienen los
datos. Y el repositorio en memoria, con un test directo sobre la
implementación real, sin mocks, porque ahí lo que quiero comprobar es que
el ConcurrentHashMap guarda, busca y expira correctamente.
El resultado es una suite de 17 tests, ejecutada con
./mvnw test, que cubre las cuatro operaciones, la expiración
por inactividad, y tanto el caso feliz como el de carrito inexistente en
cada una de las tres operaciones que lo necesitan.
Dejarlo documentado
En el README.md quedó el enunciado completo (el PDF original
se elimina del proyecto al final), la solución adoptada y por qué, la
estructura de paquetes, cómo levantar el proyecto con el wrapper de
Maven incluido, cómo ejecutar los tests, la API completa con ejemplos, y
las limitaciones que asumí conscientemente: sin base de datos real, sin
librería de validación de entrada más allá del propio tipado de
Product. Nada de eso es un descuido; es la otra cara de
mantener las dependencias al mínimo que pedía el enunciado.
El resultado
Un servicio pequeño, con la forma de la API decidida por el contrato de cada operación y no por el dominio, la ausencia de "carrito no encontrado" resuelta en un único punto en vez de repetida en cada flujo, y sin más piezas —DTOs, capas, dependencias— de las que el problema pedía.