Five Seconds Is a Teaching Decision
The tick that makes eventual consistency watchable in a console, the four-hop chain that takes twenty seconds because of it, and the DI-scope trap the estate is one refactor away from firing.
Part 13 took the dispatcher's ordering apart. This part is about the number in it — TimeSpan.FromSeconds(5) — which appears three times in the estate, in the event dispatcher, the deadline scheduler and the fake payment gateway, and which is the single most consequential constant in the repository.
It is also, I think, a genuinely good decision made for a reason nobody usually admits to: it exists to be seen.
What the delay teaches
A same-thread, same-transaction dispatcher would have made this estate look synchronous. You would POST an offer acceptance, and by the time the 201 came back, the reservation would exist, the contract would be generated, the deadline would be registered and the workload would be assigned. Every cross-module effect would appear to be part of the request. A student would learn nothing about eventual consistency, because there would be nothing eventual to observe.
With a five-second timer and a console, the same POST returns immediately and then the cascade arrives. [EVENT DISPATCH] lines appear one tick at a time, each with the full event body pretty-printed:
// EventDispatcher.cs:75-76
var eventJson = JsonConvert.SerializeObject(@event, Formatting.Indented);
_logger.LogInformation("[EVENT DISPATCH]" + Environment.NewLine + @event.GetType().Name + Environment.NewLine + eventJson);
That is a Wire Tap, and combined with the tick it turns an abstract architectural property into something you can watch happen. The appsettings.Development.json overlay adds exactly one setting — "Microsoft.EntityFrameworkCore": "Warning" — which suppresses EF's per-query SQL logging and keeps the console readable enough for the cascade to stand out. That is a small, deliberate piece of tuning in service of the same goal.
The comment on the class states the architectural reason (transactional independence between modules), and the architectural reason is real. But the pedagogical reason is why five seconds rather than fifty milliseconds. Fifty milliseconds would achieve identical isolation and teach nothing.
What the delay costs the estate's own walkthrough
The README is a run-book: three commands, then a numbered fourteen-step business walkthrough, each step linking a specific .http file, ordered as the real sales process. Add an inquiry, accept it, add a flight variant, confirm it, reveal the offer, accept it, browse reservations, download the contract, sign it, provide passenger names, add a payer, set up payment, request a change, set the change feasibility.
It is the best onboarding artefact in the repository, and the tick undermines it.
Cascade latency multiplies. A handler that publishes a further event does not dispatch it in the current tick — it enqueues it for the next one. So a four-hop chain takes up to four times five seconds. Accepting an offer produces a domain event, which becomes an integration event, which a handler turns into a deadline request, which TimeManagement answers with another event, which Sales handles. Twenty seconds of wall clock for one business action, none of it visible in the HTTP response.
A reader working through the fourteen steps at normal speed reaches step 8 — download the generated contract — before the contract exists. They get a 404. Except they do not get a 404, they get this:
HTTP 500, body
{"errorMessage":"Coś poszło nie tak!"}
Because the estate's access-control middleware indexes a static dictionary with a reconstructed route key, and an unmatched route produces a KeyNotFoundException rather than a miss — Part 16 takes that apart. So the most common onboarding failure surfaces as an opaque Polish 500, and the reader's reasonable conclusion is that the repository is broken.
Nothing in the README mentions the wait. The console log is the only signal, and only if you know to look at it.
The fix costs one sentence of documentation, or one @name-chained .http file with a poll. The observation worth generalising: a delay chosen to make asynchrony visible also makes every sequential instruction you write about the system a race. If you slow a system down deliberately, the documentation has to say so at every step where it matters, not once at the top.
Two smaller DevEx cracks compound it. The compose file has no healthcheck, so docker-compose up -d returns before Postgres accepts connections and the three README commands can race each other. And both processes bind HTTPS on localhost, making dotnet dev-certs https --trust an undocumented hard prerequisite — without it, the gateway's webhook POST fails TLS validation, the exception is logged inside the gateway process, and the walkthrough stalls at step 12 with nothing visible on the API side at all.
The trap the estate has not fallen into yet
The dispatcher creates a fresh DI scope on a background thread:
private async Task DispatchDynamicallyAsync(IEvent @event, CancellationToken cancellationToken = default)
{
using var scope = _serviceProvider.CreateScope();
var handlerType = typeof(IEventHandler<>).MakeGenericType(@event.GetType());
var handlers = scope.ServiceProvider.GetServices(handlerType);
…
}
That is correct and necessary — it is what gives each handler its own DbContext and delivers the transactional independence the design is built on. But a scope created on a timer thread has no HttpContext. IHttpContextAccessor.HttpContext is null there.
Now the identity provider:
// src/Api/GroupFlights.Api/UserContext/NaiveUserContext.cs:23-43
public static NaiveUserContext CreateFrom(HttpContext httpContext, ILogger logger)
{
var userIdFound = httpContext.Request.Headers.TryGetValue(UserIdHeader, out var userId);
var cashierIdFound = httpContext.Request.Headers.TryGetValue(CashierIdHeader, out var cashierId);
var isAdminFound = httpContext.Request.Headers.TryGetValue(AdminHeader, out var isAdmin);
try
{
return new NaiveUserContext
{
UserId = userIdFound ? new UserId(Guid.Parse(userId.FirstOrDefault() ?? string.Empty)) : null,
…
};
}
catch (Exception ex)
{
logger.LogError(ex, ex.Message);
return new NaiveUserContext();
}
}
The three TryGetValue calls are outside the try. In a background dispatch scope, httpContext is null, httpContext.Request throws an uncaught NullReferenceException, and the catch that was clearly written to absorb exactly this kind of failure never sees it. The exception escapes into the dispatcher's own catch, becomes one LogError line, and the event is dropped — it was already dequeued.
So: any handler that reaches IUserContextAccessor.Get() during a background dispatch silently loses its event. Moving three lines inside the try would turn that into a graceful empty context.
How close is the estate to firing it? One method.
// src/Backoffice/.../Services/DocumentService.cs:31-44
public async Task GenerateContract(ContractGenerationRequestedIntegrationEvent @event,
CancellationToken cancellationToken)
{
var fileBytes = await _contractGenerator.Generate(@event, cancellationToken);
var fileId = Guid.NewGuid();
await _fileRepository.UploadFile(new DocumentFile
{
…
Owner = @event.ContractSignee.UserId
}, cancellationToken);
}
DocumentService takes an IUserContextAccessor in its constructor. ContractGenerationRequestedEventHandler calls GenerateContract from a background dispatch. And GenerateContract gets the owner from the event payload rather than the accessor — while UploadSignedContract, two methods down, calls _userContextAccessor.Get().UserId and is only ever reached from an HTTP request.
The class holds a dependency that is safe in one method and fatal in the other, with nothing marking the difference. Add one line to GenerateContract that asks who the current user is, and the contract generation step of the walkthrough starts failing silently, forever, with one log line.
That is the durable warning: a background dispatcher and a request-scoped identity accessor are a landmine, and the fuse is a constructor parameter that looks harmless. If your handlers run outside a request, either the identity has to travel in the message envelope or the accessor has to fail loudly rather than throw from a line the author forgot to guard. This estate does the first by accident and the second not at all.
Was five seconds right?
For the purpose it was chosen for, yes. Watching a cascade land tick by tick teaches eventual consistency better than any diagram, and the estate is a course companion.
But the number is a single hardcoded literal repeated in three files, bound to nothing. There is no IOptions<DispatcherOptions>, no configuration key, no environment override. The estate has a DeadlinesConfiguration record built as though for options binding and never bound to anything either, so this is a pattern rather than an oversight: shapes that imply configuration, in a repository that has no configuration story beyond two appsettings files.
The version I would want is dull: one options class, five seconds in Development, fifty milliseconds everywhere else, and one sentence in the README explaining why the walkthrough needs patience. The teaching value survives. The onboarding cliff does not.
Next, the ADR said UTC and one switch unsaid it — time as a domain concern, honoured in thirty files and quietly undone by a single line in shared plumbing.