Architecture is often difficult to define directly. When it’s reasonable and intentional, it tends to disappear into the background, allowing a system to grow, adapt, and remain useful over time. When it’s poor, its effects are impossible to miss because of how quickly poor architecture fossilizes a codebase. Ultimately, poorly architected systems become unpleasant and risky to work on, and then bad things happen. (Note to self: insert a long rambling list of bad things here.)
Software teams do not usually arrive at maintainable systems by accident. Over time, the field has developed a handful of patterns that help control dependency flow and structural complexity. Among them, Hexagonal, Clean, and Onion Architecture all aim at a similar goal, which is to keep infrastructure concerns from becoming entangled with business logic.
This post is about Onion Architecture in practice. More specifically, it is about applying it to ObzenFlow, a streaming framework I have been building in Rust. What made the exercise of adopting Onion Architecture interesting was not just the layering itself, but the fact that Rust’s crate boundaries and build system can enforce those boundaries structurally rather than leaving them as conventions. It also solved a few very real problems at different stages of development.
I am not presenting the project as a perfect reference implementation. The value, I think, is that it is a real one, and that you can inspect the code for yourself. Building it this way made some things much easier, including swapping infrastructure and implementing middleware-style cross-cutting concerns, and it also clarified where the pattern adds cost. That is what I want to explore here.

