Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save SeverinAlexB/5dcfb852b8be302e30c1ddc5a3961595 to your computer and use it in GitHub Desktop.

Select an option

Save SeverinAlexB/5dcfb852b8be302e30c1ddc5a3961595 to your computer and use it in GitHub Desktop.
Rust HTTP Postgres Software Architecture

Layered Modular Architecture

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.

Architecture Overview

┌────────────────────────────────────────────────────────────────┐
│                        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)                     │
└────────────────────────────────────────────────────────────────┘

Key Characteristics

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

Startup Flow

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

Resource Sharing

Database (SqlDb)

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.

Configuration (EnvConfig)

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 reference

Shared API Clients

Each 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.

Directory Structure

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

Module Structure

Each module follows the same internal pattern:

Layer Flow

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 Responsibilities

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

Router Factory Pattern

Each module exposes a router() function that:

  1. Receives shared infrastructure (config, db)
  2. Creates module-specific dependencies internally
  3. Wires up AppState
  4. 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())

Dependency Rules

Acyclic Directed Graph (DAG)

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_a and module_b both depend on shared_provider
  • shared_provider provides the shared API integration
  • module_c is independent, doesn't use the shared provider
  • All modules depend on shared infrastructure (Shared Services, Database, Config)

Rules

  1. Dependencies must be acyclic - If A depends on B, B cannot depend on A (directly or transitively)
  2. Dependencies flow downward - Higher-level modules depend on lower-level modules
  3. Prefer independence - Keep modules independent when possible, but allow dependencies when it reduces duplication
  4. Infrastructure has no business logic - Only technical concerns

Valid vs Invalid Dependencies

✅ 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
    ▲            │
    └────────────┘

When to Create a Shared Module

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

Cohesion

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

Export Visibility

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

File Naming Conventions

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

Background Tasks

Health Monitors

Spawned at startup, runs continuously:

  • Health-checks critical dependencies periodically
  • Exits entire process if critical service becomes unresponsive
  • Application cannot function without critical dependencies

Polling Tasks

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;
});

Error Handling

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

Testing Strategy

Test Type Scope Tools
Unit Service logic Mock external APIs
Integration Repository + DB #[sqlx::test] with real Postgres

Checklist for New Modules

When adding a new feature module:

  • Create module directory under src/
  • Add mod.rs with public exports
  • Create types.rs with domain types and entities
  • Create error.rs with module-specific errors
  • Create app_state.rs with AppState struct
  • Create service.rs with business logic
  • Create repository.rs if persisting data
  • Create http.rs with router factory and handlers
  • Add pub mod new_module; to main.rs
  • Call new_module::router() in HttpServer::start()
  • Nest router under appropriate path
  • Write tests (use migrations::test_db(pool) for test database setup)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment