Splitting a Bounded Context, With the ADR Attached
ADR 04 extracts the reservation-change process out of Sales, lists ten steps and two recommendations - and nine of the ten steps are traceable to real code.
“We should split that out” is the cheapest sentence in software architecture. It costs nothing to say, it is almost always directionally right, and it dies in the gap between the meeting and the branch — because nobody wrote down which process was being split, what the split was buying, or what it would cost. Six months later the module exists, nobody can explain its boundary, and the team argues about whether it was worth it using entirely different facts.
Part 9 followed one published type into somebody else's schema. This part is the estate's flagship artefact and the reason I think GroupFlights is worth reading at all: an architecture decision record that extracts a bounded context, and a module you can score against it line by line.
The decision
04-postsale-wydzielone-z-sales.md — “Postsale extracted from Sales” — opens with its context, translated:
During the discovery of our domain we focused most on the subdomains connected with the sales area, that is
offers,reservationsand thechangesconnected with them. Analysing the “core” of our business process and its “happy path” we came to the conclusion that changes themselves are a disjoint process relative to it (though related and supporting).
That is the whole argument in one paragraph, and it is a domain argument rather than a technical one. Nobody says “the Sales module is getting big”. They say: the happy path is offer → reservation → payment → tickets, and changing a reservation after tickets are issued is a different process that touches the same nouns.
The ADR then lists three weightings for the decision — the complexity of the change process, its potential to destabilise the core of the business, and its non-necessity for the main process — and offers two options. Option 1 leaves changes in Sales, with the plus of easy integration with reservations inside one bounded context, and the minus of a “large chance of destabilising or over-complicating the happy path”. Option 2 extracts changes to a separate Postsale module, with the plus of limiting negative impact on the happy path while hiding the process' complexity behind a simple public API, and two named minuses:
- the necessity of orchestrating changes not only with the available capabilities (Finance, Time Management) but also with the business core, Sales;
- the necessity of ensuring the minimisation of conflicts between a reservation and a change — “I change a reservation on the basis of stale information”.
Option 2 wins. The expected outcome names the goal: potential turbulence, “blast radius”, limited to the necessary minimum so that the most important area in the organisation, Sales, stays stable and profitable.
Note what has already happened in that document that does not happen in most. The losing option got a genuine advantage listed. The winning option got two genuine costs listed, one of which is a risk rather than an expense. And there is a named approver — Dariusz Pawlukiewicz — beside the reporter, which means at least two people held the trade-off at once.
The ten steps, scored
The ADR's most unusual section enumerates the steps required for the harder version of a reservation change, after tickets have been issued, with the actor or system in brackets. Here is each one against the code.
| ADR step (translated) | Code | |
|---|---|---|
| 1. [UI] Client declares a wish to change a travel parameter | POST /postsale/reservation-change-request, ClientOnly |
yes |
| 2. [Postsale → Sales] Fetch a simplified reservation state as at the moment of declaration | ISalesApi.GetReservationForChange → a snapshot row |
yes |
| 3. [GDS] Cashier finds and prices the change, if feasible | — | no |
| 4. [UI] Cashier enters the proposed change | POST /postsale/reservation-change-request/feasibility, CashierOnly |
yes |
| 5. [Postsale → Finance] Generate a payment with a deadline; paying equals accepting | PostsalePaymentRequestedIntegrationEventHandler → IFinanceApi.SetupPayment |
yes |
| 6. [UI → gateway] Client pays | FakePaymentGateway auto-pays after ~5 s |
yes |
| 7. [Deadline → Postsale] Mark the payment deadline as fulfilled | Postsale subscribes to overdue, not met | no |
| 8. [Postsale → Deadline] Generate a correction of the reservation's payment and passenger-name deadlines | ReservationChangeAcceptedIntegrationEventHandler.cs:23-31 |
yes |
| 9. [Postsale → Sales] Apply the change in Sales | same handler, lines 33-40, ISalesApi.ChangeReservation |
yes |
| 10. [Sales → Postsale] Close the change once everything is applied | ReservationChangesAppliedIntegrationEventHandler.cs:18-22 → OnChangeApplied() |
yes |
Eight of ten are unambiguously implemented, and step 6 is implemented by the fake gateway the README declares. That is an unusually high fidelity between a decision record and a codebase, and it is the reason this ADR is the richest teaching artefact in the repository. You can put the markdown on the left of your screen and the handler chain on the right and follow the process end to end.
Steps 8 and 9 are worth reading directly, because they are one handler and it shows the whole shape of the split:
foreach (var deadlineChange in deadlinesToModify)
{
await _timeManagementApi.UpdateDeadline(new UpdateDeadlineDueDateDto(
new DeadlineId(deadlineChange.DeadlineId), deadlineChange.NewDueDate));
}
await _salesApi.ChangeReservation(new ChangeReservationCommand(
@event.ReservationId,
@event.ReservationChangeRequestId,
@event.ChangeTravel,
@event.CostAfterChange,
@event.PaymentDeadlineChanges,
@event.PassengerNamesDeadlineChange),
cancellationToken);
Postsale.Application/EventHandlers/Internal/ReservationChangeAcceptedIntegrationEventHandler.cs:27-40, GroupFlights at commit a19b337. Postsale corrects the deadlines it caused to move, then hands Sales a finished change as one command. That is the ADR's expected outcome rendered as code: changes together with their complexity are closed in a separate area, producing only an artefact ready to be applied in the Sales area. Sales never learns what a feasibility assessment is.
Step 3 is missing because there is no GDS integration anywhere in the repository. That is a declared-adjacent absence — a global distribution system is a third-party airline reservation network, and stubbing one is not a lesson about DDD. The cashier types a price in. Fine.
Step 7 is the one that matters, and it is not an absence. Postsale's payment path is wired to DeadlineOverdueIntegrationEvent rather than to any “deadline met” signal, so the handler that is supposed to fire when the client pays on time fires when the client runs out of time, and calls OnChangePayed() either way. It gets a full treatment in part 13 because the same handler carries a second, independent defect.
The two recommendations, scored
The ADR closes with a recommendation section headed “high encapsulation”, and it makes two demands.
"Restrictive encapsulation of the domain model." Fully honoured, and it is the best thing in the repository. Postsale.Domain's aggregates expose essentially nothing. The repository has to reach in through EF's string-based property API to query them:
return await _dbContext.ChangeRequests
.Include("_reservationToChange")
.Include("_changeToApply")
.SingleOrDefaultAsync(c =>
EF.Property<RequiredPayment>(c, "_paymentRequiredToApplyChange").PaymentId.Equals(paymentId),
cancellationToken);
Postsale.Infrastructure/Repositories/ReservationChangeRequestRepository.cs:52-60. Every string in that query is a private field name. The README offers this deliberately as the contrast against Sales.Domain.UnitTests/Offers/OfferDraftTests.cs, which tests a model with public getters. The costs of that posture — you cannot assert on state, only on emitted events, and renaming a private field is a breaking migration — are the companion series' territory rather than this one's.
"Maximum limitation of the public API (including the data model)." Half honoured, and the half that fails is instructive. Postsale.Shared contains no IPostsaleApi. It is the only module in the estate with no public API at all, and that is a deliberate, checkable realisation of the recommendation: nothing outside Postsale can command it. Postsale is reachable only through its three HTTP endpoints and its own event subscriptions.
And then it publishes three integration events into that same contract project, all three consumed only by Postsale, and one of them — ReservationChangeAcceptedIntegrationEvent — carries the full internal change payload: the new itinerary, the new total and refundable cost, every shifted deadline. The parenthetical in the recommendation is “w tym modelu danych”, “including the data model”, and it is the exact clause the events breach. Part 8 has the general form of this; here it is specific, because here there is a written instruction it contradicts.
The risk the ADR named and the code did not close
Go back to Option 2's second minus: the necessity of minimising conflicts between a reservation and a change, because you are changing a reservation on the basis of stale information.
The mitigation is step 2 — Postsale fetches a simplified reservation state at declaration time and stores it. That snapshot is real: a ReservationSnapshots table in the postsale schema, a ReservationToChange entity, four owned sub-tables for travel segments and payments, populated in ReservationChangeRequestDomainService.cs:51-65.
Taking the snapshot is half a solution. The other half is noticing when it has gone stale, and that half does not exist. There is no version on the snapshot, no ETag, no optimistic-concurrency check when the change is finally applied to Sales, and no re-read. That is part 11, and it is the sharpest single item in this series' honest ledger, precisely because the ADR named the risk in prose.
What scoring an ADR is actually for
Nine of ten steps, one of two recommendations fully honoured, one named risk mitigated halfway. If that sounds like a mediocre result, compare it to the counterfactual: an estate with no ADR at all, where none of those questions can even be asked, and where “did we do what we decided?” is a matter of opinion.
The value of the record is not that it makes you right. It is that it makes you scoreable. Every no in the table above is a conversation that can happen with specifics — “step 7 is wired to the wrong event”, not “the postsale flow feels off”. Every yes is a claim that survived contact with a reader. And the one risk the ADR named in prose and left open in code is now a ticket with a title, which is more than most unnamed risks ever get.
Write the steps down. Then, a year later, walk them.
Next, the half-finished mitigation: the snapshot with no version.