Skip to content

feat(booking): create bookings and hold seats, closing US-003, US-004 and EPIC-02 - #25

Merged
GODSCAR1 merged 9 commits into
mainfrom
feat/seat-blocks
Aug 17, 2026
Merged

GODSCAR1 merged 9 commits into
mainfrom
feat/seat-blocks

Conversation

@GODSCAR1

@GODSCAR1 GODSCAR1 commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Adds booking-service and gives flight-service the write endpoint it needs to
answer. A passenger asks booking-service for seats on a flight; booking-service
asks flight-service to hold them, and only once they are held does a booking
exist. Both services keep the hexagonal shape of ADR-006, with three Maven
modules and a framework-free domain and application layer, and both are
exercised together by a new end-to-end module.

Note on the history: the first five commits of this branch reached main
directly by mistake and the branch was cut from there, so they appear here too.
They are the seat-blocking work this PR builds on.

Why this closes both stories

US-003, create a booking. POST /api/v1/bookings takes a passenger, a
flight and a number of seats. The seats are held at flight-service first, and the
booking is written only if that succeeds, so "a booking is created for the user"
never means a booking without seats. It lands in PENDING, which here means the
seats are held and the fare is unpaid: the only state a booking can be in until
payment exists.

US-004, no overselling. The answer lives in flight-service, because that is
where the inventory is. POST /api/v1/flights/{id}/seat-blocks reads the flight
FOR UPDATE, reduces it and records the hold inside one transaction, so a second
request waits rather than reading a seat count that is about to change.
ConcurrentSeatBlockTest is what earns the claim: eight passengers after nine
seats, two each, all released at once. Four are served, four are refused with a
409, and the flight never owes seats it does not have.

That test is also the only one that fails if @UnitOfWork is removed. Every
other test runs one request at a time, and a lock that is never contended is
indistinguishable from no lock.

Why they ship together. US-004 is not a feature with its own endpoint; it is
the correctness condition of US-003. Delivering the booking first would have
meant merging a seat count that two passengers could both spend, and the fix
would have touched the same transaction, the same lock and the same port. With
both merged, EPIC-02 has no remaining scope: every child story is closed, and
each was implementable and testable on its own. The seat blocking has its own
slice and concurrency tests in flight-service, and the booking has its own in
booking-service with the outbound port mocked.

Closes #4 (US-003)
Closes #5 (US-004)
Closes #6 (EPIC-02)

Ricardo's two issues

Final parameters. Applied across both services: 95 parameters in production
code. Left off interfaces and abstract methods, where final promises something
the language does not keep, since the implementer decides. Local variables were
briefly caught by the same inspection and then stripped back out, since the issue
asked about parameters.

JSpecify. Applied as @NullMarked on sixteen package-info.java files
rather than @NonNull on each signature: with the package marked, every type is
non-null by default and @Nullable marks the exceptions. There are five, all on
the same path: an optional search filter travelling from the query string to the
Specification. That so few exist is itself the finding.

Objects.requireNonNull was not added, because the records already validate in
their compact constructors and throw better messages than a bare NPE.

ADR-006 gains a criterion for what may live in the inner modules, since JSpecify
is the first annotation to sit near that line: an annotation may if it
describes code, may not if it generates code, changes an object's
lifecycle, or needs a runtime to interpret it. JSpecify passes; Lombok, Bean
Validation and MapStruct do not, for three different reasons.

Worth knowing before this is trusted: the annotations document, they do not
enforce.
Nothing in the build checks them. NullAway would make them binding and
is the obvious next step.

Closes #23
Closes #24

Changes

  • flight-service: SeatBlock as its own aggregate, BlockSeatsUseCase, a
    write adapter under a pessimistic lock, and POST /flights/{id}/seat-blocks.
    The response carries the fare, so the caller charges what it reserved.
  • booking-service: a new service in the same three-module shape: Booking
    with its fare and total, CreateBookingUseCase, a Feign client to
    flight-service with Resilience4j, and POST /bookings.
  • e2e-tests: a module that starts the stack itself with Testcontainers,
    building the images from the same Dockerfiles the compose file uses.
  • docker-compose.yml: both services, and a Postgres instance each.

