Skip to content
Kumar Chandrachooda
.NET

Two Ways to Tell a Service Something Changed

Deliveries maps domain events into integration events that carry facts while Vehicles publishes bare ids straight from its handlers - two event philosophies in one estate, and a vehicle-to-resource mirror that syncs deletes but never creates.

By Kumar Chandrachooda 14 Feb 2026 6 min read
Two envelopes, one full and one holding only a name

Part 7 catalogued the estate's message vocabulary — eighty names in a hand-kept file. This part is about what those messages carry, because on that question the estate disagrees with itself. Two of its support services, built by the same authors on the same framework in the same season, answer “how do I tell the world something changed?” in two incompatible ways. Deliveries publishes events that carry the fact; Vehicles publishes events that carry a business card. Reading them side by side is the cheapest education in event design I know, and the bill for running both styles at once lands — as it always does in this series — on the consumers.

Philosophy one: events that carry the fact

Deliveries is a proper little domain service. Its Delivery aggregate raises domain events — DeliveryStateChanged, which carries the whole delivery, and DeliveryRegistrationAdded — and its handlers never publish those directly. They pass through a translation seam, EventMapper, before touching the bus:

// Pacco.Services.Deliveries.Infrastructure/Services/EventMapper.cs
public IEvent Map(IDomainEvent @event)
{
    switch (@event)
    {
        case DeliveryStateChanged e:
            switch (e.Delivery.Status)
            {
                case DeliveryStatus.InProgress:
                    return new DeliveryStarted(e.Delivery.Id, e.Delivery.OrderId);
                case DeliveryStatus.Completed:
                    return new DeliveryCompleted(e.Delivery.Id, e.Delivery.OrderId);
                case DeliveryStatus.Failed:
                    return new DeliveryFailed(e.Delivery.Id, e.Delivery.OrderId, e.Delivery.Notes);
            }
            break;
        case DeliveryRegistrationAdded e:
            return new RegistrationAddedToDelivery(e.Delivery.Id, e.Delivery.OrderId,
                e.Registration.Description);
    }

    return null;
}

