rgoussu@goussu: ~/library/rust
~/library/rust cat hexagonal-reference-implementation.md

Hexagonal architecture reference implementation — Rust

# The house hexagonal reference realized for Rust — a Cargo workspace of per-context crate clusters, crates as compile-enforced walls, traits as ports, one binary crate per assembly, dyn-by-default wiring with generics as the measured escape hatch, and the orphan rule quietly forcing the gateway to be a real artifact.

Conceptsaved 2026-08-09updated 2026-08-14 #hexagonal-architecture#architecture#rust#cargo#workspace#modulith#reference-implementation

Overview

The house reference implementation of hexagonal architecture for Rust, standing on its own terms. The shared principles live in that note — trisection, one deployment unit per delivery typology, a contract face and an implementation face for the domain, containerisation beside its unit, per-crate README.md/AGENTS.md. Rust's realization is a Cargo workspace whose crate graph is the architecture: crates are real compilation units with an enforced dependency graph, so every boundary rule is held by the compiler, no linter needed. On the modulith layout (adopted 2026-08-14) that becomes the strongest of the four references: a bounded context is a cluster of crates, and "billing must not touch ordering's domain" is not a convention or a linter rule but a missing edge in Cargo.toml. The decision the language itself forces is how ports are wired: trait objects vs generics — settled below (2026-08-09 discussion). Compile-verified against a hand-built two-context workspace on rustc 1.94.1 (2026-08-14). Two results reshaped the note: the orphan rule turns out to enforce more than the crate graph does, and the crate graph turns out to enforce less than claimed — it stops you naming a crate, not domain types flowing across the seam.

