One Request, Four Services
AssignVehicleToOrder is the one command in Pacco that cannot finish alone - Orders calls Vehicles, then Pricing, and Pricing calls Customers, a synchronous enrichment chain on the write path of a messaging estate. At the end of it sits a deliberately bus-less calculator with a loyalty ladder, an unassigned Id and a discount logged in dollars.
Part 13 ended with a service failing to protect itself from its own replicas. This part is about the opposite exposure: a service that cannot act without three other services answering the phone, in order, right now. Fourteen parts into a series about choreography — events, reactions, compensations, no conductor — it is time to be honest about the one flow that is nothing of the sort. When a customer picks a vehicle for their delivery, the estate stops being event-driven and becomes a telephone chain: Orders calls Vehicles, then Orders calls Pricing, and Pricing calls Customers, all synchronously, all inside one command handler.
The chain, end to end
AssignVehicleToOrderHandler in Pacco.Services.Orders.Application/Commands/Handlers/ is the estate's sync-orchestration showpiece. Trimmed to the flow:
public async Task HandleAsync(AssignVehicleToOrder command)
{
var order = await _orderRepository.GetAsync(command.OrderId);
if (order is null)
{
throw new OrderNotFoundException(command.OrderId);
}
// ... identity ownership check, HasParcels guard ...
if (!order.CanAssignVehicle)
{
return;
}
var vehicle = await _vehiclesServiceClient.GetAsync(command.VehicleId);
if (vehicle is null)
{
throw new VehicleNotFoundException(command.VehicleId);
}
var pricing = await _pricingServiceClient.GetOrderPriceAsync(order.CustomerId, vehicle.PricePerService);
order.SetVehicle(command.VehicleId);
order.SetTotalPrice(pricing.OrderDiscountPrice);
order.SetDeliveryDate(command.DeliveryDate);
await _orderRepository.UpdateAsync(order);
await _messageBroker.PublishAsync(new VehicleAssignedToOrder(command.OrderId, command.VehicleId));
}
Hop one: VehiclesServiceClient.GetAsync fetches GET vehicles/{id} and keeps two fields — Id and PricePerService. Hop two: PricingServiceClient calls GET pricing?customerId=...&orderPrice=... with the vehicle's price as input. Hop three happens out of sight: Pricing's GetOrderPricingHandler cannot answer without phoning Customers for the loyalty data — GET customers/{id}, the full-details endpoint from part 10, PII and all, to count a list. Each call enriches the command with data the next step needs — vehicle price feeds pricing, loyalty feeds the discount — a Content Enricher chain in classic EIP terms, spliced into the middle of a messaging workflow.
Count the parties to one button press: Orders, Vehicles, Pricing, Customers. Four services, three network hops, one write. For this command to succeed, all four must be up simultaneously — availability multiplication, and the arithmetic is unforgiving: four services at 99.9 % each gives the chain roughly 99.6 %, before you add the gateway in front. Every other write in this series tolerates a dead neighbour; events queue up and choreography resumes when the neighbour returns. This one turns a RabbitMQ-connected estate into a distributed monolith for the duration of a handler.
What failure actually looks like
The interesting engineering is in how the chain fails, and here the source repays close reading. Convey's HTTP client wraps every call in a Polly policy — Handle<Exception>().WaitAndRetryAsync(retries, r => 2^r seconds), three retries per the httpClient config. Two consequences hide in that line.
First, the retries fire on exceptions only — connection refused, DNS failure, socket timeout. A downstream service that answers with a 500 does not throw; Convey's SendAsync<T> checks IsSuccessStatusCode and returns default — that is, null — immediately, with no retry. So the resilience policy protects against a service being absent but not against a service being broken, which is the more common production failure.
Second, look at what each null becomes. A Vehicles outage (or 500) makes vehicle null, and the handler throws VehicleNotFoundException — an infrastructure failure reported as a domain fact. The caller is told the vehicle does not exist; it exists fine, its service was merely down, and the exception-to-message mapper will happily publish the corresponding rejected event as though the vehicle id were bad. A Pricing failure is worse: pricing comes back null and there is no null check at all — the very next line dereferences pricing.OrderDiscountPrice and the handler dies on a NullReferenceException, which no mapper recognises, so the message nacks and retries in a loop. Same chain, two hops, two different lies: one failure mode masquerades as missing data, the other as a crash. And when the outage is a real connection failure, the exponential backoff means each hop can block for 2 + 4 + 8 seconds before giving up — a fourteen-second hold on a message consumer, per hop, while the queue behind it grows.
There are no circuit breakers to cut any of this short; the only ones in the estate live in the Ocelot gateway that no compose file deploys, a story the companion series tells in a gateway in YAML and its twin in code. And one more wart from the excerpt deserves its sentence: if (!order.CanAssignVehicle) return; — every sibling guard in this handler throws, but the state guard silently succeeds, so assigning a vehicle to a delivering order returns the same 200 as assigning one to a new order, having done nothing. Two lines later, SetVehicle turns out to be a naked setter that never consults CanAssignVehicle itself — the invariant exists as a property, is enforced nowhere in the aggregate, and is politely declined rather than defended in the handler.
The calculator at the end of the chain
The final hop lands on the estate's most unusual service, and I want to be fair to it, because Pricing is a deliberate architectural argument. It is the only single-project service in the domain — Core/, DTO/, Queries/, Services/ as folders, not projects. It has no MongoDB, no RabbitMQ, no outbox, no state of any kind: its AddInfrastructure registers an HTTP client, discovery, metrics and tracing, and stops. Its entire public surface is one query route, GET pricing. In an estate that preaches messaging, Pricing quietly demonstrates when not to message: a pure function over two inputs has no business owning a queue. The domain logic is one method:
public decimal CalculateDiscount(Customer customer)
{
var discount = 0.0m;
if (customer.CompletedOrdersNumber >= 10)
{
discount = 0.1m;
}
else if (customer.CompletedOrdersNumber < 10 && customer.CompletedOrdersNumber > 3)
{
discount = 0.05m;
}
else if (customer.CompletedOrdersNumber <= 3 && customer.CompletedOrdersNumber > 0)
{
discount = 0.02m;
}
if (customer.IsVip)
{
discount += 0.1m;
}
return discount;
}
The loyalty ladder: 2 % after your first completed order, 5 % above three, 10 % at ten, and a further 10 % stacked on top once Customers' VipPolicy flips you to VIP at twenty completed orders. Combined with the state machine from part 9, this is a genuine cross-service business rule — Customers decides who is VIP, Pricing decides what that is worth — and it is the kind of rule realism that makes this estate worth reading. (The redundant range guards — < 10 && on a branch that already implies it — are free code-review material.)
Then the honest ledger, because this tiny service has three entries. Core/Entities/Customer.cs declares public Guid Id { get; private set; }, takes id as its first constructor parameter, and never assigns it — every customer Pricing ever builds is Guid.Empty, harmless only because nothing keys on it yet. The handler's log line reports discount: {customerDiscount} $ — a fraction labelled as dollars, so a 10 % discount logs as "0.1 $". And the result guard, orderDiscountPrice > 0 ? orderDiscountPrice : query.OrderPrice, quietly returns full price if the discount arithmetic ever produced zero or less — unreachable with the current ladder (no discount reaches 100 %), but a booby trap for the first person who adds a promotion, who will discover that a free order bills at list price.
The bus was right there
What makes this chain an architectural finding rather than a style complaint is that the estate already owns every tool needed to break it. Vehicles publishes events; Orders could hold a local replica of {vehicleId, pricePerService} the way it holds customer ids — part 10's stance one, for data that changes rarely. The loyalty inputs — VIP flag, completed-order count — change on exactly two events (OrderCompleted, CustomerStateChanged); Pricing could subscribe and answer from memory, or Orders could carry a cached discount. Any of these moves collapses four services to one on the write path and turns the price into what it actually is: a fact computed from slowly-changing state, not a question that must wake two services per assignment. The counter-argument — price freshness matters, quotes must reflect this second's loyalty standing — deserves its hearing, but this estate cannot make it with a straight face while its reservation flow tolerates seconds of event lag on much more dangerous facts. When a write path phones N services for data that changes on known events, you have not chosen consistency — you have declined to model it.
One part remains. We have read the aggregates, the events, the bugs, the outbox, the wire and the telephone chain; what is left is the ledger — what this estate is genuinely excellent at teaching, where its edges are sharpest, and the strange gap between rules that are realer than most production systems and data that is cardboard all the way down. Next: rich rules, demo-grade data.