One coarse internal fact — “the state changed” — fans out into three precise public facts, each named in the past tense and each carrying what a consumer needs to act: the delivery id, the order id it belongs to, and, where it matters, the payload of the change (Notes on failure, the registration's Description). A handler's tail is always the same two lines — _eventMapper.MapAll(delivery.Events) then _messageBroker.PublishAsync(events.ToArray()) — so the aggregate decides what happened and the mapper decides what the world should hear. (The return null for an unmatched event is a quiet trap this series will weigh properly in the smell audit; an unmapped domain event here becomes a null on the bus-publishing path, not an error.)

The proof that the payload is sufficient sits in Orders. It subscribes to DeliveryStarted, DeliveryCompleted and DeliveryFailed, and advances its own state machine from the event alone — there is no Deliveries HTTP client anywhere in the estate, because nobody ever needs to call Deliveries back. An event that carries the fact completes the conversation in one message.

Philosophy two: the doorbell

Now Vehicles. No domain events, no mapper, no translation seam. Its handlers construct the entity, save it, and publish the announcement themselves — here is AddVehicleHandler, trimmed only of its constructor:

public async Task HandleAsync(AddVehicle command)
{
    var vehicle = new Vehicle(
        command.VehicleId,
        command.Brand,
        command.Model,
        command.Description,
        command.PayloadCapacity,
        command.LoadingCapacity,
        command.PricePerService,
        command.Variants);

    await _repository.AddAsync(vehicle);
    await _broker.PublishAsync(new VehicleAdded(command.VehicleId));
}

Eight fields of vehicle go into the database; one field comes out on the wire. All three of Vehicles' events — VehicleAdded, VehicleUpdated, VehicleDeleted — are the same single-property class:

[Contract]
public class VehicleUpdated : IEvent
{
    public Guid VehicleId { get; }

    public VehicleUpdated(Guid id)
        => VehicleId = id;
}

VehicleUpdated is the sharpest of the three. Something about this vehicle changed — the price, the capacity, the variants, the brand — and the event names none of it. A consumer that cares must call GET /vehicles/{id} and diff for itself. These are not events in the carry-the-fact sense; they are doorbells. An id-only event moves the obligation to fetch, not the fact — the state transfer that Deliveries performs on the bus, Vehicles performs later, over HTTP, on demand, at the consumer's expense.

And the estate shows you that expense. When Orders needs vehicle data — assigning a vehicle to an order — it does not consult anything learned from vehicle_added; it holds a VehiclesServiceClient and fetches the vehicle synchronously, mid-command, adding Vehicles' uptime to the write path's availability equation. Meanwhile the events themselves go nearly unheard: across all thirteen repositories, the only subscribers to Vehicles' three events are Operations (which reads no payload anyway, as part 7 showed) and one handler in Availability. Which brings us to the mirror.

The mirror that only demolishes

Availability's whole domain is reserving resources — and in practice a resource is a vehicle wearing a different bounded context's name, sharing the same GUID. So Availability maintains a mirror of Vehicles' catalogue, and its subscription list is the tell:

// Pacco.Services.Availability.Infrastructure/Extensions.cs (trimmed)
.SubscribeCommand<ReserveResource>()
.SubscribeEvent<CustomerCreated>()
.SubscribeEvent<VehicleDeleted>();

VehicleDeleted — and no VehicleAdded, no VehicleUpdated. The one wired handler is a model of economy:

public class VehicleDeletedHandler : IEventHandler<VehicleDeleted>
{
    private readonly ICommandDispatcher _dispatcher;

    public VehicleDeletedHandler(ICommandDispatcher dispatcher)
        => _dispatcher = dispatcher;

    public Task HandleAsync(VehicleDeleted @event)
        => _dispatcher.SendAsync(new DeleteResource(@event.VehicleId));
}

Delete a vehicle and its resource twin is demolished automatically. Create a vehicle and — nothing. The resource must be created by a separate, explicit AddResource command carrying the vehicle's GUID, which in the estate's run-book is a manual step: add the vehicle, then remember to add the resource with the same id. Forget, and you have a vehicle that Vehicles will happily offer and Availability has never heard of; the first reservation against it dies on a ResourceNotFoundException. The mirror is one-way glass: it can only shrink by itself, never grow.

I do not think the asymmetry is laziness, and it is worth seeing why: it is the direct consequence of philosophy two. Deletion is the one lifecycle event for which an id is a complete fact — DeleteResource(id) needs nothing else. Creation is not: a resource is created with a set of descriptive tags — data an id-only VehicleAdded simply does not carry. A mirror-create handler would have had to call Vehicles back over HTTP from inside an event handler, and the authors — reasonably — declined. The shape of your events quietly decides which automations are even possible downstream. Deliveries-style events would have made the create leg a five-line handler; doorbell events made it a design problem, so it became a run-book step instead.

Why both, and what the tax is

How does one estate end up with both philosophies? Lineage, mostly. Publish-from-the-handler with minimal payloads is the older shape — the pattern of the authors' previous estate, DShop — while the domain-event-plus-mapper pipeline is the newer discipline that Pacco's core services grew. Vehicles is a simple catalogue and nobody revisited it; Deliveries was written with the newer muscle. Convey, deliberately, has no opinion: an IEvent is any class, and the framework will faithfully deliver a rich fact or a bare id with equal enthusiasm. The framework's neutrality is defensible — but it means event design is another decision every consumer of the framework makes alone, per service, and Pacco made it twice, differently.

The costs land in three places. Consumers inherit inconsistency: subscribing to Deliveries means reading payloads, subscribing to Vehicles means budgeting an HTTP call-back, and a developer moving between repos must re-learn the house physics each time. Contract governance inherits weight it cannot carry: state-carrying events are richer schemas to keep honest, and parts 6 and 7 showed how little honesty-keeping machinery exists. And integrations inherit gaps like the mirror's missing create leg — visible only when someone asks why a brand-new vehicle cannot be booked. To be fair to id-only events one last time: they can never ship a stale payload, and in an estate whose outbox runs with transactions off, “the event is only a pointer to the truth” is not a stupid stance. It is just a stance nobody wrote down, in an estate where the other stance is also in production. When two services co-own an association with richer events and no more governance, the domain series showed the result — the parcel that wouldn't leave — so neither philosophy survives this estate unbruised.

My rule of thumb after reading both services: an event should carry what its cheapest consumer needs to act, or admit it is a doorbell — and a doorbell estate must budget the fetch it just outsourced.

One component in the estate has no choice but to consume every style at once — commands, events, doorbells and rejections — because its job is to drive a whole order across five services and listen to everything that comes back. Next: the saga that rides a header, where OrderMaker's Chronicle orchestration stamps its state into a RabbitMQ header and the entire estate agrees to carry it.