Key points

  • The workspace is the backbone. The root Cargo.toml declares members and centralises versions via [workspace.dependencies]; cargo build -p acme-api builds exactly one deployment unit.
  • A bounded context is a crate cluster under modules/<context>/, named by convention <context>-domain-contract, <context>-domain-core, <context>-user-side-api, <context>-user-side-service, <context>-infra-*. Crate names carry the context, so cargo tree reads as a context map and a wrong edge is a compile error rather than a review comment.
  • Ports are traits; a context's domain is two crates: <ctx>-domain-contract (traits for driving and driven ports, command/result types, domain events — depends only on platform-kernel) and <ctx>-domain-core (entities, services implementing driving ports — depends only on its own contract crate). The crate graph makes infra → domain-core and billing-* → ordering-domain-* compile errors alike.
  • …but the two-crate split is bought for build economics, not for the wall (corrected 2026-08-14). A private mod core_impl inside a single <ctx>-domain crate hides entities from dependents exactly as well (E0603: module core_impl is private) — Rust does have sub-crate visibility, which the note previously wrote off. What the split genuinely buys is a smaller incremental rebuild: touching the core rebuilt 2 crates when split and 3 when merged, because in the merged crate every dependent of the contract face rebuilds too. Keep two crates on any context under active development; a small, stable context may legitimately collapse them to one.
  • <ctx>-user-side-service must be its own crate — this one is load-bearing. Whatever crate owns the peer-facing API also hands its consumers everything else that crate exports. Fold the service into <ctx>-domain-contract and every gateway in the workspace gains a legal edge to the peer's domain. Four crates is therefore the floor for a context that composes with peers: contract, core, service, infra.
  • platform-kernel is the shared vocabulary crate — command/query marker traits, the dispatch helpers, nothing business-shaped — and depends on nothing. Ubiquitous ports (clock, id generation) sit beside it in platform-commons, not inside any context.
  • One binary crate per delivery typology under application/ (acme-api, acme-consumer, acme-cron), each a deployment unit with its Dockerfile beside it. It is the only crate depending on any *-domain-core, and its [dependencies] list is the ops runbook. A context's own user-side crates are libraries: nothing under modules/ ever produces a binary.
  • The *-api contract crate is earned, not imposed: a proto crate for gRPC, generated OpenAPI types for REST, message schemas for consumers; cron-style adapters are a single crate.
  • Infrastructure granularity is decided ad-hoc per context — one infra crate per context or one per (port × technology) (ordering-infra-postgres, ordering-infra-kafka), the forcing question being which assemblies need which driven adapters. Cargo features gate variants of one adapter, never adapter selection — the assembly's [dependencies] does that.
  • Contexts meet at <consumer>-infra-<peer>-gateway, the only crate whose [dependencies] names two contexts: it implements a port the consumer declared and delegates to <peer>-user-side-service. cargo tree --invert ordering-user-side-service enumerates every inter-context edge in the workspace, exactly.
  • The orphan rule is what makes the gateway a real artifact, not a convention (2026-08-14). The assembly cannot shortcut it: impl OrderLookup for Arc<dyn OrderingService> in application/api fails with E0117 — only traits defined in the current crate can be implemented for types defined outside of the crate. Because neither the port trait nor the peer's service type is local to the assembly, the impl has nowhere to live except a crate that owns a newtype for it. Rust is the only reference where "the gateway exists as its own unit" is a compile error rather than a review comment.
  • The crate graph stops you naming a crate; it does not stop types flowing (open, 2026-08-14). A gateway depending only on ordering-user-side-service compiled while holding a value of ordering_domain_contract::Summary — returned through the service crate's public signature — and reading its fields, with no dependency edge, no error and no warning. Inference supplies what the name cannot. So "the service crate's DTOs are its own" is a discipline on stable Rust, not a guarantee. cargo -Z public-dependency with public = false and #![deny(exported_private_dependencies)] catches it precisely — it flags leaked return types and constructor parameters — but it is nightly-only as of 1.94. See the open question below.
  • The wiring rule — Arc<dyn Port> at the seams by default, generics as the measured escape hatch. Trait objects give late binding and a readable main.rs; the cost is that async trait methods aren't natively object-safe, so dyn ports mean boxed futures — one heap allocation per port call. Generics (PlaceOrderService<R: OrderRepository>) are zero-cost but monomorphize the assembly into type Tetris that infects every signature upward. The ruling: dyn is the default; generics are legitimate on hot paths, for measured performance reasons — a profiled boundary, not a style preference — and the crate that opts in keeps the generic parameters from leaking past its own public surface where possible.
  • Port design follows from the ruling: ports in domain-contract are object-safe (no generic methods), Send + Sync, boxed-async accepted — so every port can be wired as dyn, and a generics opt-in remains possible per call site.
  • Boxed-async is mandatory, not stylistic (verified 2026-08-14). Native async fn in traits is still not dyn-compatible on rustc 1.94 (E0038 … because method call is async). That makes platform-kernel load-bearing in a way the JVM's kernel is not: it owns the pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>> alias that every port method returns. Without it the Arc<dyn Port> wiring rule — and with it the identical-signature property below — simply does not compile.
  • Command dispatch: the compiler is the registry (settled 2026-08-09) — per-use-case driving-port traits by default; where a unified dispatch seam is justified, commands become an enum dispatched by one exhaustive match. The runtime registry of trait objects probed via supports() is ruled out — details below.
  • Async is decided at the edges: the runtime (tokio) is a dependency of binary crates and infra crates; domain-contract traits stay runtime-agnostic (port methods return platform_kernel::BoxFuture, no tokio types in signatures) so the domain never depends on an executor.

Details

The derived workspace

acme-service/
├── README.md / AGENTS.md
├── Cargo.toml                          # [workspace] members + workspace.dependencies
├── platform/
│   ├── kernel/                         # platform-kernel — no deps
│   └── commons/                        # platform-commons: clock, id generation
├── modules/
│   ├── ordering/
│   │   ├── domain-contract/            # ordering-domain-contract: port traits, commands
│   │   ├── domain-core/                # ordering-domain-core: entities, services
│   │   ├── user-side/
│   │   │   ├── api/                    # ordering-user-side-api: axum router (lib crate)
│   │   │   ├── api-contract/           # generated OpenAPI types — earned, not imposed
│   │   │   └── service/                # ordering-user-side-service: the peer-facing API
│   │   └── infra/
│   │       ├── postgres/               # ordering-infra-postgres (sqlx)
│   │       └── kafka/
│   └── billing/
│       ├── domain-contract/
│       ├── domain-core/
│       ├── user-side/…
│       └── infra/
│           ├── postgres/
│           └── ordering-gateway/       # the one crate naming two contexts
├── application/
│   ├── api/                            # acme-api: bin crate + Dockerfile
│   │   └── Dockerfile
│   ├── consumer/
│   └── cron/
├── compose.yaml
└── deployment/                         # IaC: OpenTofu modules + per-environment stacks

