Layered Architecture: Rust API with Axum, SQLx
🇫🇷FREntities, store, engine, routes: how to structure a Rust API in strict layers with Axum, SQLx, and utoipa. Code examples from IronFlow in production.
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<T>
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<T: Clone + Copy> {
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<RunStatus>,
pub trigger: TriggerKind,
pub payload: Value,
pub error: Option<String>,
pub retry_count: u32,
pub max_retries: u32,
pub cost_usd: Decimal,
pub duration_ms: u64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub labels: HashMap<String, String>,
pub scheduled_at: Option<DateTime<Utc>>,
pub created_by: Option<RunActor>,
}
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<'_, RunCreation>;
fn get_run(&self, id: Uuid) -> StoreFuture<'_, Option<Run>>;
fn list_runs(
&self, filter: RunFilter, page: u32, per_page: u32
) -> StoreFuture<'_, Page<Run>>;
fn update_run_status(
&self, id: Uuid, new_status: RunStatus
) -> StoreFuture<'_, ()>;
fn pick_next_pending(
&self, lease: Option<LeaseRequest>
) -> StoreFuture<'_, Option<Run>>;
fn reap_expired_leases(
&self, limit: u32
) -> StoreFuture<'_, Vec<ReapedRun>>;
// ... create_step, update_step, list_steps, get_stats, delete_run
}
StoreFuture<'a, T> is a Pin<Box<dyn Future<Output = Result<T, StoreError>> + Send + 'a>> - needed for object safety so the store can be used as Arc<dyn RunStore>.
The Store trait unifies all capabilities:
pub trait Store:
RunStore + UserStore + ApiKeyStore + SecretStore
+ AuditLogStore + ArtifactStore + LogStore
{}
impl<T: RunStore + UserStore + ApiKeyStore + SecretStore
+ AuditLogStore + ArtifactStore + LogStore> 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<LeaseRequest>
) -> StoreFuture<'_, Option<Run>> {
Box::pin(async move {
// SELECT FOR UPDATE SKIP LOCKED inside a transaction
// Atomic transition Pending -> Running
})
}
}
The in-memory implementation uses an RwLock and a sorted Vec. Same API, same behavior, zero PostgreSQL required for tests:
let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
let run = store.create_run(NewRun {
workflow_name: "deploy".to_string(),
trigger: TriggerKind::Manual,
payload: json!({}),
max_retries: 3,
// ...
}).await?.into_run();
The ironflow-api crate tests use InMemoryStore. No Docker, no migrations, no cleanup between tests. The PostgresStore has its own integration tests.
Store error handling
Store errors are storage errors, not HTTP errors:
#[derive(Debug, Error)]
pub enum StoreError {
#[error("run not found: {0}")]
RunNotFound(Uuid),
#[error("step not found: {0}")]
StepNotFound(Uuid),
#[error("invalid status transition: {from} -> {to}")]
InvalidTransition { from: RunStatus, to: RunStatus },
#[error("lease lost on run {run_id}")]
LeaseLost { run_id: Uuid, held_by: Option<String> },
#[error("artifact {name:?} already exists on step {step_id}")]
DuplicateArtifact { step_id: Uuid, name: String },
#[error("database error: {0}")]
Database(String),
}
The conversion to HTTP status codes happens in the API crate, not here. The store has no concept of a StatusCode.
The API layer: Axum handlers and DTOs
The ironflow-api crate assembles everything. It contains Axum handlers, response DTOs (separate from store entities), middleware, and OpenAPI documentation.
DTOs separate from entities
The API crate defines its own response types in src/entities/:
// ironflow-api/src/entities/run.rs - API DTO
#[derive(Debug, Serialize, Deserialize)]
pub struct RunResponse {
pub id: Uuid,
pub workflow_name: String,
pub status: RunStatus,
pub trigger: TriggerKind,
pub error: Option<String>,
pub cost_usd: Decimal,
pub duration_ms: u64,
pub created_at: DateTime<Utc>,
pub created_by: Option<CreatedBy>,
// ...
}
impl From<Run> for RunResponse {
fn from(run: Run) -> Self {
// Explicit conversion, controls what goes out
}
}
RunResponse is the public contract. The store’s Run is the internal model. The From<Run> conversion is the control point: you choose what is exposed and how.
A typical handler
#[utoipa::path(
get,
path = "/api/v1/runs/{id}",
tags = ["runs"],
params(("id" = Uuid, Path, description = "Run ID")),
responses(
(status = 200, body = RunDetailResponse),
(status = 401),
(status = 404),
),
security(("Bearer" = []))
)]
pub async fn get_run(
_auth: Authenticated,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
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 (
Authenticatedextractor) - 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<dyn Store>,
pub engine: Arc<Engine>,
pub jwt_config: Arc<JwtConfig>,
pub worker_token: String,
}
The Arc<dyn Store> 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<dyn Store> 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<Run> 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<Box<dyn Future>> 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.