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.tomldeclares members and centralises versions via[workspace.dependencies];cargo build -p acme-apibuilds 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, socargo treereads 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 onplatform-kernel) and<ctx>-domain-core(entities, services implementing driving ports — depends only on its own contract crate). The crate graph makesinfra → domain-coreandbilling-* → 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_implinside a single<ctx>-domaincrate 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-servicemust 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-contractand 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-kernelis 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 inplatform-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 undermodules/ever produces a binary. - The
*-apicontract 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
infracrate 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-serviceenumerates 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>inapplication/apifails withE0117 — 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-servicecompiled while holding a value ofordering_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-dependencywithpublic = falseand#![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 readablemain.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:dynis 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-contractare object-safe (no generic methods),Send + Sync, boxed-async accepted — so every port can be wired asdyn, and a generics opt-in remains possible per call site. - Boxed-async is mandatory, not stylistic (verified 2026-08-14). Native
async fnin traits is still not dyn-compatible on rustc 1.94 (E0038 … because method call is async). That makesplatform-kernelload-bearing in a way the JVM's kernel is not: it owns thepub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>alias that every port method returns. Without it theArc<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 viasupports()is ruled out — details below. - Async is decided at the edges: the runtime (tokio) is a dependency of binary
crates and infra crates;
domain-contracttraits stay runtime-agnostic (port methods returnplatform_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-serviceexports 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'sdomain-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, whichcargo treenames for a reviewer — plus the two-line switch to-Z public-dependencythe 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>-gatewayimplements a driven-port trait the consumer declared in its owndomain-contract, delegating toArc<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 onecargo-denyban list per crate cluster.- The assembly binds it:
main.rsconstructs the peer's service adapter, wraps it in the gateway, and hands the result to the consumer's core asArc<dyn OrderLookup>— the samedynwiring rule as every other port. - Extraction swaps one crate.
billing-infra-ordering-gatewayis replaced bybilling-infra-ordering-http, built on the publishedordering-user-side-api-contractcrate; 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
enumin<ctx>-domain-contract, and dispatch is one exhaustivematch. 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 asupports()scan): carrying per-command result types throughdynforcesAnydowncasts 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-apiandcargo-semver-checksboth consume rustdoc JSON (nightly-only),cargo-denybans dependency edges and cannot see type flow at all, and a clippy lint would have to ship as adylintcrate pinned to its own nightly. It needs nightly too, and costs more than the real nightly option for the same result. Out. -Z public-dependencyis exact but disproportionate. Withpublic = falseand#![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
pubitems 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 treenames 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
- Hexagonal architecture — the shared house principles this note realizes; the hub for all four reference implementations.
- Modulith & microservices — what the gateway crate is for, and the extraction the crate graph makes mechanical.
- The four hexagonal references compared — Rust's walls graded against the other three, and what its ceremony actually costs.
- Deep dive Rust — traits, ownership and async, the machinery this realization leans on.
- Cargo in depth — workspaces, features, profiles & publishing — the workspace model and dependency inheritance this layout is built on, and why features are for variants, not adapter selection.
- Async runtimes — the
runtime-at-the-edges rule: why tokio belongs to binary and infra crates, never to
domain-contract. - Backend protocols in Rust — the transport crates (axum, tonic, rdkafka) the application-layer adapters are built with.
- API documentation in Rust — the contract
formats the
*-apicrates carry and their derive-shaped tooling.