No configurator crate: main.rs per assembly is the assembly point. If several binaries wire identical subsets, extract a small lib crate of assembly constructors; that is a deduplication move, not a layer of the architecture.

The crate graph, rustc-enforced (an arrow reads "depends on"):

flowchart TB
    APP["application/api (bin crate)"]
    subgraph ordering["modules/ordering"]
        OUS["user-side/api (lib)"]
        OSVC["user-side/service"]
        OCORE["domain-core"]
        OAPI["domain-contract"]
        OPG["infra/postgres"]
    end
    subgraph billing["modules/billing"]
        BCORE["domain-core"]
        BAPI["domain-contract"]
        BGW["infra/ordering-gateway"]
    end
    KERNEL["platform/kernel (no deps)"]

    OAPI --> KERNEL
    BAPI --> KERNEL
    OCORE --> OAPI
    OPG --> OAPI
    OUS --> OAPI
    OSVC --> OAPI
    BCORE --> BAPI
    BGW --> BAPI
    BGW -->|"the one inter-context edge"| OSVC
    APP --> OUS
    APP -->|"wires ports as Arc dyn Port in main.rs"| OPG
    APP --> OCORE
    APP --> BCORE
    APP --> BGW

A missing arrow here is a missing line in a Cargo.toml, so "billing does not depend on ordering's domain" is checked by cargo check on every build. No other reference gets the inter-context rule for free.

The layout elements and their enforcement

Element Realization Held by
the dependency rule the workspace crate graph rustc — a wrong edge is a compile error
context isolation one crate cluster per modules/<context>/, no edges between clusters rustc
the inter-context seam <consumer>-infra-<peer>-gateway<peer>-user-side-service rustc, and cargo tree --invert enumerates them
the gateway exists as its own crate the orphan rule — the assembly cannot impl a foreign trait for a foreign type rustc (E0117)
the seam carries only the service crate's own DTOs nothing on stable — a leaked signature hands the peer's domain type to every consumer discipline, stated in the seam crate's own doc (decided 2026-08-14); -Z public-dependency when it stabilizes
domain contract vs implementation <ctx>-domain-contract / <ctx>-domain-core crates, or a private mod in one crate crate dependencies, or module privacy (E0603)
ports object-safe traits, Send + Sync, boxed-async accepted the house port-design rule
adapter contract an api-contract lib crate where the contract is real; absent otherwise convention
infra granularity per-context: single crate or one per (port × technology) the assembly's [dependencies]
ubiquitous ports platform-commons, outside every context convention
assembly point main.rs per application/<typology> binary convention
wiring explicit construction, Arc<dyn Port> by default; generics on measured hot paths house rule
supply-chain hygiene cargo-deny for licences and duplicates CI

Composing two contexts — the service crate and the gateway crate

Rust expresses the seam as two crates and one dependency edge:

  • <peer>-user-side-service exports a trait (OrderingService) plus its own DTO types, and an implementation that maps them onto the peer's commands. It depends only on the peer's domain-contract. Consumers cannot name that crate — but note the correction above: they can still receive and use its types if the service crate lets one into a public signature. Every type in the service crate's public API must be declared by the service crate itself, and that rule is on the author, not the compiler. The guard is the rule stated in this crate's own module doc — one small, rarely-edited crate per context, which cargo tree names for a reviewer — plus the two-line switch to -Z public-dependency the day it stabilizes. See the decision below (2026-08-14) for why a custom lint is not a third option on stable.
  • <consumer>-infra-<peer>-gateway implements a driven-port trait the consumer declared in its own domain-contract, delegating to Arc<dyn OrderingService>. It is the only crate in the workspace whose [dependencies] names two contexts, and the workspace lint that keeps it that way is one cargo-deny ban list per crate cluster.
  • The assembly binds it: main.rs constructs the peer's service adapter, wraps it in the gateway, and hands the result to the consumer's core as Arc<dyn OrderLookup> — the same dyn wiring rule as every other port.
  • Extraction swaps one crate. billing-infra-ordering-gateway is replaced by billing-infra-ordering-http, built on the published ordering-user-side-api-contract crate; the workspace can then be split in two, each keeping only the clusters it needs. See modulith & microservices.

