The Queries You Cannot Write
What the document strategy really costs - a promoted column, a side table, an in-memory dictionary with a TODO on it, and six update methods that look the row up without its id.
Part 6 ended the scorecard on an open row: query by anything other than the primary key. That row is where the document strategy is decided, and GroupFlights answers it three separate times, three different ways, in one module.
None of the three answers involve querying the document.
Answer one: promote the column
Contract signing happens in Backoffice. When a signed contract comes back, Sales needs to find the reservation it belongs to — and all it has is a contract id, which lives somewhere inside the JSON.
// src/Sales/.../Data/Models/ReservationDbModel.cs:24-33
internal class ReservationDbModel : DbModel
{
public static readonly string UnconfirmedReservationType = nameof(UnconfirmedReservation);
public static readonly string ReservationType = nameof(Reservation);
public Guid Id { get; set; }
public string Type { get; set; }
public string Object { get; set; }
public Guid? ContractId { get; set; }
}
OfferDbModel has three columns. ReservationDbModel has four, and the fourth is a value copied out of the document so that SQL can see it. The copy is kept in sync by hand, on every write:
existingReservation.Object = ComplexObjectSerializer.SerializeToJson(reservation);
existingReservation.ContractId = reservation.ContractToSign?.ContractId;
Two assignments where the second is derivable from the first. Forget it in one of the write paths and the lookup breaks for that reservation, silently, with no constraint to catch it.
The lookup itself has a third failure semantic, which is worth a moment:
// src/Sales/.../Data/Repositories/ReservationRepository.cs:81-87
public async Task<UnconfirmedReservation> GetUnconfirmedReservationByContractId(Guid contractId, CancellationToken cancellationToken = default)
{
var reservationDbModel = await _dbContext.Reservations.Where(_ => _.Type.Equals(ReservationDbModel.UnconfirmedReservationType))
.Where(_ => _.ContractId.Equals(contractId)).SingleOrDefaultAsync(cancellationToken);
return ComplexObjectSerializer.DeserializeFromJson<UnconfirmedReservation>(reservationDbModel.Object);
}
No null check. The sibling methods GetDraftById and GetOfferById both throw DoesNotExistException on a miss; this one dereferences and throws NullReferenceException, which the estate's error middleware turns into a 500 rather than a 404. There is also no index on ContractId, in a migration that declares no non-key indexes at all.
The genuinely interesting thing: the column is jsonb, not text. Postgres could answer this query directly with Object -> 'ContractToSign' ->> 'ContractId', and Npgsql's EF provider can translate that. No code in the module ever touches the document with a JSON operator. The jsonb type is chosen and then used as an opaque string. That is a real missed affordance and it is worth saying plainly, while also saying the counter-argument: hand-written JSON path predicates against a serialised C# object graph couple your SQL to your field names, which is precisely the coupling the document strategy was meant to avoid.
Answer two: a whole side table
TimeManagement owns deadlines. It hands back a DeadlineId, and later fires DeadlineOverdueIntegrationEvent carrying only that id. Sales then has to answer: which of my four aggregate types owns this deadline, and which instance?
The document cannot answer it, so a third table exists:
// src/Sales/GroupFlights.Sales.Application/DeadlineRegistry/DeadlineRegistryEntry.cs:3-6
public record DeadlineRegistryEntry(Guid DeadlineId, string SourceType, Guid SourceId)
{
private DeadlineRegistryEntry() : this(default, default, default){}
}
DeadlineId is the key, SourceType is a nameof string, SourceId is the aggregate's id. It is a hand-rolled foreign-key index, stored as a row, maintained by application code, and it exists purely because the thing it indexes is inside a blob. Of Sales' three tables, one is not domain data at all — it is a lookup structure the database would normally maintain for free.
The registry is at least persisted and reasonably careful: SaveMapping checks for an existing entry and throws AlreadyExistsException, GetByDeadlineId throws DoesNotExistException on a miss. The string SourceType then feeds a switch on nameof(Offer) / nameof(Reservation) in the event handler, which is a type test in string clothing — but the mechanism works.
Answer three: a static dictionary with a TODO on it
The same problem arrives a second time, from Finance, with payment ids. This time the answer is:
// src/Sales/GroupFlights.Sales.Application/PaymentRegistry/PaymentRegistry.cs:3-19
internal class PaymentRegistry : IPaymentRegistry
{
//TODO: Przepisac na wlasciwe persistence
private static readonly Dictionary<Guid, PaymentRegistryEntry> _inMemoryRegistry = new ();
public Task SaveMapping(PaymentRegistryEntry paymentRegistryEntry, CancellationToken cancellationToken = default)
{
_inMemoryRegistry.Add(paymentRegistryEntry.PaymentId, paymentRegistryEntry);
return Task.CompletedTask;
}
public Task<PaymentRegistryEntry> GetByPaymentId(Guid paymentId, CancellationToken cancellationToken = default)
{
_inMemoryRegistry.TryGetValue(paymentId, out var payment);
return Task.FromResult(payment);
}
}
The comment translates to “rewrite to proper persistence”, and it is doing a lot of work.
DeadlineRegistry and PaymentRegistry implement the same shape — id, source type, source id — against the same problem, and one of them is a table while the other is a process-local Dictionary. Restart the API and every payment-to-reservation correlation ever recorded is gone. The PaymentCompleted webhook then arrives, GetByPaymentId returns null, and — because TryGetValue's result is discarded — the caller receives a null entry rather than an exception. The reservation is never told its payment landed.
A committed payment in Finance and a reservation in Sales that will never learn about it, separated by one process restart and one dictionary. This is the clearest example in the estate of a declared shortcut having an undeclared blast radius: the TODO says “persistence”, and what it actually means is “cross-module correlation is lost on restart”.
Also worth noting: it is static, not injected state. The class is registered in DI, but the dictionary is process-global, which is an idiom this estate uses in three places and which Part 12 will show is load-bearing somewhere much more surprising.
The six update methods that lost their id
This one is not a consequence of JSON. It is a plain defect, and the document strategy made it survivable.
// src/Sales/.../Data/Repositories/OfferRepository.cs:62-76
public async Task UpdateDraft(OfferDraft offerDraft, CancellationToken cancellationToken = default)
{
var existingDraft = await _dbContext.Offers.SingleOrDefaultAsync(
o => o.Type.Equals(OfferDbModel.OfferDraftType), cancellationToken);
if (existingDraft is null)
{
throw new DoesNotExistException();
}
existingDraft.Object = ComplexObjectSerializer.SerializeToJson(offerDraft);
_dbContext.Offers.Update(existingDraft);
await _dbContext.SaveChangesAsync(cancellationToken);
}
The method receives an OfferDraft with an Id. The predicate filters on Type and nothing else. It finds “the draft” — as though there could only ever be one.
The same omission appears six times:
| # | Method | Predicate at |
|---|---|---|
| 1 | OfferRepository.UpdateDraft |
OfferRepository.cs:64-65 |
| 2 | OfferRepository.ReplaceDraftWithOffer |
OfferRepository.cs:80-81 |
| 3 | OfferRepository.UpdateOffer |
OfferRepository.cs:118-119 |
| 4 | ReservationRepository.UpdateUnconfirmedReservation |
ReservationRepository.cs:66-67 |
| 5 | ReservationRepository.ReplaceUnconfirmedWithConfirmedReservation |
ReservationRepository.cs:91-92 |
| 6 | ReservationRepository.UpdateReservation |
ReservationRepository.cs:129-130 |
With exactly one row of that type in the table, SingleOrDefaultAsync happens to return the right one and everything works. With two, it throws InvalidOperationException — Sequence contains more than one element — surfacing as a 500 from an operation that has nothing to do with the second draft. The system is single-aggregate-per-type by accident.
What proves it is an oversight rather than a design is the other half of the same files. GetDraftById filters o.Id.Equals(offerId.Value) && o.Type.Equals(...). So does GetOfferById, GetReservationById and the unconfirmed variant. Every read filters by id. Every write does not. The author knew the predicate; it simply did not get copied into the mutating methods.
To be fair to the repository: this is exactly the class of bug that a hand-written persistence layer invites and that a mapped one forecloses. Postsale's equivalent operations go through _dbContext.ChangeRequests.Update(entity), where EF derives the key from the tracked entity and the developer never writes a predicate at all. The JSON strategy's real cost is not the serializer — it is that the repository layer is one hundred percent hand-written, and hand-written code has defects at a hand-written rate.
The scorecard, second entry
Sales — jsonb document |
|
|---|---|
| Query by non-key | impossible; solved by a promoted column, a side table and an in-memory dictionary |
| Correlate a foreign id | two registries plus two string-keyed switch dispatchers |
| List view cost | full table scan plus N reflective deserialisations |
| Indexes declared | none |
| Repository correctness | six update methods omit the id predicate |
| Mapping code | ~40 lines |
Three of Sales' three tables exist, and only two of them hold aggregates.
Next, when private fields become column names — Postsale pays the opposite bill, and the receipt is printed on the database schema.