What is Onion Architecture?
Jeffrey Palermo introduced Onion Architecture in 2008 as a response to a specific failure mode in traditional layered systems. In N-tier designs, the UI couples transitively to data access through the business logic layer. When data access technology changes, that coupling forces rewrites across the stack.
Onion Architecture inverts the dependency structure. The system is arranged as concentric layers with all dependencies pointing inward. The innermost layer, the domain core, defines business rules and the abstractions that outer layers must satisfy. Outer layers supply concrete implementations for persistence, transport, and integration. The core compiles and runs with no reference to any outer layer. That inversion is what separates Onion Architecture from traditional layered designs.
Defining Architectural Goals
Every software project needs explicit architectural goals. If those goals stay implicit, the architecture drifts toward whatever is easiest in the moment.
For ObzenFlow, the actual goals were maintainability and extensibility. I wanted the framework to remain understandable as it grew. I wanted new transports, storage backends, and operational features to be additive changes rather than rewrites.
Onion Architecture was the mechanism I chose to get there. I used it to constrain cognitive complexity, preserve reversibility, and prevent fossilization. Those are enabling conditions, not end goals. If each layer remains easy to reason about, high-churn decisions remain reversible, and early coupling does not harden into structure, maintainability and extensibility become much easier to preserve.
Constraining Cognitive Complexity
Cognitive complexity is how much of a system a person or team can hold in their head at once. At the component level, it is the amount of behaviour, state, and dependency a reader must reconstruct to reason about a single component in isolation. Architectures that constrain cognitive complexity keep local reasoning tractable.
One of my first architectural goals for ObzenFlow was to make the right implementation decision the most natural one, and to make the wrong decision less efficient to implement. In practice, that means making it obvious where dependencies belong, where new types should be added, and where concrete implementations should live. It also means making cross-layer dependency mistakes unnatural to implement.
infra cannot depend on runtime while runtime also depends on infra unless the build graph itself changes. Once that boundary is enforced, it becomes much more obvious when you’re introducing a useful abstraction and when you are letting an implementation detail leak.LLMs especially love the path of least resistance, and the path of least resistance is to have components reach across logical boundaries, import concrete implementations directly, and gradually turn the dependency graph into a bowl of spaghetti. Onion Architecture pushes back on that tendency by making dependency direction explicit and, in the best case, enforceable by the compiler and build system.
Preserving Reversibility
Architectural decisions fall on a spectrum between two-way doors (cheap to reverse) and one-way doors (expensive or impossible to reverse). Preserving reversibility means keeping the two-way doors actually reversible, so choices about transport, persistence, or deployment do not force business logic rewrites when those choices change.
For example, I may prefer Warp as an HTTP server, but a framework cannot assume every team will want the same server, transport, or runtime choices. Decisions like those should remain reversible, and in practice that means giving them explicit seams in the codebase. If those choices leak into the core too early, the system becomes harder to reason about in the present and harder to change in the future.
That said, some decisions should be hard to reverse. In ObzenFlow, I committed completely to Tokio, to my own finite state machine library, and to a handful of other strong implementation choices. The point is not to avoid one-way doors, the point is to make them intentional.
Preventing Fossilization
Fossilization is the point at which enough architectural decisions harden that change starts to feel structurally unsafe. Once the cost and risk of reversing a decision become high enough, the team stops considering it, even if that means the system stagnates. Onion Architecture gives us a way to encode reversibility into the codebase intentionally before entropy removes it.
That does not mean every dependency must be abstracted behind a port. If you try to make every library swappable, you will spend all of your time building indirection and none of your time shipping. Real systems require hard decisions and the discipline to stand behind them. In practice, two-way-door decisions deserve seams in the codebase; one-way-door decisions usually do not.
The goal is to preserve reversibility where reversibility matters while containing cognitive complexity well enough that the system remains understandable and maintainable. Onion Architecture helps here by drawing hard boundaries around the decisions most likely to change while allowing less important internal implementation choices to remain direct.
With those goals in mind, the rest of this post turns to the architecture itself. The next section walks through how to think about Onion Architecture in general, and then traces a single abstraction through one narrow slice of it. The chosen abstraction is the journal, which sits at the heart of persistence in ObzenFlow.
Slicing the Onion
Onion Architecture imposes one structural constraint. Inner layers cannot depend on outer layers. Everything else follows from that.
Think about that visibility rule in terms of specificity. Inner layers declare capabilities and know only about themselves. Outer layers know about those capabilities and inject specific implementations into them. Visibility and specificity run in opposite directions.
At the edge are things the project uses but does not own. Database drivers, HTTP servers, queue clients, cloud SDKs, and logging libraries all fit here. PostgreSQL has never heard of a ChainEvent. Warp does not care what a flow is. These components are valuable because they are generic.
Move inward and the code starts to describe capabilities rather than tools. ObzenFlow needs to persist events, expose sources and sinks, run stages, replay state, and apply middleware. Those capabilities are specific to the framework. They are the reason the framework exists.
The dependency rule follows from that distinction. Inner code should describe what the system needs. Outer code should provide the concrete implementation. The runtime should ask for a journal. The runtime should not go shopping for a disk journal. The core should define the contract. The edge should decide whether disk, memory, object storage, or something not written yet satisfies that contract.
The layers then become easier to read:
- Infrastructure. Infrastructure contains commodity machinery such as database drivers, HTTP servers, message brokers, object stores, cloud SDKs, and the runnable application.
- Interface layer. The interface layer contains translation code such as sources, sinks, middleware, monitoring exporters, CLI commands, and HTTP handlers that turn external input into internal calls.
- Runtime / Domain services. Runtime and domain services contain product capabilities such as stage execution, pipeline orchestration, supervision, replay, and coordination across core abstractions.
- Domain core. The domain core contains product rules and contracts such as events, invariants, state transitions, topology rules, and traits such as
Journal<T>.
The outer pieces churn because vendors, libraries, protocols, and deployment models churn. The inner capabilities should change only when the framework changes. Onion Architecture protects that difference by injecting commodity components into product capabilities instead of letting product capabilities import commodity components directly.
A Practical Example: Injecting Storage
The diagram below shows how ObzenFlow is actually architected. The four layers are implemented across five crates, with obzenflow_dsl serving as a composition root.
The journal is the main persistence mechanism in ObzenFlow. Every event the framework processes is written to the journal, and state is reconstructed by replaying those events. (For readers interested in the nitty-gritty of why event sourcing works and why the journal is so important, see Mealy Machines, Moore Machines, and Why Event Sourcing Works.)
The journal is a particularly useful example because ObzenFlow requires a journal but does not prescribe how it stores data. Disk, memory, or object storage can satisfy the contract as long as the backend implements the Journal<T> trait. The Domain Core and the Runtime do not care which storage backs the journal. They cannot even know, because the dependency graph hides the outer crates that implement the trait.
The diagram below shows where in the architecture the journal trait is declared, required, and implemented.
- The
Journal<T>trait is declared in the core. - The runtime depends on that trait, typically as
Arc<dyn Journal<ChainEvent>>. - The infrastructure crate supplies
DiskJournal<T>as one concrete implementation.
That example may look trivial, but it captures the core advantage of the pattern. Because the runtime only depends on the Journal<T> trait, swapping in a new storage backend (an S3 journal, an in-memory test journal, a Postgres journal) is a localized change rather than an architectural one. Implementing the trait for a new backend is on the order of a week or two of work in ObzenFlow because the seam already exists. In frameworks where storage assumptions are baked into the core, adding a new storage backend often requires restructuring the runtime itself. That is a much larger and riskier project.
A skeptic might respond that this is just interfaces or dependency injection. The distinction is structural rather than syntactic. In a classical N-tier system, the data access layer typically defines the storage interface and the business layer imports it. The interface exists, but the business layer still depends on data access, and the database remains at the architectural centre.
Onion Architecture inverts that arrangement. The Journal<T> trait is declared in the core where the abstraction is at its most general. The runtime depends only on the core trait. Concrete implementations live in outer layers and are wired in at the composition root. The source-code dependency points inward at every boundary. What changes between the two patterns is the location of the abstraction and the direction of the source-code dependency.
The point is to prevent infrastructure concerns from becoming business concerns. If persistence logic leaks into the core model, business decisions will eventually be shaped by storage constraints. If transport or framework concerns leak inward, business decisions will eventually be shaped by delivery mechanics. That is how architectures drift towards complexity.
Enforcing Layers
The strongest form of Onion Architecture is one where the build system prevents violations altogether. Languages with compilers, strong type systems, and explicit dependency declarations have a significant advantage here.
For instance, in a multi-crate Rust project, each crate declares its dependencies explicitly in Cargo.toml. The compiler rejects circular dependencies because if a crate is not listed as a dependency, its types and functions are invisible. The onion layers are not a convention that developers might ignore. They are a constraint that the toolchain enforces on every build.
This is the primary architectural reason to split a Rust project into multiple crates rather than re-exporting from a single one. Within a single crate, any module can reach any other module through visibility modifiers. Nothing prevents an inner module from importing an outer one. The compiler does not enforce dependency direction within a crate boundary. Splitting into separate crates makes the dependency graph explicit and structurally enforced.
Layers in ObzenFlow
The ObzenFlow streaming framework is structured this way. The main Onion Architecture path is implemented across five default-member crates in the workspace, with obzenflow_dsl serving as a composition crate rather than a separate architectural layer:
| Crate | Role | Depends On |
|---|---|---|
obzenflow_core | Domain core | Nothing in the workspace |
obzenflow_runtime | Business logic | core |
obzenflow_adapters | Adapters | core, runtime |
obzenflow_dsl | Composition root | core, runtime, adapters |
obzenflow_infra | Infrastructure | All of the above |
The dependency graph is a strict DAG.
obzenflow_corehas zero workspace dependencies. It defines events, journal contracts, metrics interfaces, and port traits. It does not know what database backs the journal, what protocol delivers requests, or what runtime executes the pipeline.obzenflow_runtimedepends only on core. It implements handler traits, pipeline orchestration, and backpressure. It cannot import an adapter or an infrastructure type because those crates are not in itsCargo.toml.obzenflow_adaptersdepends oncoreandruntime. It provides composable middleware (rate limiting, circuit breakers), concrete source and sink implementations, and monitoring exporters. It owns adapter behaviour for concrete ingress and egress concerns, but not the runnable application, HTTP hosting shell, or deployment model.obzenflow_dsldepends oncore,runtime, andadapters. It is the composition root for flow construction. Theflow!macro, stage descriptor macros, topology validation, journal allocation, middleware resolution, and stage wiring live here. It is an outer orchestration crate, not a fifth concentric layer.obzenflow_infrasits at the outermost layer. It depends on everything and provides the concrete implementations: disk-backed journals, web servers, HTTP clients, and the application entry point. It is the only crate that knows how the system is deployed.
This structure has a practical consequence for debugging. If a bug involves business logic, it is in runtime. If it involves middleware, concrete source or sink behaviour, or monitoring exporters, it is in adapters. If it involves the runnable application, HTTP ingress endpoints, persistence, or deployment, it is in infra. The architecture narrows the search space before you open a single file.
The same principle applies in any language with explicit dependency declarations. Java modules, Scala SBT projects, and Go packages with internal visibility all support the same structural guarantee. The key property is that the build system makes inward-only dependencies the default and outward dependencies impossible without an explicit, reviewable change to the dependency manifest.
Closing
Onion Architecture was slower to implement in ObzenFlow at the beginning. The indirection, crate boundaries, and deliberate seams all added upfront cost. There is no point pretending otherwise.
But over time that cost has paid me back because the complexity is contained within each layer instead of spreading across the whole framework. That containment is what made a surprising number of features tractable. Middleware, Prometheus integration, and swappable journal implementations like disk-backed and in-memory journals became multi-day projects instead of multi-month ones. In many frameworks, features like those would require far more invasive rewrites because infrastructure concerns leak inward.
That does not make Onion Architecture the right choice for every system. But for a framework expected to grow, survive technology churn, and support multiple implementation choices over time, it has been worth the upfront drag. If you want the concrete version of that claim, take a look at ObzenFlow.