Cold chain custody and compliance API. A pharmaceutical shipment passes from hand to hand between organizations, its temperature is measured for the entire journey, and when it closes it either has a certificate or it doesn't.
Status: the five modules are built and closed. Every rule in the catalogue has a machine that checks it, and the integration suite runs against a real Oracle raised from nothing on every push. The specification lives in
docs/specification.md, the decisions indocs/adr/, and the order the work was done indocs/branching-plan.md.
A batch of vaccines leaves a lab, a carrier picks it up, it stops at an intermediate warehouse, a second carrier picks it up and it arrives at a hospital. Four different organizations have held the box, and none of them takes another's word for it. At the end of the journey somebody has to answer two questions, with evidence:
- Did the batch stay within its temperature range?
- Who was holding it at any given moment?
This project answers those two questions and nothing else.
The domain was chosen because it forces three problems a CRUD app never has to face:
- Several organizations see the same shipment without owning it. Visibility across organizations has to be solved without inventing an organization tree — see ADR-003.
- The verdict is real arithmetic over a time series: cumulative minutes out of band, data coverage. It is not a boolean somebody ticks by hand.
- Readings arrive in volume, out of order and repeated. Idempotent ingestion, indexes and partitioning stop being decoration.
They are numbered by dependency: that is also the build order.
| # | Module | Responsibility |
|---|---|---|
| 01 | identity |
Organizations, users, roles and scopes. Who is asking, and with what permission. |
| 02 | catalog |
Storage profiles, products and sites. What is shipped and under what conditions. |
| 03 | shipment |
The shipment, its chain of custody and who can see it. |
| 04 | telemetry |
Devices, temperature readings and excursion detection. |
| 05 | compliance |
The certificate, its verdict and the findings that back it. |
flowchart LR
catalog -- "frozen thresholds" --> shipment
shipment -- "ShipmentDispatched" --> telemetry
telemetry -- "ExcursionOpened / Closed" --> shipment
shipment -- "ShipmentClosed" --> compliance
telemetry -- "series and excursions" --> compliance
identity -. "org and scopes on every request" .-> shipment
Modules talk to each other through domain events, synchronous and in-process. No join crosses a
module boundary: references go by UUID and reads go through the owner's api/. Each module declares
its allowed dependencies in code — @ApplicationModule(allowedDependencies = {...}) — and an import
that is not on the list fails the build.
Inside a module the shape is hexagonal: a pure-Java domain model, a repository port it declares, and an adapter implementing it over JPA. The business rules — the state machine, the verdict arithmetic, the excursion detection — are plain objects, so they are unit-tested in milliseconds with no database anywhere near them. That is the point of it, and the reasoning is in ADR-007.
- Java 25 (LTS) and Spring Boot 4
- Oracle Database 23ai Free, schema governed by Flyway alone — no
ddl-auto - Spring Modulith + ArchUnit for the boundaries, backed by a rule catalogue that runs as a CI
gate — see
rules/ - Testcontainers with
gvenzl/oracle-free— a real Oracle in the integration tests, not H2 pretending to be Oracle - Spring Security with our own JWT, OpenAPI, Docker Compose
- Every message the API answers with travels as a key and resolves in English, Spanish and
Portuguese, chosen by
Accept-Language
The reasoning is in ADR-001 and ADR-006.
Everything from the folder down to the field is declared in
rules/project-rules.md — every rule across sixteen groups, each with a
severity, the test that checks it, and the state that test is in. It is not a style guide:
./gradlew rules runs it on every push and every pull request, and it has no switch to turn it off.
The catalogue verifies itself, too: a rule that cites a test that does not exist fails the build, a
rule still waiting for its test cannot quietly acquire one without moving its row, and a tolerated
violation with no owner and no expiry date fails as well. Nothing is left planned: the day a rule
was written without a machine is the day the build said so.
Writing those machines is what found the defects worth finding — an audit entry that came back from the database with an identifier that was not its own, two concepts wearing two names each, four columns abbreviated past the point of being guessable, and thirteen aggregates where the second writer erased the first without a trace.
./gradlew rules # the gate. Everything that does not need Oracle
./gradlew rulesDb # the rules that can only be checked against a real databasecp .env.example .env # local credentials, never committed
docker compose up -d --wait # Oracle 23ai Free and the API, on 1521 and 8080
scripts/demo.sh # the whole story below, end to endThe stack answers at http://localhost:8080/api, and the contract it serves is browsable at
http://localhost:8080/api/swagger-ui.html.
To work on the code, run the API from Gradle against the same database:
docker compose up -d oracle # only the database
./gradlew bootRun # Flyway migrates, then the API on 8080./gradlew test # the domain in milliseconds, plus the rule gate
./gradlew integrationTest # with Testcontainers, requires Docker
./gradlew rebuild # a database from nothing: migrate, start, run everythingThe domain suite is the one that proves the shape is worth its price: 41 tests in 52 milliseconds, with no Spring context and no database anywhere near them — the state machine, the verdict arithmetic, the excursion detection, the temperature band and the custody hash chain. A rule in the catalogue fails the build if a calculator loses its test, or if a domain test reaches for a framework.
docker compose up -d --wait
scripts/demo.sh # needs curl and jqOne run of scripts/demo.sh, against the stack you just raised:
- Three organizations register — a laboratory, a carrier and a hospital — and none of them can see anything that is not theirs.
- The laboratory declares a
2 – 8 °Cprofile, a product and a shipment of 400 vials, and dispatches it with a sensor attached. The thresholds are frozen at dispatch: changing the profile afterwards cannot change what this shipment was judged against. - The carrier accepts the handoff with a single-use code and custody changes hands.
- The gateway pushes three hours of readings, one every five minutes, with a 45-minute excursion
above 8 °C and a 65-minute blackout. The same batch is sent again and comes back
REPLAYED: nothing is duplicated. - The hospital takes delivery — and nobody asks for a certificate, because closing the shipment already issued one:
verdict FAIL
coverage 71.43 % of the expected samples
longest excursion 45 minutes
content hash 04ddb79e045da4d2d57c4d2e056577087e96fcd9eb4e50567028fd82196c96f5
finding DATA_GAP · CRITICAL · {"coveragePercent":71.43,"minimum":80}
finding EXCURSION_ABOVE_MAX · CRITICAL · {"durationMinutes":45,"peakCelsius":11.5}
The script is not a narration: it fails if the verdict is not the one written above. It runs in CI on every push, against Oracle raised from nothing, and the OpenAPI document the API served during that run is published as a build artifact.
docs/specification.md— tables, features and rules for the five modules. It is the contract: if the code and this document contradict each other, one of them is wrong.docs/adr/— the architecture decisions, with their context and what was ruled out.docs/branching-plan.md— the build order, branch by branch.- Implementation plans, one per module: 01 identity · 02 catalog · 03 shipment · 04 telemetry · 05 compliance.
- Data model (draw.io):
docs/coldchain-data-model.drawio— the 24 tables with their columns and relationships, and one page per module.
MIT.