Beyond the stories

Three things went in that neither story asked for:

  • Idempotency on both endpoints. Neither story mentions retries, but a lost
    response means either seats held twice or two bookings for one passenger.
    Both take an Idempotency-Key header; ADR-011 records what the full pattern
    looks like and which parts of it are missing here, chiefly that keys are
    never purged.
  • A database per service. The compose file used to carve five databases out
    of one instance. Splitting it makes "a service cannot read another's tables" a
    fact rather than a convention, at the cost of one container each.
  • The end-to-end module. No story asks for it, and it found a real bug on its
    first run (below).

Notes

The bug the end-to-end tests found. booking-service was answering 502 where
flight-service had said 422. Spring 7 deprecated HttpStatus.UNPROCESSABLE_ENTITY
in favour of UNPROCESSABLE_CONTENT, following the rename in RFC 9110;
resolve() returns the new constant, so a decoder naming the old one never
matched and fell through to the default. Every other test passed, because every
other test mocks the port the decoder sits behind. Status codes crossing a
service boundary are now compared as numbers, which do not get renamed.

Boot 4 and its neighbours moved a great deal. Recorded because the remaining
four services will hit the same wall:

Expected Actual
spring-boot-starter-aop spring-boot-starter-aspectj
SpringDataWebAutoConfiguration org.springframework.boot.data.autoconfigure.web.DataWebAutoConfiguration
org.testcontainers:postgresql org.testcontainers:testcontainers-postgresql
archunit-junit5 archunit-junit6
hibernate-jpamodelgen hibernate-processor
com.fasterxml.jackson.. tools.jackson.. (Jackson 3)
flyway-core spring-boot-starter-flyway + flyway-database-postgresql
resilience4j-spring-boot3 resilience4j-spring-boot4, and not in its own BOM, so the version is pinned by hand
HttpStatus.UNPROCESSABLE_ENTITY HttpStatus.UNPROCESSABLE_CONTENT

Spring Cloud's release train is named for the year it opened, not the year of the
release: 2025.1.x (Oakwood) is what carries compatibility with Boot 4.1.

Two decisions that a reviewer may want to push back on.

@UnitOfWork is an annotation in the application layer, satisfied by an aspect
in infrastructure delegating to a TransactionRunner. ADR-009 explains why the
alternatives failed: a boundary on the adapter releases the lock between port
calls, and pushing the whole operation behind one port method puts business rules
in the persistence layer. CreateBookingService deliberately has no such
annotation, because its only local write must not span the HTTP call before it.

Booking has no behaviour: it is constructed and validated and nothing more.
Confirming and expiring are the state changes it will grow, and both belong to
payment.

Testing

./mvnw -B clean verify gives 164 tests.

flight domain 52
flight application 13
flight search slice 21
flight seat block slice 18
flight concurrency 3
flight ArchUnit and smoke 8
booking domain 23
booking slice 17
booking ArchUnit and smoke 9

Plus ten end-to-end, which are skipped by default:

./mvnw -B clean package
./mvnw -B verify -pl e2e-tests "-Dairline.e2e=true"

The package first is not optional: the images copy a jar Maven has already
built.

Known gaps

  • Seat holds are never released. A booking whose seats were held but whose
    save then failed leaves a hold nobody will claim. The expiry sweep ADR-008
    anticipates is what would release it, and it arrives with payment.
  • Idempotency keys are never purged. The column grows with the table, and a
    key reused after any length of time returns the original booking.
  • The DTOs booking-service uses to call flight-service are declared twice,
    once per service, and can drift silently. Contract testing is the intended
    mitigation (ADR-003) and does not exist yet.
  • The passenger is trusted from the request body. US-013 puts a token in
    front of it; until then PassengerId is whatever the caller says. It is a
    value object already, so only its source changes.

@GODSCAR1
GODSCAR1 requested a review from RicardoRB August 17, 2026 17:30
@GODSCAR1 GODSCAR1 self-assigned this Aug 17, 2026
This was linked to issues Aug 17, 2026
@GODSCAR1
GODSCAR1 merged commit e9430a8 into main Aug 17, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant