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)