Rust Axum Production Coding: E01 - Rust Web App Production Coding
Scale a prototype Rust Axum web application into a production-ready, enterprise-grade codebase across nine architectural chapters. This module covers production project layout, structured tracing, configuration management with environment variable separation, a robust PostgreSQL database evolution workflow, a decoupled model layer with repository pattern back-end model controllers (BMC), password hashing/salting, secure web tokens, and a clean JSON-RPC architecture.
1. Why Do We Need This? (The Prototype-to-Production Gap)
Many educational tutorials build toy web applications in a single, monolithic file (e.g., massive main.rs files, hard-coded configurations, unmanaged database states, global error types). In production, this approach collapses:
- The Monolithic Error Trap: A single error enum representing database, configuration, and web errors leads to tight coupling and poor error visibility.
- Hardcoded Environments & Secrets: Secrets and configurations embedded in source code violate security baselines and break containerized deployments (Docker/Kubernetes).
- Untestable Tight Coupling: Web handlers directly manipulating database connections make unit testing private business logic virtually impossible without spinning up bloated mock frameworks.
Note: This series bridges the gap by building a robust architecture that separates concerns from day one, laying down a scalable foundation for enterprise Rust microservices.
2. Core Mental Model: The Production Layered Architecture
The production architecture separates responsibilities into distinct vertical slices and horizontal layers. The dependency rule flows strictly inward or downward: Web Layer -> Context -> Model Layer -> Store/Database.
3. The 9-Chapter Production Roadmap
The project is structured into nine incremental engineering chapters, each corresponding to a specific architectural phase:
4. Chapter 1: The Production Baseline & Modularization
De-risking the Monolithic Error
Instead of a single application-wide error enum, each major module owns its distinct error type. Errors are bubbled up and mapped cleanly through explicit From trait implementations.
Web Module Error (web::Error)
^
| wraps via From trait
Model Module Error (model::Error)
^
| wraps via From trait
Store / DB Error (store::Error)
Module Structure
5. Chapter 2: Structured Tracing Configuration
To avoid cluttered standard println! debugging, we integrate tracing and tracing-subscriber.
Cargo Configuration for Environment Filters
Rather than hardcoding log levels, we configure RUST_LOG in .cargo/config.toml so container orchestrators (like Kubernetes config maps) can set them externally before the application boots:
[env]
RUST_LOG = "rust_web_app=debug,tower_http=debug"
Replacing Print Statements with Tracing
We replace temporary debug prints with structured tracing events:
use tracing::info;
// Replaced println!("->> {:<12} - server started", "HANDLER");
info!("-{:!<12} - server started", "HANDLER");
6. Chapter 3: The Configuration Layer (OnceLock)
Configurations must be loaded once at startup and made globally available without expensive runtime environment lookups.
Implementing a Thread-Safe Singleton Config
Using Rust 1.70+'s standard library OnceLock, we load environment variables and fail fast during application boot if configuration is missing:
use std::env;
use std::sync::OnceLock;
#[derive(Debug)]
pub struct Config {
pub web_folder: String,
}
static INSTANCE: OnceLock<Config> = OnceLock::new();
impl Config {
pub fn load_from_env() -> Result<Self> {
let web_folder = get_env("SERVICE_WEB_FOLDER")?;
Ok(Config { web_folder })
}
}
pub fn config() -> &'static Config {
INSTANCE.get_or_init(|| {
Config::load_from_env().unwrap_or_else(|err| {
panic!("FATAL - WHILE LOADING CONFIG - Cause: {err}");
})
})
}
7. Chapter 4: Database Infrastructure & Development Workflow
The Database Testing & Iteration Dilemma
When testing code that interacts with persistent stores, developers often choose between mocking (which tests code paths never executed in production) and real integration environments.
Real-World Analogy: Mocking a database is like practicing surgery on a rubber dummy; it builds confidence, but nothing prepares you for real anatomy. This project uses a Local Real Database (PostgreSQL) inside an iterative development workflow, ensuring unit tests hit real SQL constraints.
Brute-Force Local DB Recreation Scripts
Under sql/dev_initial/, we maintain SQL scripts to drop, recreate, and seed the local database on every watch cycle:
00_recreate_db.sql: Executed by the Postgres root user to drop and recreateapp_dbandapp_user.01_create_schema.sql: Sets up core tables (user,task) starting IDs at1000to reserve lower IDs for system/test fixtures.02_seed_dev.sql: Seeds initial development records.
8. Chapter 5: The Model Layer & Task BMC (Part 1)
The Model Layer normalizes all application data types and encapsulates database operations.
The Model Manager & Store Pattern
ModelManager: Holds shared state (database connection poolPool<Postgres>, S3 clients, Redis clients). Visibility is restricted (pub(in crate::model)) so only the model layer can access the raw store.Model Controllers (BMC):TaskBMC,UserBMCimplement CRUD operations for specific entities.
Struct Separation for Security
We decouple database entities from API payloads to prevent security oversights (e.g., clients resetting internal IDs or creation timestamps):
#[derive(Serialize, FromRow, Clone, Debug)]
pub struct Task {
pub id: i64,
pub title: String,
}
#[derive(Deserialize, Debug)]
pub struct TaskForCreate {
pub title: String,
}
#[derive(Deserialize, Debug)]
pub struct TaskForUpdate {
pub title: Option<String>,
}
9. Chapter 6: Shared Implementation via Traits (dbbmc) & SQL Builder
To eliminate repetitive CRUD boilerplate across entities (Task, User, etc.), we share implementations using traits and generics (sql-b).
The DbBmc Trait
pub trait DbBmc {
const TABLE: &'static str;
}
Generic Base CRUD Functions
Using generics, higher-ranked trait bounds (for<'r> FromRow<'r, PgRow>), and sql-b (SQL Builder), we write generic base operations (base::get, base::list, base::create, base::update, base::delete) that operate on any entity implementing DbBmc.
pub async fn get<MC, E>(ctx: &Ctx, mm: &ModelManager, id: i64) -> Result<E>
where
MC: DbBmc,
E: for<'r> FromRow<'r, PgRow> + Unpin + Send,
{
let db = mm.db();
let fields = E::field_names(); // Optimized select via sql-b
let sql = format!("SELECT {} FROM {} WHERE id = $1", fields, MC::TABLE);
// Execute via sqlx...
}
10. Chapter 7: Password Encryption & User Login
Cryptographic Hashing Workflow
- User submits cleartext credentials (
username,password) over HTTPS (or local HTTP). UserBMCfetches the user record (UserForLogin), retrieving their unique UUID salt and stored encrypted password.- The password module hashes the cleartext password using HMAC-SHA512 combined with the user's salt and an application-level secret key.
- A versioned scheme prefix (e.g.,
01$) is prepended to the hash for future-proof algorithm migration.
11. Chapter 8: Secure Web Tokens (JWT-like) & Auth Middleware
Token Structure & Portability
Our custom signed token consists of three base64url-encoded parts separated by dots:
[Identifier (Username)] . [Expiration Timestamp (ISO 3339)] . [Signature (HMAC-SHA512)]
Middleware Flow (CtxResolve & CtxRequire)
12. Chapter 9: JSON-RPC Architecture for Task CRUD
Instead of traditional REST endpoints (POST /tasks, GET /tasks/1), we implement a clean JSON-RPC 2.0 architecture over a single HTTP POST endpoint (/api/rpc).
JSON-RPC Payload Structure
{
"id": 1,
"method": "create_task",
"params": {
"data": {
"title": "Learn Production Rust"
}
}
}
Declarative Macros for RPC Routing
To eliminate boilerplate pattern-matching when dispatching RPC methods, we use declarative macros (macro_rules!) to handle requests with or without parameters:
macro_rules! exec_rpc_fn {
($rpc_fn:ident, $ctx:expr, $mm:expr) => {
$rpc_fn($ctx, $mm).await.map(to_json_value)?
};
($rpc_fn:ident, $ctx:expr, $mm:expr, $params:expr) => {
$rpc_fn($ctx, $mm, $params).await.map(to_json_value)?
};
}
Summary and Key Takeaways
- Decoupled Error Architecture: Every module (
model,store,web,crypt) owns its error type, preventing leaky abstractions and bubbling up cleanly viaFromimplementations. - Environment Configuration: Leveraging
OnceLockfor global configuration ensures early failure on missing environment variables before server startup. - Rapid Dev Iteration: Combining automated local PostgreSQL database recreation (
00_recreate_db.sql,01_create_schema.sql) with Cargo watch loops provides instant developer feedback. - Model Layer Decoupling: Business logic and security rules reside in the model layer via Back-End Model Controllers (
BMC), completely independent of the underlying web framework (Axum). - Shared CRUD via Traits: Generic base controllers using
DbBmcandsql-beliminate repetitive boilerplate while preserving type safety and compile-time guarantees. - Robust Cryptography: Secure password encryption and token generation use versioned scheme prefixes (
01$) for seamless future cryptographic upgrades. - Sliding Cookie Authentication: HTTP-only session cookies are automatically validated and refreshed on incoming requests via the infallible
CtxResolvemiddleware. - JSON-RPC 2.0 Protocol: Consolidating API traffic over a single POST endpoint (
/api/rpc) governed by procedural and declarative macros streamlines routing and request logging.