name: rust-architecture description: Use when the user asks about dependency injection in Rust, trait-as-interface patterns, module boundaries, hexagonal architecture, ports and adapters, error handling strategy, when to use generics vs dyn Trait, how to structure application layers, or how to wire dependencies together. version: 1.0.0
Rust Architecture — DI, Trait Boundaries & Error Handling
Opinionated patterns for structuring Rust applications around testability and clean dependency boundaries. The core principle: domain logic never knows about IO.
Trait-as-Interface
Every significant dependency boundary is a trait. The trait is the port; the struct implementing it is the adapter.
- Define traits in the core/domain crate (or module). They describe what the system needs, not how it's done.
- Implement traits in adapter crates/modules. These are the concrete HTTP clients, database repos, file handlers.
- The application layer wires concrete adapters into domain logic.
- All port traits require
Send + Sync— this enablesArc<dyn Trait>for async/multi-threaded use and test sharing.
#![allow(unused)] fn main() { // In core — defines what we need pub trait UserRepository: Send + Sync { fn find_by_id(&self, id: &UserId) -> Result<Option<User>, UserRepoError>; fn find_by_email(&self, email: &str) -> Result<Option<User>, UserRepoError>; fn save(&self, user: &User) -> Result<(), UserRepoError>; fn delete(&self, id: &UserId) -> Result<(), UserRepoError>; } // In adapter — implements it pub struct SqliteUserRepository { /* connection */ } impl UserRepository for SqliteUserRepository { /* real IO */ } }
Traits should be object-safe when practical — this preserves the option of using dyn dispatch for app-level wiring and testing.
Generics vs dyn Trait — Context-Dependent
This is not an either/or choice. Use both, in different contexts:
Generics: for hot paths and library code
Use generics with trait bounds when the concrete type is known at compile time within a crate, or when you're writing library code where monomorphization matters.
#![allow(unused)] fn main() { pub fn find_active_users<R: UserRepository>( repo: &R, ids: &[UserId], ) -> Result<Vec<User>, UserRepoError> { ids.iter() .filter_map(|id| match repo.find_by_id(id) { Ok(Some(user)) => Some(Ok(user)), Ok(None) => None, Err(e) => Some(Err(e)), }) .collect() } }
Use generics when:
- The function is called in a tight loop
- It's part of a library's public API
- You want the compiler to inline and optimize
- The concrete type is known at the call site
dyn Trait: for app-level wiring and test doubles
Use Arc<dyn Trait> when composing the application, holding dependencies in long-lived structs, or swapping implementations in tests. Arc is preferred over Box because it allows sharing the same instance between the service and the test harness.
#![allow(unused)] fn main() { pub struct UserService { users: Arc<dyn UserRepository>, notifier: Arc<dyn Notifier>, } impl UserService { pub fn new(users: Arc<dyn UserRepository>, notifier: Arc<dyn Notifier>) -> Self { Self { users, notifier } } } }
Use dyn when:
- Constructing or holding application-level service objects
- The indirection cost is negligible (one vtable lookup per call, not in a tight loop)
- You need to swap implementations at runtime or in tests
- The struct is long-lived and owns its dependencies
Decision rule
If you're unsure: if the function constructs or holds things, use dyn. If the function processes things, use generics. Both can coexist in the same codebase.
Hexagonal-ish Architecture
Three layers, loosely held. You don't need a framework — the principle is enough.
Core / Domain
Pure logic — no IO, no runtime dependency. Can use async when the domain is inherently async, but must not depend on a specific runtime. Depends only on std and domain-specific crates (chrono, uuid, serde for derive). Defines traits (ports) for every external dependency it needs.
This layer is trivially testable — no fakes needed for pure functions, and trait-based fakes for anything with dependencies.
Adapters
Implement the port traits. HTTP clients, database access, file IO, message queues. Each adapter depends on the core crate (for the trait definition) plus its IO crates (reqwest, diesel, etc.).
Adapters live in separate modules or separate crates, depending on project size (see rust-project-setup for when to split).
App / Wiring
main.rs or an app module that constructs concrete adapters and injects them into domain services. This is where dyn dispatch happens. This layer reads config, sets up logging/tracing, and builds the dependency graph.
fn main() -> anyhow::Result<()> { let config = Config::from_env()?; let mut db_conn = SqliteConnection::establish(&config.database_url)?; run_pending_migrations(&mut db_conn)?; let users: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository::new(db_conn)); let notifier: Arc<dyn Notifier> = Arc::new(EmailNotifier::new(&config.smtp)); let service = UserService::new(users, notifier); run_server(service, &config.bind_addr) }
Module boundaries in a single crate
Even before splitting into multiple crates, enforce the layering at the module level:
src/
├── domain/ # traits, types, pure logic — no `use crate::infra`
├── infra/ # trait implementations, IO
└── bin/main.rs # wiring
The rule: domain/ never imports from infra/. Enforce this by convention (and by code review). When this boundary justifies a crate split, it's a clean move.
Error Handling Strategy
Libraries: thiserror
Reusable crates define typed error enums. Errors are part of the public API.
#![allow(unused)] fn main() { use thiserror::Error; #[derive(Debug, Error)] pub enum UserRepoError { #[error("user not found: {0:?}")] NotFound(UserId), #[error("duplicate email: {0}")] DuplicateEmail(String), #[error("storage error: {0}")] Storage(String), } }
- Each crate defines its own error type
- Use
#[from]for automatic conversion from downstream errors where appropriate - Keep variants meaningful — don't wrap every error in a generic
Internal(String)
Applications: anyhow
Binary crates and top-level application code use anyhow for ergonomic error propagation.
#![allow(unused)] fn main() { use anyhow::{Context, Result}; fn load_config() -> Result<Config> { let raw = std::fs::read_to_string("config.toml") .context("failed to read config file")?; let config: Config = toml::from_str(&raw) .context("failed to parse config")?; Ok(config) } }
Result<T>meansanyhow::Result<T>in app code- Use
.context("what we were doing")liberally — it creates a chain of context that makes debugging straightforward - Library errors convert automatically via the
Errortrait
The boundary
At the point where library errors enter application code, add context:
#![allow(unused)] fn main() { let user = repo.find_by_id(&id) .context("failed to look up user during checkout")?; }
Adapter crates that serve only one application can use either style. If the adapter might be reused, use thiserror.
Async Strategy — Runtime-Agnostic Core
The core/domain crate can absolutely use async — the key constraint is no runtime dependency. Domain logic can define and use async traits, return futures, and await other domain operations. What it must not do is pull in tokio, async-std, or any specific runtime as a dependency.
- Domain traits freely use
async fnwhen the domain is inherently async - The core crate should not depend on a specific runtime — no
tokio::spawn, notokio::time::sleep - The binary crate selects the runtime (
tokio,async-std, etc.) - Pure domain functions that don't need async should remain synchronous — don't make everything async just because some things are
If a trait needs async methods, use async fn in trait (stabilized in Rust 1.75+). Note that native async trait methods are not object-safe — use the async-trait crate if you need dyn dispatch with async:
#![allow(unused)] fn main() { use async_trait::async_trait; #[async_trait] pub trait EventStore: Send + Sync { async fn append(&self, stream: &str, events: &[Event]) -> Result<(), EventStoreError>; async fn read_stream(&self, stream: &str) -> Result<Vec<Event>, EventStoreError>; } }
Related Skills
For workspace layout and crate splitting decisions, see the rust-project-setup skill. For hand-written fakes and testing patterns that exercise these trait boundaries, see the rust-testing skill.