DEV Community

Thomas Tartrau
Thomas Tartrau

Posted on Originally published at tartrau.fr on

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)
Enter fullscreen mode Exit fullscreen mode

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
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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),
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

The handler does 3 things:

  1. Verify authentication (Authenticated extractor)
  2. Fetch data in parallel via tokio::join!
  3. 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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)