Three Ages of a Domain Model
Same framework, same four-project layout, three maturities - Parcels publishes from handlers, Orders raises events and maps them inline, Availability runs a full pipeline. The lineage made diffable.
Most codebases hide their history. Refactors overwrite the earlier shapes, and by the time you arrive the architecture looks like it was always thus. Pacco — introduced in part 1 — does something rarer: it preserves three distinct maturities of the same design side by side, in three services with identical folder layouts, identical Convey wiring, and radically different ideas about where domain logic lives. Parcels is written the way DShop, the authors' earlier estate, wrote everything. Orders adds an aggregate and domain events but keeps the plumbing inline. Availability builds the full pipeline. Reading them in that order is like reading sediment layers — and because everything else is held constant, the diff is pure signal.
Age one — Parcels, the anemic fossil
Pacco.Services.Parcels has no AggregateRoot, no domain events, no event mapper, no version field. Its central entity, Core/Entities/Parcel.cs, is a validated data holder:
public void AddToOrder(Guid orderId)
{
OrderId = orderId;
}
public void DeleteFromOrder()
{
OrderId = null;
}
That is the entire behavioural surface — two setters wearing intention-revealing names. The constructor does earn some credit: it validates with throw-expressions inline in the assignments (Name = string.IsNullOrWhiteSpace(name) ? throw new InvalidParcelNameException(name) : name), so a Parcel can never exist in an invalid state. But nothing in the Core layer ever announces anything. The announcing happens a layer up, in the application handlers. Here is the tail of AddParcelHandler.HandleAsync:
var parcel = new Parcel(command.ParcelId, command.CustomerId, variant, size, command.Name,
command.Description, _dateTimeProvider.Now);
await _parcelRepository.AddAsync(parcel);
await _messageBroker.PublishAsync(new ParcelAdded(command.ParcelId));
The handler creates, persists, and then publishes an integration event it constructed itself. DeleteParcelHandler does the same with ParcelDeleted. The entity records state; the handler decides what the rest of the world is told. There is no intermediate fact — no domain event — connecting the two, so nothing forces the announcement to match what actually happened. This is exactly the shape the same authors used throughout DShop, and Parcels carries it forward unchanged: the estate's fossil record. It is also not automatically wrong — for an entity this simple, ceremony has a cost — but hold the thought, because in part 8 we will watch this very service and this very publish-from-handler style disagree with Orders about a shared fact, permanently.
Age two — Orders, the aggregate with inline plumbing
Pacco.Services.Orders introduces the middle stratum. Its Core/Entities/AggregateRoot.cs is eleven lines of substance:
public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _events = new List<IDomainEvent>();
public IEnumerable<IDomainEvent> Events => _events;
public AggregateId Id { get; protected set; }
public int Version { get; protected set; }
protected void AddEvent(IDomainEvent @event)
{
_events.Add(@event);
}
public void ClearEvents() => _events.Clear();
}
Now the domain speaks for itself. Order.Approve() guards its transition and calls AddEvent(new OrderStateChanged(this)); the handler no longer invents the announcement, it relays it. But the relaying is manual, and every handler repeats the same three-step outro. From CreateOrderHandler:
var order = Order.Create(command.OrderId, command.CustomerId, OrderStatus.New, _dateTimeProvider.Now);
await _orderRepository.AddAsync(order);
var events = _eventMapper.MapAll(order.Events);
await _messageBroker.PublishAsync(events.ToArray());
Persist, map, publish — inline, in that order, in handler after handler (ResourceReservedHandler, ResourceReservationCanceledHandler, ParcelDeletedHandler all end with the identical MapAll + PublishAsync pair). The IEventMapper is the new idea age two contributes: a single seam where private domain events become public integration contracts, which part 4 will examine on its own terms. The cost is the copy-paste: forget the outro in one handler and that handler's domain events silently die in the aggregate's list. The contract between “the domain raised it” and “the world heard it” is a convention, enforced by diligence.
Age three — Availability, the pipeline
Pacco.Services.Availability closes the arc. Its handlers end with one line — from ReserveResourceHandler:
resource.AddReservation(reservation);
await _repository.UpdateAsync(resource);
await _eventProcessor.ProcessAsync(resource.Events);
ProcessAsync is the whole outro, abstracted. Infrastructure/Services/EventProcessor.cs iterates the aggregate's domain events, and for each one does two jobs: dispatch it to any in-process domain event handlers, then translate it for the bus:
using var scope = _serviceScopeFactory.CreateScope();
foreach (var @event in events)
{
var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(@event.GetType());
dynamic handlers = scope.ServiceProvider.GetServices(handlerType);
foreach (var handler in handlers)
{
await handler.HandleAsync((dynamic) @event);
}
var integrationEvent = _eventMapper.Map(@event);
if (integrationEvent is null)
{
continue;
}
integrationEvents.Add(integrationEvent);
}
The collected integration events are then published as a batch. This is a small in-process mediator: reflection builds the closed handler type, dynamic double-dispatches to it, a fresh DI scope isolates the handling, and Scrutor assembly-scanning registers any IDomainEventHandler<> implementations automatically. Domain events are no longer merely raw material for the bus — they are a first-class in-process extension point.
And here is the part I find genuinely instructive: no IDomainEventHandler<> implementation exists anywhere in the service. The pipeline is wired, scanned for, scoped, dispatched — and empty. You can read that two ways. Uncharitably, it is speculative generality: machinery built for a need that never arrived, executing reflection on every command for nothing. Charitably — and this is a teaching estate, so charity is warranted — it is the finished staircase to a floor the authors left for you to build. Either way, the contrast with Parcels could not be sharper: one service publishes whatever its handlers improvise; the other runs every fact through a pipeline that could, in principle, react locally before telling the world.
The same class, three folders apart
The diff has one more layer, and it is the series' first honest-ledger entry. Availability's AggregateRoot is not the same class as Orders' — it just has the same name. Availability's version increments state:
protected void AddEvent(IDomainEvent @event)
{
if (!_events.Any())
{
Version++;
}
_events.Add(@event);
}
One version bump per unit of work, on the first event raised — which Availability's Mongo repository then uses as an optimistic concurrency filter. Orders' AddEvent, quoted earlier, never touches Version; the property exists, sits at zero forever, and its repository updates unconditionally. Customers' copy matches Orders'. Same name, same estate, three services — and “aggregate root” means something different in each, because Pacco distributes its building blocks by copy-paste rather than a shared package. Whether that version filter actually protects anything is a sharper question than it looks, and part 13 is devoted to it.
One more property of the strata is easy to miss because it is an absence: the wire cannot see any of this. On RabbitMQ, all three ages look identical — a snake_case routing key on a per-service topic exchange, a JSON body, the same correlation headers stamped by the same copied MessageBroker. A consumer of ParcelAdded has no way to know it was improvised by a handler; a consumer of ResourceReserved has no way to know it survived an aggregate invariant and a mapping pipeline first. Domain maturity is a producer-side property, invisible in the contract. That cuts both ways: it is what lets the three ages interoperate at all, and it is why a subscriber can never assume the guarantees behind the events it consumes — the guarantees vary by three folders, and the wire flattens them.
What the strata teach
Three durable lessons fall out of reading the ages side by side:
- Publish-from-handler couples truth to discipline. With no domain event between the state change and the announcement, nothing but reviewer vigilance keeps them aligned. Parcels gets away with it while the entity is trivial; the moment two services share a fact, it stops getting away with it.
- Aggregates with inline plumbing trade one discipline for another. Orders' domain always tells the truth internally, but every handler must remember to relay it. The repeated
MapAll+PublishAsyncoutro is the tax, paid per handler. - Pipelines centralise the discipline — and invite machinery nobody uses. Availability pays once, in infrastructure, and its handlers become almost declarative. The empty
IDomainEventHandler<>seam is the warning label: abstraction acquired ahead of need is a bet, and you should know you are placing it.
To be fair to the estate, the gradient is almost certainly deliberate. DevMentors built Pacco to teach, and showing three answers to “where do events come from?” in one codebase is better pedagogy than showing the blessed one three times. The trap would be mistaking coexistence for equivalence — the three ages interoperate over the same bus, but they do not offer the same guarantees, as the rest of this series will keep demonstrating.
Next, we descend into age three's crown jewel: the aggregate whose one business rule can throw you out of your own booking — the reservation that bumps you.