CQRS Arrived as a Consequence
Because the write model exposes nothing, the read side could not go through the aggregate - so a second POCO maps the same physical table through a second DbContext. CQRS as an outcome rather than a choice.
Most CQRS in the wild is a decision. Somebody read a talk, drew a diagram with two boxes, and split the model deliberately. The split then has to justify itself against the complexity it adds.
Postsale's read/write split was not a decision. It was the only remaining option. Part 8 mapped an aggregate with ten private fields and one public member onto eight relational tables. Now somebody wants to list change requests on a screen — and there is nothing on the aggregate to list.
The problem, stated exactly
ReservationChangeRequest exposes Guid Id and five void methods. A query handler holding one of these objects can learn its identity and nothing else. It cannot read the travel date, the requester, the completion status or the cost.
The usual escapes are all closed:
- Add getters. That undoes the design and contradicts the ADR that asked for maximum encapsulation, including of the data model.
- Add a
ToDto()method. The aggregate would then know about a presentation shape, and every new screen would edit the domain. - Reflect over the private fields. Nobody sane.
- Read the emitted domain events. That works for tests (Part 5) because a test has just called a method. A list screen has no events to read.
So the read side went around the aggregate entirely.
A second POCO on the same table
// src/Postsale/GroupFlights.Postsale.Application/Queries/GetChangeRequests/ChangeRequestBasicData.cs:5-13
public class ChangeRequestBasicData
{
public Guid Id { get; set; }
public DateTime NewTravelDate { get; set; }
public Guid ReservationId { get; set; }
public Guid RequesterId { get; set; }
public ReservationChangeRequest.CompletionStatus? CompletionStatus { get; set; }
}
Five public properties with public setters, in a module whose whole identity is that it has none. And then the mapping that makes it work:
// src/Postsale/.../Data/EF/Configs/ChangeRequestBasicDataConfig.cs:9-19
public void Configure(EntityTypeBuilder<ChangeRequestBasicData> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.NewTravelDate).HasColumnName("_newTravelDate");
builder.Property(x => x.ReservationId).HasColumnName("_reservationToChangeReservationId");
builder.Property(x => x.RequesterId).HasColumnName("_requester");
builder.Property(x => x.CompletionStatus).HasColumnName("_completionStatus").IsRequired(false);
builder.ToTable("ChangeRequests");
}
That is the whole trick, and it is elegant in a way I did not expect. ToTable("ChangeRequests") points the read POCO at the same physical table the aggregate writes to. Each property maps to a column by explicit name — and those names are the private C# field names from Part 8, because that is what the write model's mapping produced.
The read side reads the write side's private fields, through SQL, because SQL is the only place they are visible. Encapsulation held at the C# level and was completely bypassed one layer down. The information the query needed was never actually hidden; it was hidden from C#.
Two DbContexts now exist over one schema. The write side is internal class PostsaleDbContext, registering the three domain entities and applying three configurations. The read side is public class PostsaleReadDbContext : DbContext, IDoNotMigrate, registering exactly one DbSet<ChangeRequestBasicData> and applying one configuration.
The one-line marker that makes it legal
Both contexts declare HasDefaultSchema("postsale") and both would happily generate migrations for the same table. The estate's migration runner walks every non-abstract DbContext in every loaded assembly and calls Migrate(), so without intervention the two would race to create ChangeRequests.
The intervention is one empty interface:
var dbContextThatShouldMigrate =
dbContexts.Where(db => typeof(IDoNotMigrate).IsAssignableFrom(db) is false);
foreach (var context in dbContextThatShouldMigrate)
{
(scope.ServiceProvider.GetService(context) as DbContext)?.Database.Migrate();
}
IDoNotMigrate has no members. PostsaleReadDbContext is the only type in the estate that implements it. It is a well-named marker solving a real problem in one line, and it is one of the better small pieces of design in the shared plumbing — a read model that shares a table with a write model genuinely does need to opt out of schema ownership, and this says so declaratively.
The query path, end to end
// src/Postsale/.../Repositories/ChangeRequestsReadOnlyRepository.cs:17-24
public async Task<ChangeRequestBasicData[]> Browse(Guid requesterId, int pageSize, int pageNumber, CancellationToken cancellationToken = default)
{
return await _readDbContext.ChangeRequests
.Where(x => x.RequesterId.Equals(requesterId))
.Skip((pageNumber - 1) * pageSize).Take(pageSize)
.OrderBy(x => x.Id)
.ToArrayAsync(cancellationToken);
}
A filtered, paged, ordered query, translated to a single SQL statement, projecting four columns out of eighteen. Compare that with what Sales does for the same class of request: BrowseReservations loads every reservation row, deserialises each one through the reflection resolver, and maps in C#. This is the payoff for the eight tables, and it is substantial.
Three things about it are worth flagging honestly.
.Skip().Take() before .OrderBy(). LINQ composes the expression tree rather than executing in written order, so EF emits ORDER BY before OFFSET/FETCH and the result is correct. It reads wrong, and a reader learning from this file may take away the wrong ordering rule.
No total count, and no guard on pageNumber. pageNumber = 0 produces Skip(-pageSize), which Postgres rejects. The endpoint binds pageNumber straight from the query string with no validation, so an omitted parameter binds to zero.
The requester filter is the authorisation. GetChangeRequestQueryHandler reads _userContextAccessor.Get().UserId.Value and passes it as requesterId. That is a sound pattern — filtering rather than checking — but the endpoint's declared access level is ClientAndCashier, so a cashier calling it sees their own change requests, of which there are none. The read model has no cashier-facing view at all.
Where the encapsulation finally leaks
ChangeRequestBasicData carries ReservationChangeRequest.CompletionStatus? — the domain enum, nested inside the aggregate class, referenced from the application layer and serialised straight onto the wire. PostsaleModule.cs:55-72 returns the handler's result directly with no DTO in between.
So the shape a client sees is: four fields named after nothing in particular, plus a domain enum whose values are ChangeApplied, ChangeRejectedByRequester and ChangeRejectedOnPaymentOverdue. Rename one of those and you have changed a public API.
The ADR that created this module asked for a maximally restricted public API including the data model. The aggregate honoured that request completely. The read model undercuts it at the last hop, and there is no DTO layer to blame — the module simply does not have one.
To be fair: this is a two-endpoint module in a teaching repository, and adding a mapping layer to serve one query would have been ceremony. The observation is about where a design leaks, not about whether the leak matters here.
Sales, by contrast, never split
The other Clean Architecture module in the same estate does none of this.
// src/Sales/.../Queries/Reservations/GetReservationsQueryHandler.cs:16-27
public async Task<List<ReservationDto>> HandleAsync(GetReservationsQuery query, CancellationToken cancellationToken = default)
{
var unconfirmed = (await _reservationRepository.BrowseUnconfirmedReservations(cancellationToken))
.Select(UnconfirmedReservationMap.Map)
.ToList();
var reservations = (await _reservationRepository.BrowseReservations(cancellationToken))
.Select(ReservationMap.Map)
.ToList();
return unconfirmed.Concat(reservations).ToList();
}
Queries go through the same IReservationRepository the commands use, reconstitute full aggregates, and map. One context, one model, no IDoNotMigrate, no read POCO. It can do that precisely because Reservation has sixteen public getters — a mapper can read it.
The two modules land on opposite sides of the CQRS read-model question, and the reason is the encapsulation choice, not a considered position on CQRS. Neither the README nor any of the four ADRs mentions the divergence.
That is the finding worth carrying forward. Sealing an aggregate does not eliminate the need to read it. It relocates the read to a place the aggregate cannot police — a second class, a second context, a raw column list. Whether that is a cost or a benefit depends on whether you wanted a read model anyway. Postsale did, and got a good one by accident.
Next, the model the scheduler can query — the third persistence strategy, and the five-second poll that is the entire reason it exists.