Why DDD: transport && wiring

The problemThe use cases are written and the ports have implementations — and still the service cannot be called: JSON arrives from outside, and the use case expects domain entities. Nor is there anyone to assemble it into a running process: `cmd/` is empty.

The domain is described, the use cases are written, the ports have implementations. Two things are missing: the incoming call, and whoever assembles all of it into a running process.

One handler — POST /invoices — and the assembly of the whole service.

  1. Step 1 of 8

    Nobody to call it

    On the right is the use case for issuing an invoice, as the previous chapter left it. It takes a number, a customer, lines and a due date: domain entities and nothing else.

    What arrives from outside is not that. Outside there is JSON: the number as a string, the amounts as numbers, the date in ISO. Something has to translate between them, and the question is not which framework to pick but who translates and where that translation lives.

    The second thing is visible in the tree: an empty cmd/api has appeared. The use case needs three port implementations, those need a database connection, and the connection needs a string from the environment. Nobody does any of this yet, and the service cannot be started.

    Step 2 of 8

    The request body

    A DTO is a structure of plain fields: what the data crosses the boundary in. The json tags are part of the handler's contract and they live here, not in the domain — the domain knows nothing about JSON.

    The types are primitive on purpose. invoice.Number is not accepted from outside: anything at all may arrive, and turning a string into a domain type is the transport's job.

    Validate checks the shape, not the domain: the number is not empty, the body parses. "An invoice with no lines" is a domain rule and Issue will check it; duplicating it here would mean two places, one of which goes stale eventually.

    The request body lives in a package of its own — transport/http/dto. The handler and its contract then have separate files and separate reasons to change: the contract is edited when something is agreed with the client, the handler when the order of the call changes. And the names read as they are: dto.IssueRequest.

    The translation lives there too — ToDomain(). It returns not the aggregate but what the aggregate is created from: the number, the customer, the lines and the due date, already in domain types. The transport may not assemble the invoice — Issue in the domain does that, and the checks stay there. The handler, in exchange, grows no loops: one line instead of six.

    The DTO travels no further than the transport: what goes inward are the use case's arguments.

    Step 3 of 8

    The handler: translate and call

    One handler, four actions: parse the body, check the shape, ask it for the domain entities, call the use case.

    Not one decision about the domain is made here. The total is not computed, the status is not set, the order of the operation is not held — all of that stayed in the aggregate and in application. The handler translates, and that is all.

    Look at what it owns: *application.UseCase and nothing more. No database, no transaction, no bus. That is why the handler has one reason to change — the contract of the request: the database, the transaction and the bus change without touching it.

    The response is 201 with an empty body: the client sent the number itself, so there is nothing to hand back.

    Step 4 of 8

    Domain errors into status codes

    The most common transport mistake is to answer everything the use case returned with a 500. The client then cannot tell its own error from ours and starts retrying what is pointless to retry.

    Translating errors is translation too, and it lives on the same boundary: a broken domain rule is 422, a conflicting state is 409, everything else is 500 and a line in the log.

    Neither the database error text nor the stack trace goes out: the cause stays in the log, the client gets a code and a short phrase.

    An unknown error gets 500 on purpose. The list of codes is part of the handler's contract, and it should be extended by a person, not by err.Error().

    Step 5 of 8

    The slice knows its route

    The address of the handler is declared by the module, not by a router somewhere at the root. That keeps the vertical slice whole: the use case, the DTO, the handler and the address live together because they change together.

    The multiplexer is the standard one: since Go 1.22 it understands both the method and path parameters. A framework has nothing to add here; when one is needed — logging, authorisation, limits — its place is in the assembly, not in the module.

    The assembly is left with a single Register call: it knows neither paths nor methods.

    Step 6 of 8

    Nobody to assemble it

    cmd/api finally has code, and the assembly is written by hand. It reads top to bottom: the connection, three adapters, the use case, the handler, the multiplexer, the server.

    The good news: this is all there is to DI. Dependencies arrive as constructor arguments, the implementations are chosen in one place, and no container is needed for that.

    The bad news starts with the second application. An outbox sender and a payments consumer will stand next to this one: they need the same database and the same adapters but a different entry point. These seven lines get copied, the copies drift apart, and in half a year the sender is running on its own connection pool.

    And the order: to learn that issuing.New wants a unit of work you have to open its signature. Forget it, and the compiler tells you about a type mismatch on line six, not about what is missing.

    Step 7 of 8

    wire: the same assembly, generated

    wire is not a container and not reflection. It is a generator: it reads a list of constructors and writes exactly the code we wrote by hand a step earlier.

    wire.NewSet is the set of adapters shared by every application of the service. wire.Bind says which implementation stands behind a port: the constructor returns *postgres.Invoices, and the use case wants invoice.Repository.

    initHandler never runs: the file is only built under the wireinject tag and its body is a stub. wire reads wire.Build and creates wire_gen.go next to it — that file is committed, and it is what runs in production.

    What this buys: a forgotten dependency is reported by the generator, with the name of the type, before anything starts; there is one set of adapters for all entry points; and the generated code reads like your own.

    And no zealotry about it. For one application with five constructors wire is not worth it — doing it by hand is shorter and clearer. It starts paying off when a second and a third application are assembled from the same set.

  2. Step 8 of 8

    What we ended up with

    Click a file to read it.

    • applications/issuing/transport/http/dto/issue.go — the request body and its shape check.
    • applications/issuing/transport/http/handler.go — translation and the call.
    • applications/issuing/transport/http/errors.go — domain errors into status codes.
    • applications/issuing/transport/http/routes.go — the address of the handler.
    • cmd/api/main.go — the entry point.
    • cmd/api/wire.go — the list of providers for the generator.

    The chain is closed: a request arrives at POST /invoices, becomes domain entities, and the use case builds the aggregate, saves it and writes the fact — all as one operation.

    The domain did not change by a single line in this chapter. A second handler is one more file next to this one; a second application is one more function in wire.go.

Use ← and → to move between steps.