The best way to structure Rust web services
How you organize a Rust web service matters as much as the code you write. A good layout yields faster builds, simpler tests, and safer refactors. Rust’s module system, crate boundaries, and compilation model reward deliberate structure. This guide shows practical patterns—from small APIs to production backends—optimized for clarity, modularity, and long-term growth.
Why project structure matters
- Faster builds: scoped modules and crates reduce unnecessary recompilation.
- Team clarity: consistent boundaries and naming help contributors find code quickly.
- Cleaner dependencies: traits and visibility rules shine when dependencies flow one way.
- Testability: clear seams make unit, integration, and end-to-end tests easier.
- Future-proofing: structure absorbs feature growth, framework swaps, and integrations.
Core concepts in Rust project organization
Cargo workspaces
Use a workspace to group related crates (API, domain, infrastructure, shared). Share versions, compile independently, and keep concerns separate.
# Cargo.toml (workspace root) [workspace] members = ["api", "domain", "infrastructure", "shared"] [workspace.dependencies] tokio = { version = "1.0", features = ["full"] } serde = { version = "1.0", features = ["derive"] }
- Best for large apps, microservices, shared libraries, or multi-team ownership.
Modules & Visibility
Expose only what’s necessary. Keep implementation details private; group related items into modules.
// lib.rs pub mod api; pub mod domain; mod internal; // api/mod.rs pub mod v1; mod middleware; // domain/mod.rs pub mod models; pub mod services; mod repositories;
pub
(public),pub(crate)
(crate-wide),pub(super)
(parent), default private.
Dependency strategy
Group related crates, prefer semver-compatible ranges, and gate optional features.
[dependencies] tokio = { version = "1.0", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" axum = "0.7" sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres"] } config = "0.13" tracing = "0.1" tracing-subscriber = "0.3" [dev-dependencies] tokio-test = "0.4" mockall = "0.11"
- Pin only critical crates; run
cargo audit
regularly.
Targets and builds
my-service/ ├── target/ # build artifacts (gitignored) ├── src/ # source ├── tests/ # integration tests ├── benches/ # benchmarks └── examples/ # sample binaries
- Use
cargo check
for fast loops and configure.cargo/config.toml
for project settings.
Project structure patterns
1) Basic web service (Small APIs, PoCs)
my-service/ ├── Cargo.toml ├── src/ │ ├── main.rs │ ├── lib.rs │ ├── config/ │ ├── handlers/ │ ├── models/ │ ├── services/ │ └── utils/ ├── tests/ └── README.md
Example: entry point delegating to lib code.
use my_service::config::AppConfig; use my_service::handlers::create_app; #[tokio::main] async fn main() { let config = AppConfig::from_env(); let app = create_app(config).await; axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) .serve(app.into_make_service()) .await .unwrap(); }
- Pros: quick to grasp, testable, ideal for single-purpose services.
2) Advanced multi-module (Larger Apps)
Adopt clean architecture: separate API, domain, infrastructure, and shared crates or modules.
src/ ├── api/ # HTTP layer & routing ├── domain/ # entities, services, repositories (traits) ├── infrastructure/ # DB, external clients, config, logging └── shared/ # errors, types, helpers
- Dependency flow:
api → domain
;infrastructure → domain
;shared
is reusable. - Domain has no external framework or DB dependencies.
Clean architecture in Rust
- Domain: entities, value objects, repository traits, domain services.
- Application: orchestrates use cases, DTOs, transactions, CQRS.
- Infrastructure: DB impls, external APIs, config, observability.
- Presentation: HTTP routing, handlers, middleware, validation.
Sample domain entity (value objects encourage invariants):
#[derive(Debug, Clone)] pub struct Email(String); impl Email { pub fn new(s: String) -> Result<Self, DomainError> { if s.contains('@') { Ok(Self(s)) } else { Err(DomainError::InvalidEmail) } } }
Framework-specific considerations
Actix-web
- Group routes by domain with
web::scope
; keepmain.rs
thin. - Share state via
web::Data<T>
; add middleware withApp::wrap
.
pub fn config(cfg: &mut actix_web::web::ServiceConfig) { use actix_web::web; cfg.service(web::scope("/api/v1").configure(crate::user::controller::config)); }
Axum
- Compose
Router
s with.nest()
and.layer()
. - Use extractors (
Path
,Query
,Json
,State
) for declarative handlers. - Centralize errors with an
AppError
that implementsIntoResponse
.
pub fn user_router() -> axum::Router { use axum::{routing::get, Router}; Router::new().route("/", get(get_users)).route("/:id", get(get_user)) }
Common patterns to avoid
- Circular dependencies: extract shared contracts into their own module/crate.
- God modules: split large files into cohesive submodules.
- Tight coupling: depend on traits; keep DB/framework specifics out of domain.
- Poor error handling: prefer
Result<T, E>
; avoidunwrap()
/panic!
in request paths.
Conclusion
Rust rewards thoughtful structure. Start simple, then evolve toward clean architecture as needs grow. Keep domain logic independent, use traits to decouple implementations, lean on workspaces for scale, and invest early in testing and observability. Make incremental improvements, document decisions, and let the structure work for you—not against you.
The post The best way to structure Rust web services appeared first on LogRocket Blog.
This post first appeared on Read More