Command Palette

Search for a command to run...

Architecture en couches : API Rust, Axum et SQLx

🇬🇧EN

Entities, store, engine, routes : structurer une API Rust en couches strictes avec Axum, SQLx et utoipa. Exemples concrets tirés d'IronFlow en production.

30 min de lecture
rustaxumsqlxarchitectureapi-restutoipaironflow
Schéma de l'architecture en 4 couches d'une API Rust avec Axum et SQLx : REST, store, engine, entities

La plupart des API Rust/Axum que je vois sur GitHub mettent tout dans le même fichier : le handler extrait les paramètres, exécute la requête SQL, applique la logique métier, et renvoie le JSON. Ça fonctionne pour un projet de démo. Sur une API de production avec 30+ endpoints, des machines à états, du SSE, de l’auth JWT et un système de leases pour les workers, ça ne tient pas.

J’ai structuré IronFlow - un moteur de workflows où les workflows sont du code Rust (voir pourquoi j’ai choisi Rust pour ce projet) - en couches strictes réparties dans un workspace Cargo de 20 crates. Les dépendances ne vont que dans un sens : vers le bas. Un handler REST ne peut pas appeler une requête SQL directement, et une entity n’a aucune idée de l’existence d’Axum.

Cet article montre cette architecture avec le code réel, les choix qui ont fonctionné, et ceux que je referais différemment.

La structure du workspace

Le workspace Cargo contient 20 crates. Les couches principales sont :

ironflow-store/         # Entities + accès données (traits + implémentations)
  src/entities/         # Structs, enums, FSM
  src/postgres/         # Implémentation PostgreSQL (SQLx)
  src/memory/           # Implémentation in-memory (tests/dev)
ironflow-engine/        # Orchestration, exécution, événements
ironflow-api/           # Handlers Axum, DTOs, middleware, OpenAPI
  src/entities/         # DTOs de l'API (séparés des entities du store)
  src/routes/           # Handlers par domaine
ironflow-core/          # Providers IA, opérations shell/HTTP/agent
ironflow-auth/          # JWT, extracteurs d'authentification
ironflow-types/         # Types partagés (enveloppes JSON)

Chaque couche est un crate séparé dans le workspace. Le crate ironflow-api ne peut pas accéder directement à sqlx : il passe par le trait Store défini dans ironflow-store.

InterfacesCLISDKMCPDashboardAPIironflow-api (Axum + auth + OpenAPI)Moteurironflow-engineworkerruntimeStockageironflow-storeironflow-coreironflow-authCLI/SDK/MCP -> API (persistance) -> Worker (execution) -> Providers

Les entities : le domaine sans framework

La couche entities vit dans ironflow-store/src/entities/. Elle définit les types du domaine sans dépendre d’Axum ni de SQLx pour ses requêtes. Un fichier par concept : run.rs, step.rs, run_status.rs, step_status.rs, trigger_kind.rs.

La FSM dans le système de types

Le coeur d’IronFlow est une machine à états finis (FSM) qui gère le cycle de vie de chaque run. Les transitions valides sont définies dans un matches! exhaustif :

#[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
        )
    }
}

Le matches! rend la table de transitions lisible en un coup d’oeil. Ajouter une transition, c’est ajouter une ligne. Supprimer un état, c’est un warning du compilateur partout où il est utilisé.

Un détail important : les transitions terminal-vers-même-terminal sont idempotentes. Un run déjà Failed qui reçoit Failed n’est pas une erreur. Ça simplifie les cas de concurrence entre workers.

Le FsmState<T> générique

Pour les transitions côté SQL (via la bibliothèque lib_fsm), IronFlow encapsule le statut avec l’ID de la machine à états :

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct FsmState<T: Clone + Copy> {
    pub state: T,
    pub state_machine_id: Uuid,
}

Les handlers pattern-matchent sur run.status.state, et les requêtes SQL utilisent run.status.state_machine_id pour les transitions atomiques. Un seul type porte les deux informations.

