ashik@dev

Axum Is Great. I Still Built a Framework on Top of It.

Before most of my backend work moved to Rust, I wrote a lot of NestJS. There's even an old post on this blog where I put together an API with Nest, TypeORM and Postgres.

When I switched to Axum, I didn't miss the decorators, and I definitely didn't miss the dependency injection container. What I missed was something harder to name. In a Nest project, a lot of decisions have already been made for you. Validation happens in one place. Errors have one shape. If a route needs auth, Swagger knows about it. You open a new project and the structure is already familiar.

Axum doesn't make those decisions, and that's on purpose. It gives you excellent building blocks and gets out of the way. But it meant that in every new project I rebuilt the same glue: validated extractors, an error type, OpenAPI wiring, auth middleware. Each time it came out slightly different from the last.

After enough projects, I stopped copying the glue around and turned it into a crate. That crate is Ferrax. It's at version 0.1.17, and nine of my own projects depend on it. Some of them are real products and some are weekend experiments. It's pre-1.0 and the API still moves, so please read everything below as "here's what I learned", not "go use this at work tomorrow".

The problem that kept coming back: docs that lie

Here's a perfectly normal Axum handler:

rust
async fn create_project(Json(body): Json<CreateProject>) -> impl IntoResponse {
    let project = save(body).await;
    (StatusCode::CREATED, Json(project))
}

It works. It returns 201 with a JSON body. The trouble starts when you generate OpenAPI docs for it.

impl IntoResponse hides everything. A documentation generator can't look inside a tuple and learn that it's a 201 with a Project body. So you describe the response separately, by hand, somewhere else. Now there are two sources of truth: what the handler returns, and what the docs claim it returns. They'll agree on the day you write them. Six months later someone changes the handler, and the docs keep saying the old thing.

That's annoying for humans reading Swagger. It's much worse when you generate typed API clients from the spec, which I do for almost every frontend I build. A wrong spec gives you a client that compiles and is still wrong.

This isn't a hypothetical for me. Ledgeo, one of my larger apps, grew its own local wrapper called DocumentedJson, just to keep runtime behavior and documentation in sync for one response shape. It worked. But the moment a second project needed the same thing, it was obviously a framework problem wearing an application-code disguise.

The rule: route outputs come from a closed list

The core of Ferrax is one small trait:

rust
/// Implementations are intentionally explicit. In particular, tuples and
/// erased Axum responses do not implement this trait.
pub trait RouteOutput: IntoResponse {}

A Ferrax API route has to return something that implements RouteOutput, and the list of things that do is deliberately short:

  • Json<T>
  • CreatedJson<T>
  • CookieJson<T> and CreatedCookieJson<T>
  • PdfResponse and PngResponse
  • SseResponse<S>
  • OneOf<A, B>
  • Result<Success, Error>, where both sides meet Ferrax's contracts

Tuples aren't on the list. Neither is impl IntoResponse. If you try to register a handler that returns one, it doesn't compile.

The status code is part of the type instead of a value you pass at runtime:

rust
pub type CreatedJson<T> = WithStatus<status::Created, Json<T>>;

So if a handler's signature says CreatedJson<Project>, it returns 201, and the generated spec says 201, and there's no way for those two facts to drift apart. They come from the same place.

Sometimes a handler really can return two different things. An "upsert" might return 200 when it updates and 201 when it creates. For that there's OneOf:

rust
async fn upsert(
    State(state): State<AppState>,
    VJson(body): VJson<UpsertProject>,
) -> Result<OneOf<Json<Project>, CreatedJson<Project>>, ApiError> {
    // ...
}

Both outcomes are full types, with their own status and body, so both show up in the docs. Early on I considered letting handlers pick a status code dynamically. I dropped the idea because a bare status code loses its connection to the body, headers and media type that go with it.

Here's what a small Ferrax API looks like end to end:

rust
#[derive(Deserialize, JsonSchema, Validate)]
struct CreateProject {
    #[garde(length(min = 1))]
    name: String,
}

#[derive(Serialize, JsonSchema)]
struct Project {
    name: String,
}

async fn create(
    State(_): State<AppState>,
    VJson(body): VJson<CreateProject>,
) -> Result<CreatedJson<Project>, ApiError> {
    Ok(CreatedJson::from_value(Project { name: body.name }))
}

fn project_routes() -> RouteSet<AppState> {
    Routes::new("projects")
        .post("/projects", create, "Create a project")
        .build()
}

VJson deserializes and validates in one step, so by the time the handler runs, name is guaranteed not to be empty. The OpenAPI operation comes from the input type, the output type and that one-line summary. There's nothing else to keep in sync.

One thing I want to be clear about: OpenAPI generation is optional. A project can turn documentation off entirely. The typed outputs are not optional. I think of documentation as a product decision and typed boundaries as the framework's actual job.

Guards that show up in the docs

Auth has the same drift problem as responses, just in a different place.

