A layered architecture with feature modules sitting atop shared horizontal infrastructure. Each module owns its business logic and external API adapters while sharing common resources like database, configuration, and shared API clients.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β HTTP SERVER β
β (Axum Router) β
βββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββ΄ββββββββββββββββ
β β
βββββββββββββΌββββββββββββ βββββββββββββββΌββββββββββββββ
β β β β
β Module A β β Module B β
β β β β
β βββββββββββββββββββ β β βββββββββββββββββββββββ β
β β HTTP Handlers β β β β HTTP Handlers β β
β ββββββββββ¬βββββββββ β β ββββββββββββ¬βββββββββββ β
β βΌ β β βΌ β
β βββββββββββββββββββ β β βββββββββββββββββββββββ β
β β Service β β β β Service β β
β ββββββββββ¬βββββββββ β β ββββββββββββ¬βββββββββββ β
β βΌ β β βΌ β
β βββββββββββββββββββ β β βββββββββββββββββββββββ β
β β Repository β β β β Repository β β
β βββββββββββββββββββ β β βββββββββββββββββββββββ β
β β β β β β
β βββββββββββββββββββ β β βββββββββββββββββββββββ β
β β External API β β β β External API β β
β β Client β β β β Client β β
β βββββββββββββββββββ β β βββββββββββββββββββββββ β
β β β β
βββββββββββββ¬ββββββββββββ βββββββββββββββ¬ββββββββββββββ
β β
βββββββββββββββββ¬ββββββββββββββββ
β
βββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ
β SHARED SERVICES β
β (Shared clients, separate instances) β
βββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ
β DATABASE β
β (Shared SqlDb / PgPool) β
βββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ
β CONFIG β
β (Single EnvConfig instance) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Aspect | Description |
|---|---|
| Layered | Horizontal layers (Config β Database β Shared Services β Modules β HTTP) |
| Shared Infrastructure | Database pool and config are shared across all modules |
| Module Ownership | Each module owns its service logic and external API adapters |
| Dependency Direction | Modules depend on shared infrastructure, not on each other |
main.rs
β
βββΊ EnvConfig::load()
β βββΊ Loads all configuration from environment variables
β
βββΊ HttpServer::start(config)
β
βββΊ SqlDb::connect(database_url)
β βββΊ Creates PgPool connection
β βββΊ Runs all migrations
β
βββΊ module_a::router(config, db)
β βββΊ Creates module's AppState
β βββΊ Returns configured Router
β
βββΊ module_b::router(config, db)
β βββΊ Creates module's AppState
β βββΊ Spawns background tasks (if needed)
β βββΊ Returns configured Router
β
βββΊ Combines routers, starts HTTP server
The database is truly shared across modules:
// main.rs creates single SqlDb instance
let db = SqlDb::connect(&config.database_url).await?;
// All modules receive the same instance (clone is cheap - ref-counted)
let module_a_router = module_a::router(&config, &db).await?;
let module_b_router = module_b::router(&config, &db).await?;SqlDb wraps sqlx::PgPool which is internally reference-counted. Cloning is cheap.
Single config instance loaded once, passed by reference:
let config = EnvConfig::load(); // Single load
module_a::router(&config, &db) // Passed as &EnvConfig
module_b::router(&config, &db) // Same referenceEach module instantiates its own client from shared config:
// Module A creates its own instance
let api_client = SharedApiClient::new(
&config.api_url,
&config.api_key,
);
// Module B creates its own instance (same config values)
let api_client = SharedApiClient::new(
&config.api_url,
&config.api_key,
);This is safe because reqwest::Client (used internally) is ref-counted. Separate instances allow concurrent calls without blocking.
src/
βββ main.rs # Entry point, bootstrap
β
βββ infrastructure/ # Shared infrastructure layer
β βββ config.rs # EnvConfig - environment configuration
β βββ http/ # HTTP server, middleware, extractors
β β βββ mod.rs
β β βββ server.rs # HttpServer::start(), router composition
β β βββ error.rs # HTTP error types
β β βββ extractors.rs # Custom Axum extractors
β βββ sql/ # Database abstraction
β βββ mod.rs
β βββ sql_db.rs # SqlDb wrapper around PgPool
β βββ migrator.rs # Generic migration runner
β βββ migration.rs # MigrationTrait definition
β βββ unified_executor.rs # Abstraction for pool/transaction
β
βββ shared/ # Shared services layer
β βββ mod.rs
β βββ shared_api_client.rs # Shared API client
β
βββ module_a/ # Feature module A
β βββ mod.rs # Public exports
β βββ http.rs # Router factory + handlers
β βββ app_state.rs # Module's AppState
β βββ service.rs # Business logic
β βββ repository.rs # Database queries
β βββ types.rs # Value objects, entities
β βββ error.rs # Module-specific errors
β βββ external_api.rs # External API client
β βββ migrations/ # Module-owned migrations
β
βββ module_b/ # Feature module B
βββ mod.rs # Public exports
βββ http.rs # Router factory + handlers
βββ app_state.rs # Module's AppState
βββ service.rs # Business logic
βββ repository.rs # Database queries
βββ types.rs # Value objects, entities
βββ error.rs # Module-specific errors
βββ external_api.rs # External API client
βββ background_task.rs # Background task (if needed)
βββ migrations/ # Module-owned migrations
Each module follows the same internal pattern:
http.rs (Inbound Adapter)
β
βΌ
app_state.rs (Dependency Container)
β
βΌ
service.rs (Business Logic)
β
ββββΊ repository.rs (Data Access)
β β
β βΌ
β infrastructure/sql (Shared Database)
β
ββββΊ *_api.rs (External API Client)
β
βΌ
External Service
| Layer | File | Responsibility |
|---|---|---|
| Inbound Adapter | http.rs |
Router factory, HTTP handlers, request/response mapping |
| App State | app_state.rs |
Dependency wiring, holds service references |
| Service | service.rs |
Business logic, orchestration, use case implementation |
| Domain | types.rs |
Value objects with validation, entities |
| Repository | repository.rs |
Database queries via UnifiedExecutor |
| Error | error.rs |
Module-specific error types with conversions |
Each module exposes a router() function that:
- Receives shared infrastructure (config, db)
- Creates module-specific dependencies internally
- Wires up AppState
- Returns a configured Router with state embedded
// module_a/http.rs
pub async fn router(
config: &EnvConfig,
db: &SqlDb,
) -> Result<Router, HttpServerError> {
let state = AppState::new(config, db.clone());
Ok(Router::new()
.route("/", post(create_handler))
.route("/validate", post(validate_handler))
.with_state(state))
}The main HTTP server nests module routers:
// infrastructure/http/server.rs
Router::new()
.route("/", get(health_check))
.nest("/module-a", module_a_router)
.nest("/module-b", module_b_router)
.layer(TraceLayer::new_for_http())Modules can depend on other modules as long as dependencies form a top-down acyclic graph. No circular dependencies allowed.
Example: Feature Modules with Shared Provider
ββββββββββββββββββββββ βββββββββββββββββββββββββ βββββββββββββββββββββββββ
β β β β β β
β Module A β β Module B β β Module C β
β β β β β β
βββββββββββ¬βββββββββββ βββββββββββββ¬ββββββββββββ βββββββββββββ¬ββββββββββββ
β β β
β β β
βββββββββββΌβββββββββββββββββββββββββββΌββββββββββββ β
β β β
β Shared Provider β β
β (shared provider module) β β
β β β
ββββββββββββββββββββββββββ¬ββββββββββββββββββββββββ β
β β
ββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββΌββββββββββββ
β β
β Shared Services β
β β
ββββββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββ
β β
β Database β
β β
ββββββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββ
β β
β Config β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
In this example:
module_aandmodule_bboth depend onshared_providershared_providerprovides the shared API integrationmodule_cis independent, doesn't use the shared provider- All modules depend on shared infrastructure (Shared Services, Database, Config)
- Dependencies must be acyclic - If A depends on B, B cannot depend on A (directly or transitively)
- Dependencies flow downward - Higher-level modules depend on lower-level modules
- Prefer independence - Keep modules independent when possible, but allow dependencies when it reduces duplication
- Infrastructure has no business logic - Only technical concerns
β
VALID (DAG - no cycles):
module_a βββΊ shared_provider βββΊ shared_services
module_b βββΊ shared_provider βββΊ shared_services
module_c βββΊ shared_services
β INVALID (cycle):
module_a βββΊ module_b
β² β
ββββββββββββββ
Create a shared module when:
- Multiple modules need the same external API integration
- Business logic is duplicated across modules
- A clear abstraction boundary exists
Keep modules separate when:
- They have different external providers
- Coupling would create unnecessary complexity
- Independence aids testing and deployment
Keep related functionality together within modules. A module should have a single, well-defined purpose.
Signs of good cohesion:
- All types in a module relate to the same domain concept
- Changes to a feature typically touch files within a single module
- Module name accurately describes everything inside it
Signs of poor cohesion:
- A module contains unrelated utilities "for convenience"
- Feature changes require edits across many modules
- Difficulty naming the module because it does too many things
Prefer explicit pub use statements over glob exports (pub use foo::*).
// β
GOOD - Explicit exports make API surface clear
pub use http::router;
pub use error::ModuleError;
pub use service::ModuleService;
// β AVOID - Glob exports hide what's public
pub use repository::*;Why explicit exports matter:
- Documents the module's public API in one place
- Prevents accidentally exposing internal types
- Makes breaking changes obvious during code review
- Enables IDE "find usages" to work accurately
Modules follow consistent file naming patterns:
| File | Purpose | Required |
|---|---|---|
mod.rs |
Module declaration and public exports | Yes |
http.rs |
Router factory and HTTP handlers | Yes |
service.rs |
Business logic and orchestration | Yes |
repository.rs |
Database queries | If persisting data |
error.rs |
Module-specific error types | Yes |
app_state.rs |
Axum handler state container | Yes |
Spawned at startup, runs continuously:
- Health-checks critical dependencies periodically
- Exits entire process if critical service becomes unresponsive
- Application cannot function without critical dependencies
Spawned by modules that need to poll external services:
- Polls external service for status changes
- Updates database when changes are detected
- Uses broadcast channel to notify waiting HTTP handlers
// module/http.rs
let syncer = BackgroundSyncer::new(service.clone(), api_client).await;
tokio::task::spawn(async move {
syncer.clone().run().await;
});Each module defines its own error type with conversions:
// module/error.rs
#[derive(Debug, thiserror::Error)]
pub enum ModuleError {
#[error("Resource not found")]
NotFound,
#[error("External API error: {0}")]
ExternalApi(#[from] ExternalApiError),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
}
// Converts to HTTP response
impl IntoResponse for ModuleError { ... }Error flow: ExternalAPIError β ModuleError β HTTP Response
| Test Type | Scope | Tools |
|---|---|---|
| Unit | Service logic | Mock external APIs |
| Integration | Repository + DB | #[sqlx::test] with real Postgres |
When adding a new feature module:
- Create module directory under
src/ - Add
mod.rswith public exports - Create
types.rswith domain types and entities - Create
error.rswith module-specific errors - Create
app_state.rswith AppState struct - Create
service.rswith business logic - Create
repository.rsif persisting data - Create
http.rswith router factory and handlers - Add
pub mod new_module;tomain.rs - Call
new_module::router()inHttpServer::start() - Nest router under appropriate path
- Write tests (use
migrations::test_db(pool)for test database setup)