The Deterministic Hexagon
This is the architecture I hold my systems code to. I call it the Deterministic Hexagon: a hexagon of ports and adapters with a deterministic, replayable core in the center. The name is literal.
Palermo, proposing his onion in 2008, said it honestly: “it’s not completely new, but I’m proposing it as a named, architectural pattern.” Same claim here. Hexagonal architecture is twenty years old. The Dependency Rule is older than most of the codebases that violate it. Deterministic cores are how TigerBeetle and FoundationDB earn their reliability claims. What was missing, at least for me, was a name for the marriage. A named style is easier to follow than a folder of good intentions, so I wrote the rules down.
The name
A house style is the set of arguments you agree to stop having. Where a file lives, when a trait exists, who calls new: these questions have fine answers already, and re-deciding them per feature burns judgment that should be spent on the actual problem.
Each parent arrived the way patterns actually arrive. Learning Domain-Driven Design came as a recommendation from my boss and showed up in the projects that followed. Hexagonal came as a colleague mentioning a college project of his on a call; I got curious, studied it at home, and added it to what I already used.
This particular house style inherits from four parents:
| From | It keeps |
|---|---|
| Hexagonal (Cockburn, 2005) | ports and adapters, driving vs driven, the app runs headless |
| Onion (Palermo, 2008) | inner rings, application services, “the database is external” |
| Clean (Martin, 2012) | domain / use cases / adapters, and the Dependency Rule |
| DDD (Evans, 2003) | the domain at the center; entities, value objects, repository as a port |
On top of that inheritance it makes one commitment none of the parents ask for: the core is deterministic. All four parents keep I/O out of the domain so you can test it. This style keeps entropy out of the domain so you can replay it. The difference sounds small. It is the whole point, and it gets its own section below.
The one rule
Source dependencies point inward.
bin -> adapters -> application -> ports -> domain
Read -> as “depends on”. The domain is innermost and depends on nothing at all: no I/O, no
clock, no randomness, no framework. Ports depend on it, because a port’s signatures are written in
domain types. The domain imports nothing from the outer rings. A driven adapter implements a trait the core owns. Martin called this the Dependency Rule, and it is the only law here; everything else in this style is a consequence of it. Any change that makes an arrow point outward is wrong.
The layout
src/
├── domain/ pure. entities, value objects, rules, domain errors. std only.
├── ports/ traits. contracts the core owns. domain types only in signatures.
│ ├── driven/ what the app asks of the world: Store, Clock, Rng, IdGen
│ └── driving/ the use case API, once two entry points need to share it
├── application/ use cases. one per operation. orchestrate domain and ports.
├── adapters/
│ ├── driving/ the world drives the app: cli/, http/, protocol/
│ └── driven/ the app drives the world: store/memory/, store/disk/
└── bin/ the composition root. builds adapters, injects them, runs.
Two naming decisions do more work than they look like they do.
First, one vocabulary: driving and driven, Cockburn’s own terms, used everywhere. Half the confusion around this pattern comes from mixing “input/output”, “primary/secondary”, and “inbound/outbound” in the same codebase and forcing every reader to re-derive the mapping.
Second, the grouping is asymmetric on purpose. Driving adapters group by interface type (cli/, protocol/), because that is how the world arrives. Driven adapters group by resource (store/memory, store/disk), because that is what the port is about, and the second implementation lands next to the first, where the comparison is easiest to read.
The sixth directory
The tree above has five entries and every project grows a sixth: an HTTP client with a retry policy, a queue consumer loop, a database engine carrying its pool settings. Several adapters share it and none of them owns it.
It is not a ring, and infrastructure/ is the wrong name. Onion and Clean spend that word on the outer ring, the one that contains the adapters, so a directory called infrastructure/ sitting beside adapters/ and not containing them reads wrong to anyone who knows the vocabulary. It also implies a dependency direction that is not there: this points nowhere, because it knows nothing about the domain. An adapter depends on it the way it depends on reqwest.
It is a library you have not published yet, and that is the test. Name it for what it is and let the directory say the rest: libs/http/, libs/queue/, libs/db/. libs/infrastructure/http/client.rs names the ring twice and the thing once.
One rule keeps it from rotting: no port and no adapter goes in there. A port is a contract the core owns, and the core lives in the service. Move a store port up and its domain types follow, then the types those reference, and a service that never touches that domain ends up compiling it. The service is one drawer and the library is the other. There is no third.
Wiring, or DI without a framework
Dependency injection has a reputation problem. The phrase evokes XML, containers, and stack traces with forty frames of framework between main and your code. None of that is dependency injection. Dependency injection is a parameter:
pub fn set_value(store: &mut dyn Store, input: SetInput) -> Result<(), StoreError> {
let key = Key::parse(&input.key)?;
store.set(key, input.value)
}
Three rules of wiring.
Use cases receive their ports. A use case never constructs an adapter. If DiskStore::new() shows up inside application code, the boundary is already gone; no folder structure will save it. The function signature is the contract that keeps the core honest.
When a use case needs a second port, the parameter list becomes a struct that holds them, built once at the root. The rule did not change; the parameters moved to the constructor, and handle kept the signature.
pub struct ExpireKeys {
store: Box<dyn Store>,
clock: Box<dyn Clock>,
}
impl ExpireKeys {
pub fn handle(&mut self) -> Result<u32, StoreError> {
let now = self.clock.now();
self.store.evict_older_than(now)
}
}
Only the composition root constructs. Exactly one place knows which concrete adapters exist, and in Rust that place is not a framework. In a program this size it is main itself; when the app grows, it is a bootstrap module that main calls and nothing else imports:
fn main() -> anyhow::Result<()> {
let mut store = MemoryStore::new();
let cli = Cli::parse();
run_command(&mut store, cli.command)
}
What is missing is logic. The root constructs and hands off.
&dyn Port by default, generics on the hot path. Trait objects keep signatures readable and compile times sane. The cost is a pointer hop per call, which is nothing at the edge and something in a tight loop. Reach for <S: Store> when a profile says so, and stop the generic from infecting every caller above it.
Which leaves the stance: skip the container. A DI container re-implements function application with strings and reflection. In a language with a real type system, main calling constructors in order is the entire feature set of a container, and the compiler checks it. The symmetric trap also exists, by the way. Driving ports follow the same restraint: the use case’s function signature is the driving port, and it only becomes a trait when a second entry point actually needs to share it.
The deterministic core
The mother rule: given the same seed and the same sequence of commands, the core produces the same state and the same hash at every step, on every machine, every time.
FoundationDB and TigerBeetle build their entire testing strategy on this property. Run the whole system in a simulated world, inject partitions, crashes, and clock skew, and when something breaks, replay the exact run from its seed until the bug has nowhere to hide. In this style, the property comes from seven rules:
-
Keep floats out of the state. Float results depend on platform, compiler flags, and the order of operations. Quantities are integers or fixed point; a 1.5% probability is 1_500 out of 100_000.
-
Seed the randomness and put it behind a port. One
ChaCha8Rng, seeded at the root, injected through anRngtrait.thread_rngis OS entropy, and OS entropy kills replay. Security tokens still come from the OS at the edge; the state never does. -
Ordered maps only.
HashMapiterates in a random order per process: same input, different walk, different hash. In the core every map is aBTreeMap, and every iteration that touches state goes in key order. -
Impose a total order on commands before applying them. The network delivers messages in whatever order it likes. The core sorts by
(tick, node, seq)first, then applies. Two nodes that agree on the log agree on the state. -
Derive IDs, never draw them.
Uuid::new_v4()in the state path is entropy in disguise. Use a monotonic counter, orhash(seed, node, seq). -
State is a fold.
state = fold(State::new(seed), commands). Persisting means saving the seed and the log; restoring means replaying it. This rule ages well: a write-ahead log is the same idea, and so is a Raft log, so the architecture does not change shape as the system grows up. -
Hash the state every step. A stable digest over the ordered map, logged or printed. Two replicas diverged if and only if their hashes diverged, and the first differing step tells you where to look.
The parents ask for a pure domain and stop there. Purity gives you fast unit tests. Determinism gives you replay, and replay is a different sport: bugs that reproduce on the first try, simulation tests that cover years of failure scenarios per CPU hour, and a debugging story where “works on my machine” is a contradiction in terms.
Port policy
Ports are where this style dies, when it dies. The failure mode has a name: port explosion. One trait per struct, one mock per trait, DTOs at every ring, and a team that spends its afternoons translating between identical types.
Keep ports few and thick. Cockburn himself favors a handful, grouped by conversation: persist things, tell time, draw randomness.
Two questions hide inside that, and they have different answers. Whether a dependency gets a port: always, from its first use. How many ports you end up with: as few as the conversations you actually have.
The first answer is where I break with the canon, so I will say it plainly. Everyone who endorses abstracting the database and the clock grounds it in needing a test seam. Uncle Bob says mock across architecturally significant boundaries and not within. Seemann says true architectural dependencies. Dan North warns about shadow codebases where every class is backed by exactly one interface. Not one of them writes “at first use, regardless of testability.”
I do. Anything the code reaches across a boundary is a port from the moment it exists: a database, an HTTP API, a queue, the filesystem, the clock. Not on the second use, not once a fake is needed, not once somebody announces the swap. That is what dependency injection is for. A concrete dependency reached directly is not simpler until it needs to change; it is a decision to find out, at the worst possible moment, how much of the code has quietly grown into its shape.
The rule of three is a real rule and it governs something else. Do not extract a shared helper before the third caller, because until then you are guessing at the shape. A boundary carries no such uncertainty. It is a boundary the moment it exists, and abstracting it is a statement about the present rather than a bet on the future.
Ports stay agnostic. Name the port for the conversation, never for the technology behind it: Store, never PostgresStore. Shape it the same way. A port that mirrors DynamoDB’s API method for method is a vendor contract with a Rust spelling, and the second implementation will fight every signature it did not choose. The port is what the app asks for; which vendor answers is the adapter’s problem.
And the signature rule: ports speak domain types only. The moment std::io::Error or a wire format appears in a port trait, the port is a hole in the wall with a curtain over it.
Two ports that name one counterparty are one port. The check is a directory listing: two adapters under one vendor directory, answering to one base URL and one set of credentials, are halves of one conversation. The directory is a proxy and it breaks when a counterparty is reached two ways, their queue for the request and their bucket for the document, and then the shared prefix in the port names is the only signal left. Run it backwards too, because shared counterparty is the signal and shared technology is noise: two adapters under queue/ talking to different counterparties are two ports, and merging them gives you the OracleAdapter that implements everything Oracle-shaped.
The rule stops where a merge forces you to prefix. Three stores can share one physical table and still be three ports, because get means three things and the lifetimes differ. Merging them prefixes every method with the aggregate it touches, which is three ports re-encoded as a naming convention. What united them was the table, and which table a row lands in is the adapter’s decision.
One last failure mode, quieter than explosion: a port created to hold a signature you did not want spreading. A raw storage map in a method breaks the signature rule, and putting it behind its own port looks like containment. A port that exists to contain a violation of the signature rule is the violation. The fix is modelling the missing fields on the entity, at which point the methods speak domain types and fit on the port that already existed.
When not to do this
A prototype you will throw away does not need a hexagon, and pretending otherwise is how prototypes stop shipping. A pure library with no I/O does not need one either, because it has no boundary to abstract in the first place.
The hexagon costs you folders. The determinism costs you habits: the float you cannot use, the uuid you cannot mint, the HashMap you reach for and put back. Pay when a replayable core is worth more than the habits. You will know, because you will be staring at a bug that only happens on Tuesdays, wishing you had a seed.
Discuss on Bluesky →
Comments on Bluesky
No comments yet. Reply on Bluesky and it shows up here.