A Service in Thirty Lines
The fair part of the ledger - how UseDispatcherEndpoints, self-identifying commands, afterDispatch hooks and snake_case conventions let every Pacco service put its whole HTTP surface in one readable file.
Part 2 spent its whole length on the copy-paste tail, and if I stopped there this series would be a hit piece. It isn't one, because the other half of the bargain is real: the thirty lines Convey promises are genuinely thirty lines, they appear in all ten services with almost no variation, and the conventions behind them are better thought out than most hand-rolled API layers I have inherited. This part takes the framework's win seriously, in the estate's own code — because you cannot price a tax fairly without pricing what it buys.
The whole surface, one file
Recall the specimen from part 1 — Pacco.Services.Vehicles, the class body of its Program.cs:
public static async Task Main(string[] args)
=> await WebHost.CreateDefaultBuilder(args)
.ConfigureServices(services => services
.AddConvey()
.AddWebApi()
.AddApplication()
.AddInfrastructure()
.Build())
.Configure(app => app
.UseInfrastructure()
.UseDispatcherEndpoints(endpoints => endpoints
.Get("", ctx => ctx.Response.WriteAsync(ctx.RequestServices.GetService<AppOptions>().Name))
.Get<GetVehicle, VehicleDto>("vehicles/{vehicleId}")
.Get<SearchVehicles, PagedResult<VehicleDto>>("vehicles")
.Post<AddVehicle>("vehicles",
afterDispatch: (cmd, ctx) => ctx.Response.Created($"vehicles/{cmd.VehicleId}"))
.Put<UpdateVehicle>("vehicles/{vehicleId}")
.Delete<DeleteVehicle>("vehicles/{vehicleId}")
))
.UseLogging()
.UseVault()
.Build()
.RunAsync();
There is no VehiclesController, no [HttpPost], no IActionResult. Get<GetVehicle, VehicleDto>("vehicles/{vehicleId}") says: when this route is hit, bind a GetVehicle query from the route values and query string, dispatch it through the query dispatcher, serialise the VehicleDto that comes back. Post<AddVehicle>("vehicles") says: bind an AddVehicle command from the JSON body and dispatch it through the command dispatcher. The route table is the API documentation, and it reads in one screen. This is 2020, remember — two years before Minimal APIs shipped, on netcoreapp3.1 and the legacy WebHost. The estate is running a route-to-handler style the platform itself would only later adopt.
Two details in this file repay a slower look.
The afterDispatch hook is how status codes stay out of handlers. A dispatched command returns nothing — Convey's ICommandHandler is fire-and-forget by design — so how does POST return a 201 Created with a Location header? A lambda that runs after dispatch, given the command and the HttpContext. The handler stays pure application logic; the HTTP opinion lives at the route declaration, next to the route. Compare that with the average controller action, where domain calls, status-code selection, and header assembly share one method body.
The empty route is the health surface. Get("", …) writes the service name from AppOptions — every service in the estate answers its root URL with its own name, which sounds trivial until you are staring at ten containers behind a gateway wondering which one you are actually talking to.
The command that knows its own name
The Location header above hides a neat trick. The lambda reads cmd.VehicleId — but this is a create operation; where did the id come from? From the command itself:
// Pacco.Services.Vehicles.Application/Commands/AddVehicle.cs
[Contract]
public class AddVehicle : ICommand
{
public Guid VehicleId { get; }
// ...
public AddVehicle(Guid vehicleId, string brand, string model, string description,
double payloadCapacity, double loadingCapacity, decimal pricePerService, Variants variants)
{
VehicleId = vehicleId == Guid.Empty ? Guid.NewGuid() : vehicleId;
// ...
}
}
If the caller does not supply an id, the command assigns itself one in its constructor. That single line buys three things. The Created location is known before dispatch, so the hook needs no return value from the handler. The same command can be dispatched over HTTP or consumed from RabbitMQ with identical semantics — no channel-specific id ceremony. And a client that generates its own id gets natural idempotency material for retries. The cost is a mild oddity — constructors that mint identity are a surprise the first time you meet one — but it is a convention, applied everywhere, and conventions you can predict stop being surprises by the second service.
Queries take the opposite stance and the asymmetry is deliberate: commands are constructor-immutable, queries are settable property-bags — SearchVehicles : PagedQueryBase is three auto-properties plus inherited paging — because queries bind from query strings and route values, where a forgiving shape is the right shape.
Conventions all the way to the wire
The compression does not stop at routing. Three attribute-and-config conventions carry it onto the bus.
[Contract], visible on AddVehicle above, marks a message as public surface; UsePublicContracts<ContractAttribute>() in the pipeline then exposes a machine-readable endpoint describing every contract the service owns. Seven of the ten services wire it — a home-grown, runtime contract registry, years before that idea was fashionable, and it becomes load-bearing when we reach the contract-testing parts of this series.
[Message("customers")] pins a consumed message to the exchange it originates from. Every service re-declares the foreign events it cares about as local classes and stamps them with the source exchange — Orders' copy of CustomerCreated carries [Message("customers")], and the binding follows the attribute — which makes each of those little copied classes a contract, a theme this series returns to in its contract-governance parts.
Errors follow the same philosophy: naming is the API. Every service maps exceptions to a {code, reason} envelope, and unless an exception carries an explicit code, the code is derived from the type name — ExceptionToResponseMapper runs exception.GetType().Name.Underscore().Replace("_exception", "") and memoises the result in a ConcurrentDictionary. Throw DeliveryNotFoundException and the client receives {"code": "delivery_not_found", "reason": …}. Nobody maintains an error-code table; the class names are the error-code table, which means renaming an exception class is a breaking API change — a sharp consequence, but at least a consistent one, and the same derivation feeds the *Rejected events on the bus side, so both channels speak identical codes for free.
And the wire names are derived, not written. From every service's appsettings.json:
"rabbitMq": {
"conventionsCasing": "snakeCase",
"queue": {
"template": "vehicles-service/{{exchange}}.{{message}}"
}
}
AddVehicle becomes routing key add_vehicle; the subscription queue materialises as vehicles-service/vehicles.add_vehicle. Class name in, topology out. Nobody in ten services ever writes a queue name by hand, which means nobody ever typos one — the whole broker topology is a deterministic function of type names and two config lines.
The last piece is the one that still reads as radical: the same command types are subscribed from the bus with SubscribeCommand<AddVehicle>() in the very services that expose them over HTTP. Post AddVehicle to the gateway or publish add_vehicle to the vehicles exchange — the identical handler runs either way, which is exactly what the self-assigned ids and the fire-and-forget handler contract were quietly preparing for. Every write is dual-channel, queries are HTTP-only, and the CQRS split is visible at the transport layer — a thread the domain series pulls properly in CQRS at the transport layer.
Thirty lines, with footnotes
Now the honesty. “Thirty lines” survives contact with the estate, but with a spread. OrderMaker's Program.cs is exactly 30 lines; the aggregate services run 45–50 including their using directives, which are roughly half the file; and Identity is the outlier at 90 — because Identity opts down a level, swapping UseDispatcherEndpoints for plain UseEndpoints and hand-writing a lambda per route: authenticate the JWT, call IIdentityService, pick 401 or 404 yourself. Sign-in cannot be a fire-and-forget command — it must return a token — so the dispatcher DSL cannot express it, and the file grows to look like what every other service avoided. The exception proves the design: the dispatcher endpoints compress exactly the routes that fit the command/query mould, and the moment a route needs a response the command pattern won't give, you are back to writing HTTP by hand.
The second footnote is the one this series exists for. The thirty lines end at .AddApplication().AddInfrastructure() — two innocent calls that expand into the seventeen-step chains and the seven-repo glue family of part 2. The surface is compressed; the basement is copied. Both statements are true, and the framework deserves credit for the first exactly as much as scrutiny for the second.
Next, we follow one of those basement calls — .AddMongo(), the one Vehicles makes twice — down to the database, and find one index across ten services: honest document modelling on top of a discipline the estate never adopted.