Production Patterns

Crate layout, config and tracing, API errors with serde, clippy in CI, and a shutdown that finishes work already in flight.

Production patterns

This is the last lesson, and it only uses tools you already have. The order is the order you meet them in a service: where code lives, how it is configured, how a request fails in JSON, how you know it is fast enough, and how it stops.

Layout

A binary crate should be thin. Domain code does not import a web framework or a database driver. Infrastructure implements ports the domain defined. The dependency arrow points inward: the API and the database adapters depend on the domain, and the domain depends on neither.

apiHTTPdomainno frameworkinfradb, clients
StatusThe domain is the center

discount_cents is domain: integers, no sockets. main only calls it. That split is what the module lesson’s privacy was for.

01_project_layout.rsRust
fn discount_cents(price: u32, percent: u32) -> u32 {
    price.saturating_sub(price.saturating_mul(percent) / 100)
}

fn main() {
    println!("{}", discount_cents(2_000, 10));
}

Config and tracing

Read configuration at startup and fail the process if it is missing or invalid. That is expect or ? at main, the policy from error handling, not a default that hides a bad deploy. Log lines are events. A span is the region around a request. Do not log secrets, tokens, or full card numbers. A health check answers whether the process should receive traffic. It is not a place to take a new dependency at request time.

LevelUse it for
debugInvestigation. Leave it off in production until you need it.
infoNormal traffic
warnA retry
errorA failure the caller sees
request spantracingeventsno secrets
StatusThe span is the request

Child events inherit the request id. A plain log line without a span is harder to join later.

02_config_logging_tracing.rsRust
struct AppConfig {
    port: u16,
}

impl AppConfig {
    fn from_env_like(port: Option<&str>) -> Result<Self, String> {
        let raw = port.ok_or_else(|| String::from("PORT missing"))?;
        let port = raw.parse().map_err(|_| String::from("PORT invalid"))?;
        Ok(Self { port })
    }
}

fn main() {
    let cfg = AppConfig::from_env_like(Some("8080")).unwrap();
    println!("{}", cfg.port);
}

JSON errors

Serde turns a struct into JSON and back. A request body becomes a type, and invalid JSON is a Result, not a panic. Validate by parsing into a type that cannot represent the bad state, the same enum and newtype habit. The HTTP error is a small JSON object with a code and a message, not the Debug of an internal error. Idempotency means a retried request does not apply a payment twice. The handler checks a key before the domain runs.

bodybytesCreateUserparseddomainOkerror JSONErr
StatusParse before you work

A missing @ never becomes a user. The type is the validation.

03_api_error_json_serde.rsRust
struct CreateUser {
    email: String,
}

fn parse_user(email: &str) -> Result<CreateUser, &'static str> {
    if email.contains('@') {
        Ok(CreateUser { email: email.to_string() })
    } else {
        Err("invalid email")
    }
}

fn main() {
    match parse_user("a@b.com") {
        Ok(user) => println!("{}", user.email),
        Err(err) => eprintln!("{err}"),
    }
}

Performance and CI

Measure before you rewrite. The levers you already know are capacity on a Vec, borrowing instead of clone, and fewer allocations in a hot loop. clippy with warnings denied, rustfmt, and cargo test are the gate. A release profile and a stated minimum compiler version belong in that same gate. count_bytes sums lengths. It does not build an intermediate vector of copies.

fmtstyleclippywarnings deniedtestthen release
StatusThe gate is ordered

Format first so the diff is about behavior. Lint next. Tests last, including the ones that do not touch the clock.

04_performance_clippy_ci.rsRust
fn count_bytes(chunks: &[&[u8]]) -> usize {
    chunks.iter().map(|c| c.len()).sum()
}

fn main() {
    let parts: [&[u8]; 2] = [b"ab", b"cdef"];
    println!("{}", count_bytes(&parts));
}

Shutdown

Stop accepting work, let in-flight work finish, then exit. A flag the workers load is enough to show the two phases. Timeouts, retries, and a circuit breaker sit on the client calls you already return as Result. A retry needs an idempotency key or it will double-charge. A rate limit is a bound, the same idea as a bounded channel.

runningaccept workdrainingfinish in flightstoppedexit
StatusThe flag flips before the process dies

New requests are refused. Workers already holding a job finish it. Drop still runs, so files and locks close.

05_graceful_shutdown_and_resilience.rsRust
use std::sync::atomic::{AtomicBool, Ordering};

fn main() {
    let running = AtomicBool::new(true);
    running.store(false, Ordering::SeqCst);
    while running.load(Ordering::SeqCst) {
        // worker would serve here, then notice the flag and drain
    }
    println!("drained");
}