Architecting Rust Systems
Most languages let you bolt architecture on after the fact: draw boxes and arrows on a whiteboard, then hope the code follows.
Rust does not extend that courtesy, because the compiler enforces a real dependency graph, a real ownership graph, and a real set of types the moment you write use and struct.
This page is the conceptual anchor for the rest of the architecture-design section: it explains why crate boundaries, error strategy, and domain modeling are really the same activity viewed from three angles, and why deferring any of them is more expensive in Rust than in a dynamically typed language.
Summary
- In Rust, architecture is encoded directly in the crate/module graph, the error type hierarchy, and the domain types, rather than living only in diagrams or documentation.
- Insight: Decisions made late (loose crate boundaries, ad-hoc error handling, primitive-typed domain data) compound into compile-time coupling that is far harder to unwind than in languages without a borrow checker.
- Key Concepts: crate graph, module boundary, error layering, newtype, typestate, illegal state.
- When to Use: Starting a multi-crate workspace, introducing a second integration surface (HTTP plus CLI, for example), designing an error type for a library others will consume, or modeling a domain with real invariants (orders, accounts, workflows).
- Limitations/Trade-offs: Over-applying these ideas to a small script or prototype adds ceremony (extra crates, extra enum variants, extra newtypes) that slows early iteration without a matching payoff.
- Related Topics: ownership and lifetimes, trait-based abstraction, workspace tooling, API design.
Foundations
Architecture, stripped of buzzwords, is the set of decisions about what depends on what, what can fail and how failure is represented, and what data shapes are allowed to exist.
Rust makes all three of those decisions visible and, critically, enforceable by the compiler rather than by convention or code review alone.
A crate graph is the directed graph formed by Cargo.toml dependency entries; a module boundary is the finer-grained equivalent inside a single crate, drawn with pub, pub(crate), and private items.
Because Rust forbids circular crate dependencies outright, the crate graph is a forced acyclic graph, which means the compiler itself rejects a certain class of "spaghetti architecture" that other ecosystems merely discourage.
Error layering means each layer of a system (domain logic, infrastructure adapters, transport handlers) owns an error type shaped for its own concerns, with translation happening at the seams rather than one giant error enum threading through everything.
A newtype is a single-field tuple struct that wraps a primitive (struct OrderId(u64)) so the type checker, not a runtime check, prevents mixing up an order ID and a customer ID even though both are u64 underneath.
An illegal state is a value your data model can technically represent but that should never exist in practice, such as an order that is both "cancelled" and "shipped" at once when those are stored as two independent booleans instead of one enum.
The unifying insight across all of this is that Rust's type system and ownership model are not just safety features, they are an architecture description language that the compiler actively checks on every build.
Mechanics & Interactions
These three pillars interact rather than standing alone.
Crate boundaries determine which error types are even visible to which layers, because an error type defined in a domain crate cannot leak framework-specific details (an axum status code, a sqlx row error) without that crate depending on axum or sqlx, which the boundary should forbid.
Domain modeling shapes what the error types even need to express, because a well-typed domain often turns entire classes of runtime error into compile-time impossibility, shrinking the error enum instead of growing it.
The dependency direction that matters most is that infrastructure and transport crates depend inward on the domain crate, never the reverse, which is what keeps business rules testable without a database or an HTTP server running.
// domain crate: no tokio, no axum, no sqlx in Cargo.toml
pub enum OrderError { NotFound(OrderId), AlreadyShipped }
// infra crate depends on domain; domain never depends on infra
impl From<sqlx::Error> for OrderError {
fn from(_: sqlx::Error) -> Self { OrderError::NotFound(OrderId(0)) }
}The snippet above is less about the code and more about what is absent: the domain crate's Cargo.toml has no way to accidentally import a web framework, so the compiler physically prevents the most common architectural drift, business logic quietly depending on delivery mechanism.
A common reasoning pitfall is treating this as a one-time setup step; in practice the graph should be re-checked whenever a new crate is added, because a single misplaced use statement across a boundary can silently reintroduce the coupling the split was meant to prevent.
Advanced Considerations & Applications
At scale, the crate graph becomes a build-performance lever as much as a design one, because Cargo recompiles a crate and everything downstream of it whenever its public API changes, so unstable crates near the root of the graph slow every developer's inner loop.
Error strategy needs to account for observability from the start: a domain error enum that is #[non_exhaustive] lets you add new failure cases without a semver break, while a stable set of external error codes keeps client SDKs and dashboards from breaking on internal refactors.
Domain modeling pays off most in systems with real state machines, workflows, payments, order lifecycles, where a typestate pattern (each state is a distinct type, and only the valid transition methods exist on each type) turns "did we forget to check this?" bugs into "this code does not compile" errors.
None of these three pillars is free, and the advanced skill is knowing how far to push each one for the system's actual risk profile rather than applying maximum rigor everywhere.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Single crate, modules only | Fast iteration, no workspace ceremony | No compiler-enforced boundary, easy to blur layers | Solo projects, prototypes, small CLIs |
| Multi-crate workspace with inward dependency rule | Compiler-enforced boundaries, parallel compilation, testable domain in isolation | More Cargo.toml files to maintain, upfront design cost | Systems with 2+ integration surfaces or multiple contributors |
| One shared "god" error enum | Simple to start, one type to match on everywhere | Every layer recompiles on any error change, leaks internal detail outward | Never recommended past a throwaway prototype |
Layered errors with #[from] translation at boundaries | Each layer owns its own concerns, changes stay local | Requires writing conversion impls at every seam | Any system with more than one crate or external API |
Primitive-typed domain data (raw u64, String, bool flags) | Zero ceremony, quick to write | Illegal states compile, invariants live only in comments | Scripts, throwaway tools |
| Newtypes plus enums for domain state | Illegal states become compile errors, self-documenting | More types to define and thread through the codebase | Business rules where correctness matters and bugs are costly |
Common Misconceptions
- "Architecture means picking the right design pattern." - In Rust the higher-leverage decisions are the crate dependency direction and the shape of the domain types; patterns like builder or visitor are implementation details layered on top, not the architecture itself.
- "I can add crate boundaries later once the project grows." - Splitting a crate after business logic and framework code are already tangled together is a real refactor, not a file move, because every
useof a framework type inside "domain" code has to be found and removed. - "One big error enum keeps things simple." - It feels simple at first, but it means every crate that produces or consumes errors recompiles whenever any single error case changes anywhere in the system, and it forces internal detail out to public API consumers.
- "The type system replaces the need for tests." - Newtypes and enums eliminate entire categories of bugs (mixed-up IDs, impossible state combinations) but they do not verify business logic like tax calculations or scheduling rules; you still need tests for those.
- "More crates are always better for architecture." - Splitting into many tiny crates before boundaries are stable adds
Cargo.tomloverhead and dependency-graph churn without a matching benefit; three to seven crates is a common range for mid-sized systems, not thirty.
FAQs
What does "architecture" concretely mean in a Rust codebase?
It is the combination of three things the compiler actually enforces: the acyclic crate/module dependency graph, the error type hierarchy and where translation happens between layers, and the domain types that determine which states are representable at all.
Why should error strategy be decided early instead of evolving naturally?
Once a few dozen call sites use .unwrap() or blanket-convert everything to anyhow::Error, retrofitting typed, layered errors means touching every one of those call sites individually.
Deciding the shape early, typed domain errors inside, translation at the edges, costs little when the codebase is small and compounds in savings as it grows.
How does the crate graph actually enforce architectural boundaries?
Cargo will not compile a circular dependency between crates, so if domain never lists infra as a dependency, code in domain physically cannot import anything from infra, even by accident.
This is stronger than a lint or a code review rule because it is checked on every single build, not just when someone remembers to look.
How does domain modeling actually reduce the size of an error type?
Every runtime check you move into the type system removes a corresponding Err variant that would otherwise exist to report the violation at runtime.
A NonEmptyVec<LineItem> newtype means "order has no line items" is no longer a possible error case, because it is no longer a possible value.
When is a single crate with modules the right choice instead of a workspace?
Solo projects, prototypes, and small CLIs with one integration surface rarely benefit from workspace overhead; pub(crate) module boundaries give most of the organizational benefit without extra Cargo.toml files to maintain.
Reach for a workspace once there are two or more integration surfaces (HTTP and CLI, for example) or more than one contributor working in parallel.
What is the trade-off of pushing typestate modeling too far?
Typestate (a distinct type per state, with only valid transition methods available) is powerful for genuine multi-step workflows, but applying it to simple two-state flags multiplies the number of types and functions for little real safety gain. Reserve it for state machines where an invalid transition would be a real, costly bug, not for every boolean in the system.
Isn't a workspace with many small crates always more "architected" than one big crate?
No; extra crates add real cost (build graph complexity, more files to keep in sync, slower cargo check on some workflows) and that cost should be paid only when it buys something, such as compile isolation for a stable domain layer or a genuine team boundary.
A monolith with clean module boundaries can be just as well-architected as a workspace, and is often the better starting point.
How do these three pillars show up differently for a library crate versus a binary application?
A library crate should avoid anyhow entirely and expose a precise, #[non_exhaustive] error enum because its error type is part of its public API contract with downstream consumers.
A binary application has more freedom to aggregate errors loosely at the top level (in main, for instance) since it is the end of the call chain, not an API other code depends on.
What is the most common misconception that causes real production pain?
Believing crate boundaries can be added after the fact "when we have time," which in practice means business logic quietly accumulates framework imports (an HTTP status code here, a database row type there) until separating them requires touching most of the codebase at once.
How does the inward dependency rule interact with testing?
Because infrastructure depends on the domain and not the reverse, domain logic can be unit tested without a database connection, an HTTP client, or any I/O at all, which makes the fast test suite fast because it has nothing slow to wait on. Infrastructure adapters get their own, separate integration tests that exercise the real database or network calls.
Does making illegal states unrepresentable mean I no longer need runtime validation?
Not entirely; data still arrives from outside the type system's control, over the network as JSON, from a database row, from user input, so a validation or TryFrom step at the boundary is still required to convert loosely typed input into the strict domain type.
The type system's job starts once that conversion succeeds, guaranteeing the resulting value cannot become invalid afterward.
How do I decide which of the three pillars to invest in first on a new project?
Start with the crate/module boundary decision, because it determines where the other two even live; decide error strategy next since it shapes how every layer communicates failure; treat rich domain typing as the layer you deepen incrementally as real business rules and their associated bugs emerge.
Related
- Workspace & Crate Boundaries - the crate-graph pillar in depth, with a concrete workspace layout and dependency rules.
- Error Strategy - how to layer domain, infrastructure, and API error types with translation at the seams.
- Domain Modeling with Types - newtypes, enums, and typestate for making illegal states unrepresentable.
- Trait-Based Abstraction - how traits define the ports that let infrastructure plug into the domain without the domain depending on it.
- Architecture Decision Checklist - a practical checklist for applying these pillars when making a real architectural decision.
- Typestate Pattern - the pattern behind the state-machine style domain modeling referenced above.
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.