L’entity Run

#[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>,
}

Le Run est le modèle interne du store. L’API ne l’expose jamais directement - elle utilise un RunResponse (DTO) qui contrôle ce qui sort. Les IDs sont des UUID v7 (triés chronologiquement, bons pour les index B-tree).

Le store : traits et deux implémentations

La couche store définit des traits async pour l’accès aux données, avec deux implémentations : PostgresStore pour la production et InMemoryStore pour les tests.

Le trait RunStore

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> est un Pin<Box<dyn Future<Output = Result<T, StoreError>> + Send + 'a>> - nécessaire pour maintenir l’object safety et pouvoir utiliser Arc<dyn RunStore>.

Le trait Store unifie toutes les capacités :

pub trait Store:
    RunStore + UserStore + ApiKeyStore + SecretStore
    + AuditLogStore + ArtifactStore + LogStore
{}

impl<T: RunStore + UserStore + ApiKeyStore + SecretStore
    + AuditLogStore + ArtifactStore + LogStore> Store for T {}

Le blanket impl signifie que tout type qui implémente les 7 sous-traits est automatiquement un Store. InMemoryStore et PostgresStore implémentent chacun les 7.

Deux backends interchangeables

L’implémentation PostgreSQL utilise SELECT FOR UPDATE SKIP LOCKED pour le picking concurrentiel de runs :

impl RunStore for PostgresStore {
    fn pick_next_pending(
        &self, lease: Option<LeaseRequest>
    ) -> StoreFuture<'_, Option<Run>> {
        Box::pin(async move {
            // SELECT FOR UPDATE SKIP LOCKED dans une transaction
            // Transition atomique Pending -> Running
        })
    }
}

L’implémentation in-memory utilise un RwLock et un Vec trié. Même API, même comportement, zéro PostgreSQL nécessaire pour les 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();

Les tests du crate ironflow-api utilisent InMemoryStore. Pas de Docker, pas de migrations, pas de cleanup entre les tests. Le PostgresStore a ses propres tests d’intégration.

La gestion d’erreur du store

Les erreurs du store sont des erreurs de stockage, pas des erreurs HTTP :

#[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),
}

La conversion vers les codes HTTP se fait dans le crate API, pas ici. Le store ne sait pas ce qu’est un StatusCode.

La couche API : handlers Axum et DTOs

Le crate ironflow-api assemble tout. Il contient les handlers Axum, les DTOs de réponse (séparés des entities du store), les middlewares, et la documentation OpenAPI.

Des DTOs séparés des entities

Le crate API définit ses propres types de réponse dans src/entities/ :

// ironflow-api/src/entities/run.rs - DTO de l'API
#[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 {
        // Conversion explicite, contrôle ce qui sort
    }
}

Le RunResponse est le contrat public. Le Run du store est le modèle interne. La conversion From<Run> est le point de contrôle : on choisit ce qui est exposé et comment.

Un handler typique

#[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))
}

Le handler fait 3 choses :

  1. Vérifier l’authentification (Authenticated extractor)
  2. Récupérer les données en parallèle via tokio::join!
  3. Convertir en DTOs et retourner

Le ? convertit les StoreError en ApiError via le From impl. Le tracing est automatique. La doc OpenAPI est générée par #[utoipa::path].

La conversion d’erreurs

Les StoreError se convertissent automatiquement en 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()
    }
}

Chaque StoreError est traduit en un code HTTP précis. Un LeaseLost est un 409 Conflict (le client peut retry), pas un 500. Un RunNotFound est un 404. Le store ne décide pas du code HTTP, l’API le fait.

L’assemblage du router

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))
}

Deux groupes de routes séparés : les routes internes (worker-to-API, protégées par un token dédié) et les routes publiques (authentification JWT). Les routes internes utilisent worker_token_auth, les routes publiques utilisent Authenticated. Les deux passent par le même AppState.

L’AppState : injection de dépendances

