Layered Architecture: Rust API with Axum, SQLx
Most Rust/Axum APIs I see on GitHub put everything in the same file: the handler extracts parameters, runs the SQL query, applies business logic, and returns JSON. That works for a demo project. On a production API with 30+ endpoints, state machines, SSE, JWT auth, and a worker lease system, it falls apart. I structured IronFlow - a workflow engine where workflows are Rust code (see why I chose Rust for this project) - into strict layers spread across a 20-crate Cargo workspace. Dependencies only go in one direction: downward. A REST handler cannot call a SQL query directly, and an entity has no idea Axum exists. This article shows this architecture with real code, the decisions that worked, and the ones I would reconsider. The workspace structure The Cargo workspace contains 20 crates. The main layers are: ironflow-store/ # Entities + data access (traits + implementations) src/entities/ # Structs, enums, FSM src/postgres/ # PostgreSQL implementation (SQLx) src/memory/ # In-memory implementation (tests/dev) ironflow-engine/ # Orchestration, execution, events ironflow-api/ # Axum handlers, DTOs, middleware, OpenAPI src/entities/ # API DTOs (separate from store entities) src/routes/ # Handlers by domain ironflow-core/ # AI providers, shell/HTTP/agent operations ironflow-auth/ # JWT, authentication extractors ironflow-types/ # Shared types (JSON envelopes) Each layer is a separate crate in the workspace. The ironflow-api crate cannot access sqlx directly: it goes through the Store trait defined in ironflow-store. Entities: the domain without a framework The entities layer lives in ironflow-store/src/entities/. It defines domain types without depending on Axum or SQLx for queries. One file per concept: run.rs, step.rs, run_status.rs, step_status.rs, trigger_kind.rs. The FSM in the type system The core of IronFlow is a finite state machine (FSM) that manages the lifecycle of each run. Valid transitions are defined in an exhaustive matches!: #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RunStatus { Pending, Running, Completed, Failed, Retrying, Cancelled, AwaitingApproval, Warning, } impl RunStatus { pub fn can_transition_to(&self, target: &RunStatus) -> bool { if self == target && self.is_terminal() { return true; // idempotent } matches!( (self, target), (RunStatus::Pending, RunStatus::Running) | (RunStatus::Pending, RunStatus::Cancelled) | (RunStatus::Running, RunStatus::Pending) // lease expired | (RunStatus::Running, RunStatus::Completed) | (RunStatus::Running, RunStatus::Failed) | (RunStatus::Running, RunStatus::Warning) | (RunStatus::Running, RunStatus::Retrying) | (RunStatus::Running, RunStatus::Cancelled) | (RunStatus::Running, RunStatus::AwaitingApproval) | (RunStatus::Retrying, RunStatus::Running) | (RunStatus::Retrying, RunStatus::Failed) | (RunStatus::Retrying, RunStatus::Cancelled) | (RunStatus::AwaitingApproval, RunStatus::Running) | (RunStatus::AwaitingApproval, RunStatus::Failed) | (RunStatus::AwaitingApproval, RunStatus::Cancelled) ) } pub fn is_terminal(&self) -> bool { matches!( self, RunStatus::Completed | RunStatus::Failed | RunStatus::Warning | RunStatus::Cancelled ) } } The matches! macro makes the transition table readable at a glance. Adding a transition means adding a line. Removing a state triggers a compiler warning everywhere it is used. An important detail: terminal-to-same-terminal transitions are idempotent. A run that is already Failed receiving Failed is not an error. This simplifies concurrency scenarios between workers. The generic FsmState For SQL-side transitions (via the lib_fsm library), IronFlow wraps the status with the state machine ID: #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct FsmState { pub state: T, pub state_machine_id: Uuid, } Handlers pattern-match on run.status.state, while SQL queries use run.status.state_machine_id for atomic transitions. A single type carries both pieces of information. The Run entity #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Run { pub id: Uuid, pub workflow_name: String, pub status: FsmState, pub trigger: TriggerKind, pub payload: Value, pub error: Option, pub retry_count: u32, pub max_retries: u32, pub cost_usd: Decimal, pub duration_ms: u64, pub created_at: DateTime, pub updated_at: DateTime, pub started_at: Option, pub completed_at: Option, pub labels: HashMap, pub scheduled_at: Option, pub created_by: Option, } The Run is the store's internal model. The API never exposes it directly - it uses a RunResponse (DTO) that controls what goes out. IDs are UUID v7 (chronologically sorted, good for B-tree index performance). The store: traits and two implementations The store layer defines async traits for data access, with two implementations: PostgresStore for production and InMemoryStore for tests. The RunStore trait pub trait RunStore: Send + Sync { fn create_run(&self, req: NewRun) -> StoreFuture; fn list_runs( &self, filter: RunFilter, page: u32, per_page: u32 ) -> StoreFuture; fn pick_next_pending( &self, lease: Option ) -> StoreFuture; // ... create_step, update_step, list_steps, get_stats, delete_run } StoreFuture> - needed for object safety so the store can be used as Arc. The Store trait unifies all capabilities: pub trait Store: RunStore + UserStore + ApiKeyStore + SecretStore + AuditLogStore + ArtifactStore + LogStore {} impl Store for T {} The blanket impl means any type that implements all 7 sub-traits is automatically a Store. Both InMemoryStore and PostgresStore implement all 7. Two interchangeable backends The PostgreSQL implementation uses SELECT FOR UPDATE SKIP LOCKED for concurrent run picking: impl RunStore for PostgresStore { fn pick_next_pending( &self, lease: Option ) -> StoreFuture Result { let run = state.get_run_or_404(id).await?; let (steps, deps, artifacts) = join!( state.store.list_steps(id), state.store.list_step_dependencies(id), state.store.list_artifacts_for_run(id) ); let response = RunDetailResponse { run: RunResponse::from(run), steps: steps?.into_iter().map(StepResponse::from).collect(), // ... }; Ok(ok(response)) } The handler does 3 things: Verify authentication (Authenticated extractor) Fetch data in parallel via tokio::join! Convert to DTOs and return The ? converts StoreError to ApiError via the From impl. Tracing is automatic. OpenAPI docs are generated by #[utoipa::path]. Error conversion StoreError converts automatically to ApiError via #[from]: #[derive(Debug, Error)] pub enum ApiError { #[error("run not found")] RunNotFound(Uuid), #[error("authentication required")] Unauthorized, #[error("invalid credentials")] InvalidCredentials, #[error("{0}")] Conflict(String), #[error("database error")] Store(#[from] StoreError), // ... } impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self { ApiError::RunNotFound(_) => StatusCode::NOT_FOUND, ApiError::Unauthorized => StatusCode::UNAUTHORIZED, ApiError::Store(StoreError::LeaseLost { .. }) => StatusCode::CONFLICT, ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR, // ... }; let envelope = ErrorEnvelope { code: self.code().to_string(), message: self.to_string(), }; (status, Json(json!({ "error": envelope }))).into_response() } } Each StoreError is translated to a precise HTTP code. A LeaseLost is a 409 Conflict (the client can retry), not a 500. A RunNotFound is a 404. The store does not decide the HTTP code, the API does. Router assembly pub fn create_router(state: AppState, config: RouterConfig) -> Router { let internal_routes = Router::new() .route("/runs/next", get(pick_next_run)) .route("/runs/{id}/status", put(update_run_status)) .route("/runs/{id}/lease", post(renew_lease)) .layer(from_fn(worker_token_auth)); let api_v1 = Router::new() .route("/runs", get(list_runs).post(create_run)) .route("/runs/{id}", get(get_run)) .route("/runs/{id}/cancel", post(cancel_run)) .route("/runs/{id}/approve", post(approve_run)) .route("/workflows", get(list_workflows)) .route("/stats", get(get_stats)) .route("/events", get(events)); Router::new() .nest("/api/v1/internal", internal_routes) .nest("/api/v1", api_v1) .layer(RequestBodyLimitLayer::new(2 * 1024 * 1024)) .layer(from_fn(security_headers)) } Two separate route groups: internal routes (worker-to-API, protected by a dedicated token) and public routes (JWT authentication). Internal routes use worker_token_auth, public routes use Authenticated. Both go through the same AppState. AppState: dependency injection #[derive(Clone)] pub struct AppState { pub store: Arc, pub engine: Arc, pub jwt_config: Arc, pub worker_token: String, } The Arc is the injection point. In production, it is a PostgresStore. In tests, it is an InMemoryStore. The handler does not know which one it uses. Tests: the concrete benefit of the architecture The get_run handler tests illustrate the benefit of traits: #[tokio::test] async fn existing_run() { let store = Arc::new(InMemoryStore::new()); let run = store.create_run(NewRun { workflow_name: "test".to_string(), trigger: TriggerKind::Manual, payload: json!({}), max_retries: 3, // ... }).await.unwrap().into_run(); let state = test_state_with_store(store); let app = Router::new() .route("/{id}", get(get_run)) .with_state(state); let resp = app.oneshot( Request::get(format!("/{}", run.id)) .header("authorization", auth_header) .body(Body::empty()).unwrap() ).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } No Docker, no database, no migrations. The test instantiates an InMemoryStore, creates a run, and verifies the handler returns 200. Execution takes a few milliseconds. What works well Store traits. Two interchangeable implementations (PostgresStore and InMemoryStore) simplify testing and allow starting the project without a database. The Arc in AppState makes injection transparent. DTOs separate from entities. The API's RunResponse and the store's Run are distinct types. Modifying the internal model does not break the API contract. The From conversion is the single control point. End-to-end typed errors. From StoreError to ApiError, every conversion is explicit. The matches! in IntoResponse documents the error-to-HTTP-code mapping. No hidden .unwrap(). Generated OpenAPI documentation. utoipa annotates handlers and types. The spec is always in sync with the code. The embedded dashboard consumes this spec directly. What I would change The missing service layer. Today, business logic lives in handlers (for simple cases) or in the engine (for orchestration). An explicit ironflow-services crate with status transition logic, cost limit validation, and event construction would make handlers thinner and tests more targeted. Boxed StoreFutures. The Pin is needed for object safety of dyn RunStore, but adds one allocation per call. For non-dynamic usage (when the concrete type is known), direct async methods would be more performant. This is the classic flexibility vs. performance tradeoff. The numbers The IronFlow workspace: Metric Value Crates in the workspace 20 REST endpoints (public + internal) 30+ Lines of Rust code ~25,000 Unit tests 150+ Store backends 2 (PostgreSQL + in-memory) Supported AI providers 10 The project is open source on GitLab. The release profile uses lto = true, strip = true, codegen-units = 1, and panic = "abort". The resulting binary is compact and starts in under a second. Conclusion Layered architecture is not an invention. It is a classic pattern from Java/C#/.NET. What is specific to Rust is that the type system and Cargo workspaces make this separation enforced by the compiler, not by convention. A handler that tries to import sqlx directly will not compile if the ironflow-api crate does not list it in its dependencies. The key point of IronFlow: store traits with two implementations. PostgresStore for production, InMemoryStore for tests. This is what makes handler tests fast and reliable without external infrastructure. The cost is real: more crates, more From impls, more boilerplate for error conversions. But on a project with concurrent workers, leases, and state machines, the maintainability gains easily justify the investment. If you are starting a Rust API with Axum, begin by separating entities from the rest. Add a store trait when you want to test without a database. Add DTOs when the internal model diverges from the API contract. And split into crates when compilation times or responsibility boundaries justify it. To see how this architecture supports real use cases, read how IronFlow orchestrates AI agents for automated code review.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to