Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 118 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Records marked **Open** are not yet decided and are pending discussion.
| [014](#adr-014-consuming-a-payment-event-once) | Accepted | Consuming a payment event once |
| [015](#adr-015-a-boarding-pass-without-a-seat) | Accepted | A boarding pass without a seat |
| [016](#adr-016-when-check-in-is-allowed) | Accepted | When check-in is allowed |
| [017](#adr-017-what-a-notification-is-here) | Accepted | What a notification is here |
| [018](#adr-018-carrying-the-passenger-in-a-payment-event) | Accepted | Carrying the passenger in a payment event |

---

Expand Down Expand Up @@ -102,10 +104,11 @@ it reads rows and sends `payload` to `topic` keyed by `aggregate_id`. Keying by
aggregate id routes all events for one aggregate to the same partition, which
preserves per-aggregate ordering.

`payment` and `checkin` own an outbox. `booking` was expected to and does not:
everything it would announce, another service already knows, and it consumes
rather than publishes. `flight` only responds to commands, and `auth` and
`notification` publish nothing.
`payment`, `checkin` and `booking` own an outbox. The claim that `booking`
would never need one was wrong, and EPIC-05 is where it broke: a booking having
been made is something only `booking` can say, and notifying a passenger about
it is the first thing that had to hear it. `flight` only responds to commands,
and `auth` and `notification` publish nothing.

### Consequences

Expand Down Expand Up @@ -1125,3 +1128,114 @@ departure precisely in order to explain why it is refusing.
- **Nobody checks whose booking it is.** The booking id is enough to check in,
which is the same gap bookings and payments already have, and it closes when
there is an auth-service to close it.

---

## ADR-017: What a notification is here

**Status:** Accepted

### Context

EPIC-05 asks that a passenger be told when they book, when they pay and when
they check in. Nothing in this system can tell anyone anything: no service holds
an email address, a phone number or a name. `PassengerId` is a uuid and that is
all there has ever been, because there is no auth-service.

So "the user receives a notification" had to be given a meaning that is
honest rather than one that looks finished.

### Decision

**A channel port with an adapter that writes a log line.** The port is what a
real provider would replace; the adapter is what this system can actually do.
The same shape as the payment gateway: a seam that is simulated on purpose, not
a stub that pretends.

**Every send is recorded in `notifications`**, with `sent_at` null until the
channel has taken it. There is no read endpoint: this is the record of a send,
not an inbox, and no story asks for one.

**Written first, sent second, marked third.** A crash between the write and the
send leaves a notification nobody received, which the partial index over
`sent_at IS NULL` makes findable. The other order would send one and forget it
had, and the passenger would get it again on the next delivery.

**Every consumer claims the event id before doing anything** (ADR-014). This
matters more here than anywhere else in the system: confirming a booking twice
is the same booking, and notifying a passenger twice is the thing they notice.

**Three modules, like every other service.** The domain is thin, but the wording
lives in it and is tested without a broker, which is the part of this service
anyone would ever read.

### Consequences

- **Nobody is actually told anything.** A log line is not a notification, and
the day this system has contact details it will need a real adapter and a
retry policy that goes with it.
- **Nothing sweeps the unsent.** The index exists, the job does not. A channel
that throws leaves a row that stays unsent until somebody looks.
- **The channel is called outside the transaction**, so a provider that hangs
cannot hold a database connection open. The cost is the window above.
- **`processed_events` is never purged**, the same gap booking-service has.
- **A booking notification cannot name the flight.** booking-service holds seats
by id and never learns the flight number, so the message names the booking and
the total instead. Only the check-in message can say which aeroplane.

---

## ADR-018: Carrying the passenger in a payment event

**Status:** Accepted

### Context

`payment.succeeded.v1` carried the payment, the booking and the amount, and no
passenger. payment-service did not even know who the passenger was: it reads the
booking to find out what is owed and discarded every other field.

To notify anyone about a payment, something had to supply the passenger. Three
ways were considered.

**notification-service reads booking-service over HTTP** when a payment message
arrives. Rejected: a synchronous call inside the consumption of a message means
that when booking-service is down the consumer either blocks its partition
retrying or drops the notification, and it would need Feign and a resilience
configuration to do it.

**notification-service remembers who a booking belongs to** from
`booking.created.v1`, which it consumes anyway. Rejected, and this is the
interesting one: the two are different topics, and Kafka orders messages within
a partition, not across topics. In production minutes pass between booking and
paying, so it would almost always work. In the end-to-end test milliseconds
pass. A failure that only appears when things are fast is the kind that appears
in a demonstration.

### Decision

**The event carries the passenger.** `BookingToPay` keeps the `passengerId` that
booking-service already returns, `PayBookingService` hands it to the recorder,
and `PaymentSettled` carries it to the listener that writes the outbox row.

**`Payment` does not store it.** No column, no migration, no field. A payment is
money, and whose booking it settles is booking-service's business. The passenger
travels beside the payment inside the in-process event, because an announcement
is addressed to someone even when the thing being announced is not.

**Both payment events get it**, not just the successful one. Nobody notifies on
a refusal today, and building the two contracts differently would be a
difference with no reason behind it.

### Consequences

- **The contract stayed at v1.** Adding a field does not break a consumer that
ignores what it does not know, and booking-service's copy of this event
declares two fields and says so in its own javadoc. A rename or a removal
would need v2.
- **A payment message now names a passenger**, which is one more place a
passenger identifier exists. With real personal data rather than a uuid that
would be worth weighing; with a uuid it is not.
- **payment-service reads a field it does not use itself.** If booking-service
ever stops returning it, payment breaks for a reason that has nothing to do
with payments.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,15 @@ mvn test

## Arquitectura basada en eventos

El sistema usa **Spring Events** para desacoplar servicios.
El sistema usa **Kafka** para desacoplar servicios. Cada publicador escribe el
mensaje en su propia tabla `outbox`, dentro de la transacción que causó el
hecho, y un relay lo lleva al topic (ADR-001).

### Eventos principales

* `BookingCreatedEvent`
* `PaymentProcessedEvent`
* `CheckInCompletedEvent`
* `booking.created.v1`
* `payment.succeeded.v1` y `payment.failed.v1`
* `checkin.completed.v1`

---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.programandoenjava.airline.booking.application.event;

import com.programandoenjava.airline.booking.domain.booking.Booking;

/**
* A booking was made. In process only: it carries value objects and is never
* serialised. What crosses the wire is the flat integration event the listener
* maps this into (ADR-001).
*/
public record BookingCreated(Booking booking) {

public BookingCreated {
if (booking == null) {
throw new IllegalArgumentException("A booking is required");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@NullMarked
package com.programandoenjava.airline.booking.application.event;

import org.jspecify.annotations.NullMarked;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.programandoenjava.airline.booking.application.port.out.events;

/**
* Announces something that happened, in process. A port rather than Spring's
* publisher directly, so the use case stays free of the framework (ADR-006).
*/
public interface DomainEventPublisher {

void publish(Object event);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@NullMarked
package com.programandoenjava.airline.booking.application.port.out.events;

import org.jspecify.annotations.NullMarked;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.programandoenjava.airline.booking.application.transaction;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Runs the annotated use case inside one transaction. Declared here and
* satisfied in infrastructure, which is what keeps this file free of any
* framework import (ADR-009).
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface UnitOfWork {

boolean readOnly() default false;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@NullMarked
package com.programandoenjava.airline.booking.application.transaction;

import org.jspecify.annotations.NullMarked;
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.programandoenjava.airline.booking.application.usecase;

import com.programandoenjava.airline.booking.application.event.BookingCreated;
import com.programandoenjava.airline.booking.application.port.out.events.DomainEventPublisher;
import com.programandoenjava.airline.booking.application.port.out.savebooking.SaveBookingPort;
import com.programandoenjava.airline.booking.application.port.shared.IdempotencyKey;
import com.programandoenjava.airline.booking.application.transaction.UnitOfWork;
import com.programandoenjava.airline.booking.domain.booking.Booking;

import java.util.Optional;

/**
* The write and the announcement, in one transaction. A class of its own rather
* than a method on CreateBookingService, because {@code @UnitOfWork} is
* proxy-based and does nothing when a bean calls itself. Keeping it here also
* keeps the transaction off the seat hold, which is a network call.
*/
public class BookingRecorder {

private final SaveBookingPort saveBooking;
private final DomainEventPublisher events;

public BookingRecorder(final SaveBookingPort saveBooking,
final DomainEventPublisher events) {
this.saveBooking = saveBooking;
this.events = events;
}

/*
* Announced only when this call is the one that wrote it. A repeated
* request carrying the same idempotency key gets the booking that already
* exists, and nothing new has happened to tell anyone about.
*/
@UnitOfWork
public Booking record(final Booking booking, final IdempotencyKey key) {
Optional<Booking> lostTheRace = saveBooking.saveIfNew(booking, key);
if (lostTheRace.isPresent()) {
return lostTheRace.get();
}

events.publish(new BookingCreated(booking));

return booking;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import com.programandoenjava.airline.booking.application.port.out.holdseats.HoldSeatsCommand;
import com.programandoenjava.airline.booking.application.port.out.holdseats.HoldSeatsPort;
import com.programandoenjava.airline.booking.application.port.out.holdseats.SeatsHeld;
import com.programandoenjava.airline.booking.application.port.out.savebooking.SaveBookingPort;
import com.programandoenjava.airline.booking.application.port.shared.IdempotencyKey;
import com.programandoenjava.airline.booking.domain.booking.Booking;
import com.programandoenjava.airline.booking.domain.booking.BookingId;
Expand All @@ -20,16 +19,16 @@ public class CreateBookingService implements CreateBookingUseCase {

private final FindBookingPort findBooking;
private final HoldSeatsPort holdSeats;
private final SaveBookingPort saveBooking;
private final BookingRecorder recorder;
private final Clock clock;

public CreateBookingService(final FindBookingPort findBooking,
final HoldSeatsPort holdSeats,
final SaveBookingPort saveBooking,
final BookingRecorder recorder,
final Clock clock) {
this.findBooking = findBooking;
this.holdSeats = holdSeats;
this.saveBooking = saveBooking;
this.recorder = recorder;
this.clock = clock;
}

Expand Down Expand Up @@ -61,8 +60,6 @@ public Booking create(final CreateBookingCommand command) {
fare,
now);

Optional<Booking> lostTheRace = saveBooking.saveIfNew(booking, key);

return lostTheRace.orElse(booking);
return recorder.record(booking, key);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.programandoenjava.airline.booking.infrastructure.adapter.out.events;

import com.programandoenjava.airline.booking.application.event.BookingCreated;
import com.programandoenjava.airline.booking.domain.booking.Booking;
import com.programandoenjava.airline.booking.infrastructure.adapter.out.events.dto.BookingCreatedEvent;
import com.programandoenjava.airline.booking.infrastructure.adapter.out.persistence.outbox.OutboxWriter;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import tools.jackson.databind.ObjectMapper;

import java.time.Clock;
import java.util.UUID;

/**
* Turns the in-process event into the flat one other services read, and puts it
* in the outbox.
*
* <p>BEFORE_COMMIT, so the row joins the transaction that wrote the booking.
* AFTER_COMMIT would announce a booking whenever the write that followed
* failed, and lose one whenever this did (ADR-001).
*/
class BookingCreatedListener {

private static final String AGGREGATE_TYPE = "booking";

private final OutboxWriter outbox;
private final ObjectMapper objectMapper;
private final Clock clock;

BookingCreatedListener(final OutboxWriter outbox,
final ObjectMapper objectMapper,
final Clock clock) {
this.outbox = outbox;
this.objectMapper = objectMapper;
this.clock = clock;
}

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void on(final BookingCreated created) {
Booking booking = created.booking();

BookingCreatedEvent event = toEvent(UUID.randomUUID(), booking);

String payload = objectMapper.writeValueAsString(event);
String bookingId = booking.id().value().toString();

outbox.write(AGGREGATE_TYPE, bookingId, BookingTopics.CREATED, payload, clock.instant());
}

private static BookingCreatedEvent toEvent(final UUID eventId, final Booking booking) {
return new BookingCreatedEvent(
eventId,
booking.id().value(),
booking.passengerId().value(),
booking.flightId().value(),
booking.seats().value(),
booking.total().amount(),
booking.total().currency().getCurrencyCode(),
booking.status().name(),
booking.createdAt());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.programandoenjava.airline.booking.infrastructure.adapter.out.events;

/**
* One topic. Confirming and failing a booking are not announced: both happen
* because a payment message said so, and whoever wants to know already has it
* from payment-service.
*/
public final class BookingTopics {

public static final String CREATED = "booking.created.v1";

private BookingTopics() {
}
}
Loading
Loading