In a typical setup, the middleware that checks tokens lives in one file, and the line that tells Swagger "this route needs a bearer token" lives in another. It's easy to protect a route and forget to document it. It's worse to document a route as protected and forget to actually protect it.

A Ferrax Guard carries both parts together: the runtime check and the change it makes to the OpenAPI operation. Here's roughly what device authentication looks like in tgn, the notification service I run for myself, trimmed down a bit:

rust
#[derive(Clone)]
struct RequireDevice {
    state: AppState,
}

#[async_trait]
impl GuardCheck for RequireDevice {
    type Error = ApiError;

    async fn check(&self, parts: &mut Parts) -> Result<(), Self::Error> {
        let device = authenticate_device(&self.state, parts).await?;
        parts.extensions.insert(device);
        Ok(())
    }
}

fn device_auth(state: &AppState) -> Guard<AppState> {
    Guard::new()
        .with_required_header::<AuthorizationHeader>()
        .use_guard(RequireDevice { state: state.clone() })
        .with_security("bearer")
}

Routes then pick up the guard as a unit:

rust
Routes::new("notifications")
    .with_guard(device_auth(&state))
    .post("/notify", notify, "Send a notification")
    .build()

Inside the handler, the authenticated device comes out through GuardContext<Device>. If the guard didn't run, there's nothing to extract. And because the guard also documents the header and the security requirement, the spec and the actual behavior can't disagree about which routes are protected.

What I refused to copy from NestJS

The obvious way to "build Nest for Rust" is to copy Nest: a DI container, modules with imports and exports, decorators. I thought about it for a while and decided against all three.

No dependency injection container. In Nest, the container exists partly because TypeScript needs help knowing what a class depends on. Rust doesn't have that problem. If a handler needs the database, the database is in the application state, and you can see that in the handler's signature. Explicit constructors are boring, and I've come to think boring is exactly right for this.

No module graph. Nest's imports, exports and dynamic modules mostly exist to feed the container. Without the container, they don't have much reason to exist. Ferrax groups routes with RouteSet and server-rendered pages with Views, and that's been enough.

No GraphQL. This one surprises people. GraphQL is typed, but the client chooses the shape of each response. One resolver can produce many different valid outputs, and that doesn't fit a framework built around a closed set of response contracts. Rather than bolt on a second philosophy, I left it out.

I wrote these down as decisions in the repo, with the reasoning next to them, mostly so that future me doesn't reopen them every few months.

Features came from real apps, not from a roadmap

Almost nothing in Ferrax was added because a framework "should have it". Features arrived when one of my projects hit a wall.

Streaming HTML and the in-memory cache showed up the same day I was building a live dashboard for my home router tool. I wrote about that project in I Gave My Home Router a Git Diff. The cache coalesces requests, so ten simultaneous requests for a cold key cause one load, not ten. It also supports stale-while-revalidate, so a slow refresh doesn't make everyone wait.

Multipart uploads came when an app needed file uploads. SSE came when I needed live updates without WebSockets. The most recent release added server-rendered form handling, redirects and HTML error pages, because I was building an app that works without JavaScript and needed them.

The same release changed two defaults, and I think these small ones say more about the framework than the big features do. Query strings are no longer logged by default, because they sometimes carry things that shouldn't end up in logs. And incoming request IDs are now sanitized. Anything that isn't a short, plain identifier gets replaced with a fresh UUID instead of being trusted.

There's one rule from the design notes that I keep coming back to: if more than one application needs a response shape, it becomes a typed Ferrax primitive. Nobody gets told to "just use a tuple".

Where it honestly isn't done

I keep a comparison against NestJS in the repo, and I try to be honest in it. My rough guess is that Ferrax covers about 70 to 75 percent of the Nest-style experience for building HTTP APIs. As a full application platform, it's closer to 30 or 40 percent.

The big missing pieces are background jobs, WebSockets and gRPC. I've decided to build them in a specific order. First comes a supervisor that owns startup, cancellation and graceful shutdown for everything running inside the app. Then workers, then the transports. All three need the same lifecycle, and building it three times would give me three slightly incompatible versions.

For durable jobs, I've also committed to being upfront about delivery guarantees. Jobs will be at-least-once, and applications will need to be idempotent. I'd rather say that plainly than hide it behind an API that suggests exactly-once and quietly isn't.

The compiler errors also need work. When a handler returns something that isn't a valid route output, Rust tells you, but not always in words a newcomer would understand.

Was it worth it?

For my own projects, clearly yes. The glue I used to rewrite in every repo now lives in one place. Improvements reach every app with a version bump, and my generated frontend clients have stopped lying to me.

What surprised me most was how much of the work was deciding what not to build. The trait that bans tuples is a single line. The reasoning behind it, and behind skipping DI, modules and GraphQL, took far more thought than any of the code.

found a typo? posts live in git.suggest an edit ->

comments

view on github ->