One Public Member, Sixteen Consequences
Postsale's aggregate has ten private fields and exactly one public member. Reservation, in the same estate, has eighteen. Following that difference all the way down to the database.
“Encapsulate your aggregates” is advice everybody agrees with and almost nobody costs out. It sounds free. It is not free, and GroupFlights is unusual in that it contains a controlled experiment: two aggregate roots, built by the same team in the same repository, one with public getters everywhere and one with none.
Part 3 looked at Sales' two-class lifecycle, where Reservation exposes its state freely. Postsale's ReservationChangeRequest takes the opposite position, and the rest of this series is largely a bill for that decision.
The two headers, side by side
Sales' Reservation declares its public surface in one block:
// src/Sales/GroupFlights.Sales.Domain/Reservations/Reservation.cs:59-77
public ReservationId Id { get; private set; }
public OfferId SourceOfferId { get; }
public AirlineOfferId AirlineOfferId { get; init; }
public Client Client { get; }
public AirlineType AirlineType { get; private set; }
public string AirlineName { get; private set; }
public IReadOnlyCollection<FlightSegment> Travel => _travel;
public IReadOnlyCollection<FlightSegment> Return => _return;
public PassengersData DeclaredPassengers { get; }
public bool PassengerNamesRequiredImmediately { get; init; }
public Deadline PassengerNamesDeadline { get; private set; }
public IReadOnlyCollection<Passenger> ProvidedPassengers => _providedPassengers;
public PresentableTravelCost Cost { get; private set; }
public bool CanChangePassengersOrTravel => AirlineType is AirlineType.Traditional;
public Deadline ConfirmInAirlinesDeadlines { get; private set; }
public IReadOnlyCollection<RequiredPayment> RequiredPayments => _requiredPayments;
public CompletionStatus? Status { get; private set; }
public bool IsCompleted => Status is not null;
Eighteen members. Two of them — CanChangePassengersOrTravel and IsCompleted — are derived predicates rather than state, which leaves sixteen public getters over the aggregate's internals. Setters are all private or init, so this is read-only exposure, not an anaemic model. But everything is visible.
Postsale's aggregate declares this:
// src/Postsale/GroupFlights.Postsale.Domain/Changes/Request/ReservationChangeRequest.cs:12-46
public class ReservationChangeRequest : DomainEventsSource
{
private readonly ReservationToChange _reservationToChange;
private readonly DateTime _newTravelDate;
private readonly UserId _requester;
private bool _isFeasible;
private RequiredPayment _paymentRequiredToApplyChange;
private ReservationCost _newCost;
private List<FlightSegment> _newTravel;
private ReservationChangeToApply _changeToApply;
private bool _isActive;
private CompletionStatus? _completionStatus;
private ReservationChangeRequest() { }
internal ReservationChangeRequest(ReservationToChange reservationToChange,
DateTime newTravelDate, UserId requester, Guid? id = default)
{ … }
public Guid Id { get; init; }
Ten private fields. One public member — the identity, which anything persisted needs. Five public methods: SetUpChangeFeasibility, OnChangePayed, OnPaymentOverdue, RejectChange, OnChangeApplied. Sixteen to one is the headline number of this series, and it is the ADR's doing: the decision record that split Postsale out of Sales asked explicitly for high encapsulation of the domain model and a maximally restricted public API “including the data model”. That instruction was followed to the letter.
What the sealing actually buys
Take the aggregate seriously for a moment, because it is genuinely well built.
Every method is named for a business event that happened, not a field that changed: OnChangePayed, OnPaymentOverdue, OnChangeApplied. There is no SetStatus, no MarkInactive. A caller cannot drive the object into a state the domain does not recognise, because the vocabulary for driving it is the vocabulary of things that occur in an airline change process.
The lifecycle guard is one private method:
private void EnsureStillActive()
{
if (_isActive is false)
{
throw new ChangeRequestIsNotActiveAnymoreException();
}
}
And the constructor is internal, so the aggregate cannot be created from outside the domain assembly at all — the only sanctioned route is ReservationChangeRequestDomainService.CreateChangeRequest. That is not accidental. Creating a change request requires calling into another bounded context to fetch the reservation being changed, and an aggregate constructor is the wrong place for an await.
The payoff is real: there is no way to observe an inconsistent ReservationChangeRequest, because there is no way to observe one at all. Every criticism a reviewer could make about ordering, partial mutation or leaked internals is structurally unavailable.
Consequence one: the guard is not on every path
Sealing the object does not automatically make the object correct, and this one demonstrates it precisely.
SetUpChangeFeasibility calls EnsureStillActive(). OnChangePayed calls EnsureStillActive(). OnPaymentOverdue, RejectChange and OnChangeApplied do not — read ReservationChangeRequest.cs:126-145 and the three terminal transitions all begin by assigning _isActive = false with no check that it was ever true.
The consequence is that a finalised request can be re-rejected or re-applied, and each call enqueues a fresh ReservationChangeRequestFinalized domain event. Three of five transitions skip the lifecycle guard, in a class whose entire design premise is that the lifecycle is enforced from inside.
That is not an argument against encapsulation. It is a reminder that encapsulation moves the question from “can callers break the invariant?” to “does every method check it?” — and the second question has no compiler support either. With sixteen public getters a reviewer can at least see the state; here you have to read all five methods and hold them in your head.
Consequence two: the null guard that cannot fire
// ReservationChangeRequest.cs:30-39 (shape reproduced, values mine)
internal ReservationChangeRequest(ReservationToChange reservationToChange, …)
{
if (reservationToChange.AirlineType is AirlineType.LowCost)
{
throw new ThisReservationDoesNotSupportChangesException();
}
_reservationToChange = reservationToChange ?? throw new ArgumentNullException(nameof(reservationToChange));
…
}
Line 32 dereferences reservationToChange.AirlineType. Line 37 checks whether reservationToChange is null. A null argument produces a NullReferenceException from the business rule, never the ArgumentNullException the author wrote two lines later.
The estate's constructor-guard idiom — X = x ?? throw new ArgumentNullException(nameof(x)); — is near-universal across both rich modules, and it is a good idiom. Here it was applied and then out-ordered by an invariant check that arrived first. It is the kind of defect that only shows up when you read the constructor top to bottom, which is exactly what a sealed class discourages you from doing.
Consequence three: the rule that says reservation and enforces user
This is the sharpest one, and it lives at the seam between the aggregate and its domain service.
// src/Postsale/.../Changes/DomainService/ReservationChangeRequestDomainService.cs:41-46
var anyActiveChangeForThisReservation = await _repository.ExistsActiveForGivenUser(requester, cancellationToken);
if (anyActiveChangeForThisReservation)
{
throw new OnlyOneActiveChangePerReservationIsAllowedException();
}
The local variable says ForThisReservation. The exception type says PerReservation. Its message, translated, says “only one active change is permitted per reservation” (“Tylko jedna aktywna zmiana jest dozwolona dla kazdej rezerwacji”). And the repository method it calls:
// src/Postsale/.../Repositories/ReservationChangeRequestRepository.cs:22-28
public async Task<bool> ExistsActiveForGivenUser(UserId userId, CancellationToken cancellationToken = default)
{
return await _dbContext.ChangeRequests.AnyAsync(c =>
EF.Property<UserId>(c, "_requester").Equals(userId)
&& EF.Property<bool>(c, "_isActive") == true,
cancellationToken);
}
The method name, its parameter and its SQL are all per user. reservationToChangeId is in scope on line 32 and never reaches the predicate.
The behavioural result is two bugs pointing in opposite directions. A client with two bookings can only have one change in flight across both. Two people acting on the same booking can each open a change request concurrently — which is the case the exception was named for.
The thing I find genuinely instructive: the only name in the whole chain that tells the truth is the test method's. ReservationChangeRequestDomainService_ForbidsCreatingChangeRequest_IfThereIsOtherActiveForThisUser. Somebody wrote the test after reading the code rather than after reading the exception, and got it right. That is an argument for tests as documentation that has nothing to do with catching regressions.
To be fair to the author: with the aggregate sealed, this rule genuinely cannot live inside it — a single instance cannot know what other instances exist. It has to be a domain service plus a repository query, and that split is where a naming drift becomes invisible. The encapsulation did not cause the bug, but it did move the rule to the one place nothing was checking it.
The chain from here
That is the ledger opened. The next five parts spend it:
- Testing. With no getters, assertions have to read something else. Part 5 shows what.
- Persistence. Ten private fields have to reach columns somehow — Part 8 is the mapping, and it is not pretty.
- Reading. A query side that cannot go through the aggregate has to go around it (Part 9).
- The repository. Look again at
Updateabove: it inspectsreservationChangeRequest.DomainEventsto discover whether aReservationChangeToApplyneeds inserting, because the aggregate will not tell it directly. Persistence reading the event queue is a consequence of the sealing, and Part 17 shows how narrowly it works.
Next, asserting on events when there are no getters — the two test files the README puts side by side, and the ledger they produce.