Servicio REST que resuelve el precio aplicable a un producto de una cadena del grupo en una fecha dada. Cuando varias tarifas están vigentes en esa fecha, aplica la de mayor prioridad.
curl 'http://localhost:8080/api/v1/prices?applicationDate=2020-06-14T16:00:00&productId=35455&brandId=1'{
"productId": 35455, "brandId": 1, "priceList": 2,
"startDate": "2020-06-14T15:00:00", "endDate": "2020-06-14T18:30:00",
"price": 25.45, "currency": "EUR"
}Con Gradle (el wrapper descarga el JDK 25 vía toolchain si no lo tienes):
./gradlew bootRunCon Docker, sin necesidad de JDK:
docker build -t prices-api . && docker run -p 8080:8080 prices-apiSin compilar nada, desde la imagen publicada por la CI:
docker run -p 8080:8080 ghcr.io/trapy/prices-api:latestLa documentación navegable de la API queda en http://localhost:8080/swagger-ui.html.
El contrato vive en docs/openapi.yaml y puede verse renderizado
en Swagger Editor.
./gradlew test --tests FindApplicablePriceApiTest| Petición (producto 35455, cadena 1) | Tarifa | Precio |
|---|---|---|
| 14-jun 10:00 | 1 | 35.50 € |
| 14-jun 16:00 | 2 | 25.45 € |
| 14-jun 21:00 | 1 | 35.50 € |
| 15-jun 10:00 | 3 | 30.50 € |
| 16-jun 21:00 | 4 | 38.95 € |
Se ejecutan contra el endpoint real con la base H2 inicializada por Flyway, y se
acompañan de casos negativos (404 y 400 en formato application/problem+json).
flowchart LR
client((Cliente HTTP)) -->|"GET /api/v1/prices"| rest
subgraph infrastructure
rest["PriceController<br/>(adaptador REST — implementa<br/>la interfaz generada del contrato)"]
jpa["PricePersistenceAdapter<br/>(adaptador JPA)"]
db[("H2 en memoria<br/>esquema y datos: Flyway")]
end
subgraph application
in["FindApplicablePriceUseCase<br/>(puerto de entrada)"]
svc[FindApplicablePriceService]
out["PriceRepository<br/>(puerto de salida)"]
end
subgraph domain
model["Price · Money · PriceQuery<br/>(records con invariantes)"]
policy["HighestPriorityWins<br/>(política de selección)"]
end
rest --> in
svc -. implementa .-> in
svc --> out
svc --> policy
policy --> model
jpa -. implementa .-> out
jpa --> db
Las dependencias apuntan siempre hacia el dominio; la regla la garantiza ArchUnit en cada build.
Arquitectura hexagonal en un solo módulo, verificada por test. El dominio
(domain) no conoce Spring, JPA, Jackson
ni MapStruct; los casos de uso y puertos viven en
application y los adaptadores en
infrastructure. Para este alcance,
separar en módulos de Gradle habría sido ceremonia: las reglas de dependencia las
garantiza ArchUnit
en cada build, incluida la pureza del dominio y que nadie depende del composition root.
La regla de negocio vive en el dominio y se ve. El adaptador de persistencia
aporta solo las tarifas vigentes del producto y cadena (consulta acotada con índice
compuesto — nunca un findAll), y la elección entre candidatas es una política del
dominio: HighestPriorityWins,
con desempate determinista por identificador de tarifa y test unitario sin arrancar
nada. A escala, ese desempate se empujaría a la consulta (ORDER BY priority DESC LIMIT 1); con el volumen del enunciado, la versión en dominio es más expresiva y
igual de correcta.
API-First de verdad. docs/openapi.yaml es la fuente de
verdad: de él se generan la interfaz del controller y los modelos en tiempo de build
(no se commitean), el controller la implementa y, si el contrato cambia, el código no
compila hasta cumplirlo. Swagger UI actúa como visor de ese mismo yaml. No hay
anotaciones de documentación escritas a mano: las únicas viven en el código generado
desde el contrato, que no puede divergir de él.
Java moderno con criterio. El dominio usa records con invariantes en el
constructor; Optional solo como retorno; dinero como BigDecimal con su divisa
(Money). Lombok queda
confinado a infraestructura (entidad JPA y constructores de adaptadores), y los
mappers son interfaces de MapStruct con los value objects resueltos en métodos
default. Las fechas son LocalDateTime: el enunciado no define zona horaria y el
API la refleja tal cual; con múltiples husos la decisión habría sido Instant en UTC.
Errores como Problem Details (RFC 9457). Sin tarifa aplicable → 404 con detalle
útil; parámetros inválidos, ausentes o malformados → 400. Todo en
application/problem+json desde un
@RestControllerAdvice
que extiende el manejo estándar de Spring.
H2 en runtime, PostgreSQL en los tests del adaptador. El enunciado pide base en memoria y se respeta (H2 + Flyway con los datos del ejemplo). La arquitectura permite además verificar el mismo adaptador y las mismas migraciones contra un PostgreSQL real con Testcontainers: mismo puerto de salida, dos motores, mismos tests.
Secretos nunca en claro. Las propiedades sensibles van cifradas con Jasypt
(ENC(...)) y la clave maestra llega por entorno; en CI, un gate de detect-secrets
corta el pipeline si aparece un secreto plano. La clave de esta kata lleva un default
deliberado para que el evaluador ejecute sin fricción — en un entorno real no
existiría y viviría en el vault.
La pirámide completa, cada capa con su papel:
- Unitarios de dominio sin Spring: la regla de prioridad, el desempate y las invariantes.
- Integración sobre el endpoint real: los 5 casos del enunciado con todos sus campos, más los negativos del contrato.
- ArchUnit: la arquitectura como test.
- Testcontainers + PostgreSQL: el adaptador de persistencia es agnóstico del motor.
- Cucumber (escenarios en español):
la regla de negocio como documentación ejecutable; cada build genera un informe HTML
navegable pensado para perfiles no técnicos (artifact
reports-cucumberde la CI). - Karate: el contrato técnico HTTP/JSON de punta a punta, incluido el formato de los errores.
Cobertura medida con JaCoCo y doble gate: umbral del 80 % por paquete en dominio y
aplicación verificado en el build (./gradlew check), y quality gate de plataforma
en SonarCloud.
Pipeline por stages en GitHub Actions, con su grafo real de dependencias:
flowchart LR
S[secrets-check] --> B[build]
B --> T1["test<br/>(unit + gate de cobertura)"]
B --> T2["test<br/>(cucumber)"]
B --> T3["test<br/>(karate)"]
B --> C[contract]
T1 & T2 & T3 --> SO["sonar<br/>(quality gate)"]
T1 & T2 & T3 --> D[dockerize]
C --> D
D --> SC["security-scan<br/>(Trivy)"]
- secrets-check:
detect-secretscontra el baseline; un secreto plano corta todo. - test: las tres suites en paralelo, con sus informes como artifacts.
- coverage: gate de JaCoCo y resumen en el job.
- sonar: análisis de SonarCloud con la cobertura importada del XML de JaCoCo.
- contract: validación del OpenAPI.
- dockerize: imagen multi-stage (builder JDK 25, runtime JRE alpine, usuario no
root, capas cacheables con
jarmode=tools), publicada en GHCR en cada push adevelop/main. - security-scan: Trivy sobre la imagen — bloquea por CRITICAL con fix disponible (hallazgos visibles en el log) y sube el informe completo como SARIF al Security tab.
El trabajo está organizado como un sprint visible en el propio repo: backlog en issues con criterios de aceptación, una rama y una pull request por tarea con su pipeline en verde antes del merge, y Conventional Commits. La historia de commits y PRs cuenta la evolución real del proyecto, decisiones y correcciones incluidas.
- Autenticación/autorización: fuera de alcance del enunciado; el diseño hexagonal la acomodaría como filtro en el adaptador REST sin tocar dominio.
- Caché de precios: las tarifas tienen vigencia temporal; una caché exigiría TTL coherente con esas ventanas o invalidación por cambio de tarifas. Sin evidencia de necesidad, YAGNI.
- Paginación y listados: el contrato pide una consulta puntual; no hay colecciones que paginar.
- Versionado del API: la ruta ya declara
v1; Spring Framework 7 añade versionado nativo de endpoints si hiciera falta evolucionar. - Tiempo real / SSE, métricas, tracing: valor dudoso para el alcance; el pipeline y la arquitectura dejan sitio para añadirlos sin reestructurar.