Three Services, One Order: The Sample System the Series Skipped
The 22-part Convey series read the packages; it never once walked the sample system that ships with them. Opening the missing chapters with the trip an order takes through Orders, Pricing and Deliveries — and what the samples quietly teach that no package README does.
Over twenty-two weeks, the Microservices with Convey series took apart every package in the toolkit — the builder, the CQRS spine, RabbitMQ, the outbox, Vault, all of it. When I finished the retrospective, I did what I always do after a long series: I went back through my notes looking for what I had skipped. The list was longer than I expected. The whole sample system. The appsettings.json as an artifact in its own right. An entire package I waved off in one paragraph. The docker-compose file. Testing — anywhere. The ecosystem Convey came out of.
So this is the companion series: Convey — The Missing Chapters. Eight parts, same weekly slot, same rules — Convey is open source from the DevMentors ecosystem, credit belongs to its authors, and my job is to read it carefully, not to claim it. Where the first series went package by package, this one goes into the seams between the packages — and there is no better place to start than the thing I quoted fragments of for five months without ever walking end to end: the Conveyor sample.
The system in one sentence
The samples/ folder of the Convey repository holds three services — Orders, Pricing, Deliveries — plus a docker-compose stack and a Conveyor.rest script. The business flow is deliberately tiny: you POST an order, Orders synchronously asks Pricing what it costs, saves it to Mongo, publishes OrderCreated through RabbitMQ; Deliveries picks that up, starts a delivery, and publishes DeliveryStarted back; Orders subscribes to that too. One synchronous hop, two asynchronous hops, a full circle.
What makes it worth an article is not the flow. It is that the three services are deliberately different sizes, and the differences are the curriculum.
Three composition chains, three sizes of service
Pricing is the smallest thing that counts as a Convey service. Its entire registration is eight calls: AddConvey(), an error handler, Consul, Fabio, Jaeger, Prometheus, AddWebApi(), Build(). No Mongo, no RabbitMQ, no CQRS handlers — its one endpoint is wired inline:
.Get<GetOrderPricing>("orders/{orderId}/pricing", async (query, ctx) =>
await ctx.RequestServices.GetRequiredService<IJsonSerializer>()
.SerializeAsync(ctx.Response.Body, new PricingDto
{
OrderId = query.OrderId, TotalAmount = 20.50m
}))
That is the endpoint DSL from part 5 used in its rawest form: a query type bound from the route, no handler class, hand serialization straight into the response body. Every order costs 20.50 — Pricing exists to be called, not to be interesting.
Deliveries is the middle size: no HTTP API beyond ping, no database, just event handlers and a broker connection. It is what a pure worker service looks like on this chassis.
Orders is the flagship, and its Program.cs is the single densest artifact in the repository — twenty-five Add* calls composing Consul, Fabio, Jaeger, Mongo plus a typed repository, all three CQRS dispatchers, Prometheus, Redis, RabbitMQ with the Jaeger plugin, a Mongo-backed outbox, the web API, and both Swagger packages. When I quoted it in part 1 of the parent series I trimmed it; read untrimmed, the pipeline half is just as instructive as the registration half, because the order of Use* calls — error handler before routing, certificate authentication before endpoints, UseRabbitMq().SubscribeEvent<DeliveryStarted>() dead last — is a working answer to the middleware-ordering questions the package docs never address.
The lesson the sizes teach: the chassis does not impose a floor. A service pays for exactly the capabilities it composes, and the samples prove it by shipping an 8-line service and a 25-line service on the same frame.
The handler where everything meets
CreateOrderHandler in Orders is the one class where more Convey packages intersect than anywhere else in the samples — repository, HTTP client, tracer, publisher, outbox, all injected into forty lines. Its shape (source: Convey samples, MIT-licensed):
var pricingDto = await _pricingServiceClient.GetAsync(command.OrderId);
// ...
var order = new Order(command.OrderId, command.CustomerId, pricingDto.TotalAmount);
await _repository.AddAsync(order);
var spanContext = _tracer.ActiveSpan?.Context.ToString();
var @event = new OrderCreated(order.Id);
if (_outbox.Enabled)
{
await _outbox.SendAsync(@event, spanContext: spanContext);
return;
}
await _publisher.PublishAsync(@event, spanContext: spanContext);
Three things here repay staring.
The outbox fork is application code. The chassis gives you IMessageOutbox and IBusPublisher, but choosing between them, per publish, is your handler's job — an if on _outbox.Enabled. I discussed the outbox pattern at length in the parent series without ever pointing out that the sample's own authors decided the toggle belongs in the handler, not behind the abstraction. That is a real design position: the handler that wrote to Mongo is the only code that knows its event must travel through the same durability domain.
The synchronous call sits inside the command. Orders calls Pricing over HTTP during CreateOrder — if Pricing is down, order creation fails. The sample is honest about the coupling instead of hiding it behind a cache or a default price, and the client it uses (PricingServiceClient) is itself a lesson: it resolves the URL from the httpClient.services map, and if Vault PKI and certificate security are both enabled, it fetches its client certificate and attaches it as a header — the mTLS story actually joined up, in one constructor.
The span context is threaded by hand. The handler pulls _tracer.ActiveSpan?.Context and passes it into the publish call so the trace survives the broker hop. Which sets up the best comment in the whole sample.
The comment that admits the seam
On the other side of the broker, Deliveries' OrderCreatedHandler has to receive that span context. It digs the correlation id out of IMessagePropertiesAccessor, reads the span_context header, decodes it from bytes — and right above that block sits a comment from the authors: //Refactor to an external decorator.
I love this comment. It is the sample admitting, in-line, that tracing propagation is cross-cutting plumbing that leaked into a business handler. The chassis has a decorator seam — part 4 was entirely about it — and the Jaeger RabbitMQ plugin handles the incoming span; but publishing a new event with the inherited context is left as manual work, and the authors knew it. When a toolkit's own sample carries a TODO, that TODO is the most accurate documentation of the toolkit's boundary you will ever get.
The controller nobody talks about
Here is the detail that genuinely surprised me on the re-read: Orders ships an OrdersController. A classic [ApiController], attribute-routed at api/orders, injecting ICommandDispatcher and IQueryDispatcher — living in the same service as UseDispatcherEndpoints, which wires the very same GetOrder and CreateOrder messages at orders/... with no controller at all. The pipeline runs both: UseEndpoints(r => r.MapControllers()) immediately followed by UseDispatcherEndpoints(...).
Two routes to every operation, one MVC-flavored and one chassis-flavored, in the flagship sample. As documentation it is superb — it shows a team exactly what migrating controller code onto the dispatcher DSL looks like, diffable in one file. As a shipped service it would be a code-review rejection: two public shapes for the same contract, one of which ([FromRoute] GetOrder query binding a whole query object from route values) works by MVC's binder rather than Convey's. The samples are teaching aids first, and this is the clearest example of a place where copying the sample verbatim would copy the pedagogy, not the practice.
The .rest file is the missing quickstart
The last artifact is Conveyor.rest — a REST Client script defining the three service URLs (5001–5003), a create_order POST with a fixed customer GUID, and then this:
@orderLocation = {{create_order.response.headers.location}}
GET {{orders}}/{{orderLocation}}
It captures the Location header that the dispatcher endpoint's afterDispatch hook set (ctx.Response.Created($"orders/{cmd.OrderId}") — part 6) and follows it. Eight lines that verify the entire command-side contract: POST accepted, 201 emitted, resource retrievable. Nothing in the repo says “start here”, but this file is the quickstart, and running it while three consoles scroll Figgle banners and RabbitMQ logs is the fastest way I know to make the whole first series concrete.
Where the missing chapters go
That is the tour the parent series never gave. The rest of this series digs where this post surfaced the gaps: the appsettings.json those services share, read top to bottom; the OpenStack object-storage package I dismissed in a paragraph; the EF-versus-Mongo outbox decision; the ten-container compose file it takes to run these three toys; how you test any of this; how you would onboard a team; and the wider DevMentors ecosystem these samples are a miniature of. Same time next week — bring the source.