Skip to content

    Concepts13

    Every module is a hexagon

    Why each aggregate and read model in Bounda declares its own ports, keeps their implementations beside it and has one chosen by name.

    Alistair Cockburn’s ports and adapters, the hexagonal architecture, wants an application that users, programs and tests can drive alike, and that can be built and tested apart from the devices and databases it will finally talk to. Each conversation with the outside goes through a port, an interface the application defines for one purpose, and each technology plugs into it through something that translates. A real mail service, a console and an in-memory double are three ways to fill the same port.

    The usual drawing puts one hexagon around the whole application. Bounda draws it smaller: every aggregate and every read model is its own hexagon, with its ports at its root, their implementations in its own infrastructure/ and the choice between them made under its own name. This page says why, and what it costs. How to write one is in Project layout.

    The storefront’s order aggregate tells customers their order was placed. The contract is a file at the aggregate’s root that exports an interface named after it:

    app/domain/order/notifier.tsTypeScript
    export interface NotifierArgs {
    readonly orderId: string;
    readonly customerId: string;
    readonly total: number;
    readonly idempotencyKey: string;
    }
    export interface Notifier {
    (args: NotifierArgs): Promise<void>;
    }

    It is a port because infrastructure/notifier/ exists next to it, holding console.ts and memory.ts; without that directory it would be one more module of the aggregate. Every handler of order (its commands, its policies, every handler of its processes) receives notifier beside its other arguments, and a handler of payment never does. The configuration picks one implementation by aggregate and port, and a test hands in its own under the same key:

    bounda.config.tsTypeScript
    ports: { order: { notifier: process.env.NOTIFIER === "memory" ? "memory" : "console" } },
    // tests/storefront.test.ts
    ports: { order: { notifier: async (confirmation) => void sent.push(confirmation) } },

    Both are typed from what the generator found and checked again at boot (the rules), and a test gets only the ports it passes.

    An implementation has no +types of its own. It imports its port and fulfils it, and satisfies reports a mismatch in place; the generated registry checks every implementation against the interface anyway, so one that does not fit fails tsc either way.

    app/domain/order/infrastructure/notifier/console.tsTypeScript
    import type { Notifier } from "../../notifier.ts";
    export default (async ({ orderId, customerId, total, idempotencyKey }) => {
    console.log(
    `[notifier] confirmation for order ${orderId} sent to ${customerId} (${total}), key ${idempotencyKey}`,
    );
    }) satisfies Notifier;

    That file imports nothing from Bounda. An implementation with state, a client, a pool or a secret, exports create instead, typed as CreateImplementation<Port>, the one type it takes from Bounda, and each part of it has a reason: env, because the host’s environment is process.env in Node, the Durable Object’s env on Cloudflare and what a test passes; tenant, the name of the store, so an implementation can serve each tenant with its own account; the app’s logger; the app’s clock, so the time it reads is the time a test controls; and a result that may be a promise, for a client that must connect first. It runs once per app, so on Cloudflare once per Durable Object: each tenant’s store builds its own client, and app.stop() closes it.

    Why the contract is written, never inferred

    Section titled “Why the contract is written, never inferred”

    The generator refuses a port whose module does not export the interface, and a port directory with no implementation. It could have taken the type of the one implementation there is. It does not, for two reasons. The contract comes first: it is what the aggregate needs from the world, written before anyone picks a provider, and often while only a fake exists. And an inferred contract would move with its implementation: a method added to the HTTP client for its own convenience would appear in every handler, and one removed would break handlers that never mentioned the implementation. Written down, the port changes only when someone changes what the domain asks.

    Read models have ports too, for their queries

    Section titled “Read models have ports too, for their queries”

    A read model declares and implements ports the same way, and only the handler of its queries receives them: a query that completes its rows with an exchange rate or a profile from outside. Projections and repository get none. A projection commits exactly once per batch and replays its whole history on a rebuild, so an outside call there would be repeated and could answer differently the second time; the reasons are in Ports of a read model.

    Where it comes from, and the closest mechanics

    Section titled “Where it comes from, and the closest mechanics”
    • Cockburn’s Hexagonal Architecture (2005) names ports by purpose and lets one port have several adapters, a real database, a flat file or an in-memory mock among them. Bounda keeps the idea and moves the boundary inward.
    • Folders by role inside a feature. Tom Hombergs’ BuckPal layout (Educative, repository) puts outgoing ports in account/application/port/out and their adapters in account/adapter/out/persistence. Oliver Zihler’s folder structures vary the names but keep the rule: the top folder is a business slice, technical splits go inside it. Bounda’s slice is the aggregate, and its one technical split is infrastructure/.
    • DDD’s infrastructure layer, on which the domain never depends (Microsoft’s DDD guide). Here that layer is one directory per module, with the dependency pointing the same way.
    • Elixir’s behaviour and adapter. The closest mechanics. Swoosh defines the Swoosh.Adapter behaviour, and each mailer names its adapter in config, a different one per environment and Swoosh.Adapters.Test in tests. Ecto names a repo’s adapter in the module and keeps its settings in config. The difference is who writes the contract: there the library owns the behaviour; here each aggregate writes its own.
    • Laravel’s manager offers one API over drivers chosen by a config value, as Storage, Cache and Queue do: one per capability, for the whole app.
    • Effect’s services and layers. A tag names a service, a layer builds it, and the program is provided its layers at the top: the typed form of the app-wide ports discussed below.
    • Ports of the app, injected by the host. One bag of services handed to boot(), as a container of providers or an Effect layer at the top would be. It is less to learn, and it loses the locality: what an aggregate depends on is no longer in its folder, any handler could reach any service, and two aggregates that use one provider share one contract, so a change one needs reaches the other. In Bounda a provider two aggregates use is two ports, each with the operations its aggregate needs, and the client they share is an ordinary module outside app/domain and app/read, such as app/lib/stripe.ts, which both implementations import.
    • Implementations chosen in code. An if in a factory, or a container’s registration, can choose anything. A choice in bounda.config.ts can only name an implementation that exists, checked by the compiler and again at boot, and a test cannot reach a provider it did not ask for.

    Cockburn’s own word for what fills a port is adapter, but in Bounda that word names the storage packages, @bounda-dev/sqlite and its siblings, and one word for two things would make every sentence ambiguous. Driver is what Laravel says, but in hexagonal vocabulary the driving side is the one that calls in, a user, a test or a script, the opposite of what these are. Provider is the word of dependency injection, where it means anything the container can inject (NestJS), which says how a thing is delivered, not what it is for. Implementation is the plain word TypeScript already uses for fulfilling an interface (implements), and that is all one is here.

    • Where SDKs are imported. The layout puts every client and SDK under app/**/infrastructure/** or in a shared module those files import, so a lint rule scoped by path can hold the rest of the domain free of them. Bounda does not check imports itself.
    • Repeating a call. A port is a seam, not a guarantee. A command handler may run again on a concurrency retry, and a reaction runs at least once, so a call through a port must be safe to repeat: pass the idempotencyKey the handler receives (calling the outside world).
    • The calling side. These are the ports the domain calls out through. What calls in, a route or a webhook, is the host’s code, which dispatches commands and runs queries on the app.
    • Ports and ports of a read model, for how to declare, implement and choose them.
    • Doubles, for what a test passes instead.
    • How Bounda runs, for the store per tenant that create runs once for.
    • The precedents, linked above: Cockburn, Hombergs and Zihler, Swoosh’s adapter behaviour, Ecto, Laravel’s manager, Effect’s services and layers.