Async is worth calling out here: because the peer-facing trait is object-safe and boxed-async like every other port, the in-process and HTTP gateways have identical signatures. Rust makes the "an in-process call and a remote call look the same at the port" claim literally true at the type level rather than aspirationally.

Command dispatch — the compiler is the registry (settled 2026-08-09)

  • Default: per-use-case driving-port traits (PlaceOrder) implemented by services in <ctx>-domain-core — the shape the wiring rule already assumes.
  • When a unified seam is justified (audit trail, command log/replay, uniform middleware): the mediator pattern applies in its Rust-native form — the sealed command hierarchy is literally an enum in <ctx>-domain-contract, and dispatch is one exhaustive match. A new command without an arm is a compile error — strictly stronger than the JVM's runtime "no handler supports" exception; the compiler walks you through every dispatch site.
  • The enum is per context, never per workspace. One command enum for the whole modulith would put every context's vocabulary in one crate every adapter depends on — the shared-domain failure the layout exists to prevent. Per-context enums also mean a carved-out context leaves with a closed, complete command set.
  • Ruled out — the registry-probing form (Vec<Box<dyn Handler>> with a supports() scan): carrying per-command result types through dyn forces Any downcasts and erases the command→result link — dynamic dispatch where the language wants static.
  • Cross-cutting: wrap the dispatch function (or decorate individual port impls) at the assembly point, same as the other references.

The layout dial — keep basic the default, longer than the JVM does (settled 2026-08-14)

Rust pays the most ceremony of the four references: four crates per context minimum, each a Cargo.toml, each a line in the workspace member list, each with hand-written relative path = dependencies. That is a real cost for a service that genuinely holds one bounded context, and unlike Go there is no version of the modulith that is nearly free. So Rust should offer both layouts and default to basic — and should stay on basic past the point where a JVM project would switch, because the JVM's per-context cost is build files a plugin can generate while Rust's is also a crate-name namespace every use statement spells out. The counter-pressure is that Rust's walls are the strongest of the four once you have paid, so a project that knows it has two contexts should start at modulith rather than migrate. See the four references compared.

Settled, and the one thing still open on stable

Settled — the compile-time cost of many small crates (2026-08-09): accepted by design. The dependency-rule enforcement the crate separation buys is the point of the layout; the build cost is its price, not a factor to optimize the architecture around. Measured incremental behaviour supports this: touching a domain-core rebuilt only that crate and the assembly (2 crates), where the same edit in a merged domain crate rebuilt 3. Cargo compiles independent clusters in parallel and rebuilds only what changed.

Decided, and still partly open — enforcing the seam's type discipline on stable (2026-08-14). The one wall this layout assumes and does not get: nothing on stable Rust stops <ctx>-user-side-service from exposing a <ctx>-domain-contract type in its public API, and once it does, every consumer can use that type without declaring any dependency on it. The three candidates are not equally weighted, and working through them settles the stance even though it does not close the gap:

  • A custom lint is not actually available on stable. Checking "every type in the seam crate's public API is declared by that crate" needs the public API surface, and stable offers no supported way to get it: cargo public-api and cargo-semver-checks both consume rustdoc JSON (nightly-only), cargo-deny bans dependency edges and cannot see type flow at all, and a clippy lint would have to ship as a dylint crate pinned to its own nightly. It needs nightly too, and costs more than the real nightly option for the same result. Out.
  • -Z public-dependency is exact but disproportionate. With public = false and #![deny(exported_private_dependencies)] it flags leaked return types and constructor parameters — verified. But turning it on pins the whole project to nightly, including every single-context project that has no seam, to enforce one rule on one crate. That trades "always latest stable" for a wall most projects never test.
  • So: discipline, reinforced structurally. The rule governs the pub items of exactly one small, rarely-edited crate per context whose entire purpose is to be that seam. State it in that crate's own module doc, at the point where it would be violated, rather than in a checklist elsewhere; cargo tree names the one crate a reviewer has to read.