#[derive(Clone)]
pub struct AppState {
    pub store: Arc<dyn Store>,
    pub engine: Arc<Engine>,
    pub jwt_config: Arc<JwtConfig>,
    pub worker_token: String,
}

Le Arc<dyn Store> est le point d’injection. En production, c’est un PostgresStore. En test, c’est un InMemoryStore. Le handler ne sait pas lequel il utilise.

Les tests : le bénéfice concret de l’architecture

Les tests du handler get_run illustrent le bénéfice des 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);
}

Pas de Docker, pas de base de données, pas de migrations. Le test instancie un InMemoryStore, crée un run, et vérifie que le handler retourne 200. L’exécution prend quelques millisecondes.

Ce qui fonctionne bien

Les traits pour le store. Deux implémentations interchangeables (PostgresStore et InMemoryStore) simplifient les tests et permettent de démarrer le projet sans base de données. Le Arc<dyn Store> dans AppState rend l’injection transparente.

Les DTOs séparés des entities. Le RunResponse de l’API et le Run du store sont des types distincts. Modifier le modèle interne ne casse pas le contrat API. La conversion From<Run> est le point de contrôle unique.

Les erreurs typées de bout en bout. Du StoreError au ApiError, chaque conversion est explicite. Le matches! dans IntoResponse documente la correspondance erreur/code HTTP. Pas de .unwrap() caché.

La documentation OpenAPI générée. utoipa annote les handlers et les types. La spec est toujours synchronisée avec le code. Le dashboard embarqué consomme cette spec directement.

Ce que je changerais

La couche service manquante. Aujourd’hui, la logique métier vit dans les handlers (pour les cas simples) ou dans l’engine (pour l’orchestration). Un crate ironflow-services explicite avec la logique de transition de statuts, de validation des limites de coût, et de construction d’événements rendrait les handlers plus fins et les tests plus ciblés.

Les StoreFuture boxées. Le Pin<Box<dyn Future>> est nécessaire pour l’object safety de dyn RunStore, mais ajoute une allocation par appel. Pour un usage non-dynamique (quand le type concret est connu), des méthodes async directes seraient plus performantes. C’est le tradeoff classique entre flexibilité et performance.

Les chiffres

Le workspace IronFlow :

MétriqueValeur
Crates dans le workspace20
Endpoints REST (public + interne)30+
Lignes de code Rust~25 000
Tests unitaires150+
Backends de store2 (PostgreSQL + in-memory)
Providers IA supportés10

Le projet est open source sur GitLab. Le profil release utilise lto = true, strip = true, codegen-units = 1 et panic = "abort". Le binaire résultant est compact et démarre en moins d’une seconde.

Conclusion

L’architecture en couches n’est pas une invention. C’est un pattern classique en Java/C#/.NET. Ce qui est spécifique à Rust, c’est que le système de types et les workspace Cargo rendent cette séparation enforcée par le compilateur, pas par convention. Un handler qui essaie d’importer sqlx directement ne compilera pas si le crate ironflow-api ne le liste pas dans ses dépendances.

Le point clé d’IronFlow : les traits de store avec deux implémentations. Le PostgresStore pour la production, le InMemoryStore pour les tests. C’est ce qui rend les tests de handlers rapides et fiables sans infrastructure externe.

Le coût est réel : plus de crates, plus de From impls, plus de boilerplate pour les conversions d’erreurs. Mais sur un projet avec des workers concurrents, des leases, et des FSM, le gain en maintenabilité justifie largement l’investissement.

Si vous démarrez une API Rust avec Axum, commencez par séparer les entities du reste. Ajoutez un trait pour le store quand vous voulez tester sans base de données. Ajoutez des DTOs quand le modèle interne diverge du contrat API. Et découpez en crates quand les temps de compilation ou les frontières de responsabilité le justifient.

Pour voir comment cette architecture sert de base à des cas concrets, lisez comment IronFlow orchestre des agents IA pour la revue de code automatique.