The upgrade is pre-written, so it is mechanical the day public-dependency stabilizes: add public = false to the seam crate's <ctx>-domain-contract dependency and #![deny(exported_private_dependencies)] to its crate root. Two lines, no restructuring.

What stays open is the enforcement, not the stance — and the difference matters when comparing references. Rust's peer seam is genuinely weaker than the JVM's, where build scope holds it, and than Go's, where unnameability does. Nothing here is blocking: the failure mode is coupling that compiles, not a broken build.

Examples

Workspace root:

[workspace]
resolver = "2"
members = [
  "platform/kernel", "platform/commons",
  "modules/ordering/domain-contract", "modules/ordering/domain-core",
  "modules/ordering/infra/postgres",
  "modules/ordering/user-side/api", "modules/ordering/user-side/service",
  "modules/billing/domain-contract", "modules/billing/domain-core",
  "modules/billing/infra/ordering-gateway",
  "application/api",
]

[workspace.dependencies]          # versions declared once, like a parent POM
tokio = { version = "1", features = ["rt-multi-thread"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio"] }

A port and the peer seam:

Note the shape every port takes: BoxFuture, never async fn. On rustc 1.94 an async fn in a trait makes that trait dyn-incompatible, so Arc<dyn Port> would not compile.

// platform-kernel — the alias the whole layout depends on
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;

// ordering-domain-contract
pub trait OrderRepository: Send + Sync {
    fn save(&self, order: NewOrder) -> BoxFuture<'_, Result<OrderId, RepositoryError>>;
}

// ordering-user-side-service — the peer-facing driving adapter.
// OrderSummary and ServiceError are declared HERE: every type in this
// crate's public API must be its own, or the peer's domain leaks.
pub trait OrderingService: Send + Sync {
    fn orders_for(&self, customer: &str) -> BoxFuture<'_, Result<Vec<OrderSummary>, ServiceError>>;
}

// billing-domain-contract — billing's own vocabulary, no mention of ordering
pub trait OrderLookup: Send + Sync {
    fn activity_for(&self, customer: &CustomerRef) -> BoxFuture<'_, Result<Activity, LookupError>>;
}

// billing-infra-ordering-gateway — the one crate naming two contexts.
// The orphan rule forces this newtype: the assembly cannot write this
// impl itself, because neither the trait nor Arc<dyn OrderingService>
// is local to it (E0117).
pub struct OrderingGateway(Arc<dyn OrderingService>);

impl OrderLookup for OrderingGateway {
    fn activity_for(&self, customer: &CustomerRef) -> BoxFuture<'_, Result<Activity, LookupError>> {
        let peer = self.0.clone();
        let customer = customer.as_str().to_owned();
        Box::pin(async move {
            let orders = peer.orders_for(&customer).await.map_err(|_| LookupError)?;
            Ok(Activity::of(orders.iter().map(|o| o.cents).sum()))
        })
    }
}

application/api/src/main.rs — the configurator:

let repo: Arc<dyn OrderRepository> = Arc::new(PgOrderRepository::new(pool.clone()));
let ordering = Arc::new(OrderingServiceAdapter::new(repo.clone()));

let orders: Arc<dyn OrderLookup> = Arc::new(OrderingGateway::new(ordering.clone()));
let invoice = InvoiceService::new(Arc::new(PgInvoiceRepository::new(pool)), orders);

let app = ordering_user_side_api::router(ordering.clone())
    .merge(billing_user_side_api::router(Arc::new(invoice)));

Related