Create Scalable Axum Web Apps by Organizing Routes and Handlers

Axum is an amazing framework for building web applications in Rust, but as applications grow, code organization becomes critical for maintainability and scalability. In this article, we’ll explore how to structure your Axum app using routes and handlers in separate files. We will take a hands-on approach to progressively modularize your codebase and keep it clean and manageable.

By the end of this article, you’ll understand how to break your application into separate files and modules that handle routing, logic, and utilities.


Step 1: Setting Up the Project

Let’s first set up a minimal Axum project. In your terminal, run the following commands:

cargo new axum_modular_example --bin
cd axum_modular_example

Now, open the Cargo.toml file and add dependencies for Axum and Tokio:

[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Let’s now create a simple Axum application that listens on port 3000 and responds with "Hello, World!" when accessed. Open src/main.rs and write the following code:

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "Hello, World!" }));

    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

With this, you’ve created a basic server. To run it:

cargo run

Your app is now serving "Hello, World!". But what if this app grows, and you have more routes and business logic? This is where modularity comes into play.


Step 2: Modularizing the Routes and Handlers

Organizing Routes and Handlers

Instead of keeping everything in main.rs, we’ll split the functionality into separate files. We will:

  • Move routes into the routes module.
  • Place handlers in a separate handlers module to further separate concerns.

Creating the routes and handlers Modules

  1. First, create the routes and handlers directories:
mkdir src/routes
mkdir src/handlers
  1. Inside src/routes, create a mod.rs file:
touch src/routes/mod.rs
  1. Similarly, create the mod.rs file inside src/handlers:
touch src/handlers/mod.rs

Moving the Handler Logic

Next, we’ll move the route handlers into the handlers/mod.rs file.

// src/handlers/mod.rs
use axum::response::IntoResponse;

pub async fn hello_world() -> impl IntoResponse {
    "Hello, World!"
}

pub async fn current_time() -> impl IntoResponse {
    use std::time::SystemTime;
    let time = SystemTime::now();
    format!("Current time is: {:?}", time)
}

Now that the handlers are separate, the handlers/mod.rs file has two functions: hello_world and current_time, each returning responses.

Organizing Routes

Now, let’s move the routing logic into routes/mod.rs to wire everything together. We'll import the handler functions here:

// src/routes/mod.rs
use axum::{routing::get, Router};
use crate::handlers::{hello_world, current_time};

pub fn create_routes() -> Router {
    Router::new()
        .route("/", get(hello_world))
        .route("/time", get(current_time))
}

Here, we define a function create_routes() that sets up all routes and associates them with their respective handlers from the handlers module.

Updating main.rs

Finally, update the main.rs file to use the newly modularized structure:

use axum::Server;
use routes::create_routes;

mod routes;
mod handlers;

#[tokio::main]
async fn main() {
    let app = create_routes();

    Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Notice how main.rs is now much simpler. It just calls create_routes() from the routes module, and the routing logic is neatly abstracted away. We’ve now separated the concerns of routing, handlers, and the main entry point.


Step 3: Adding More Complexity with Additional Routes

Now, let's extend our application by adding more routes. For example, let’s introduce a new route that greets users by name. This will demonstrate how we can continue expanding the application while keeping the structure clean.

Adding the greet Handler

Add the greet handler to handlers/mod.rs:

// src/handlers/mod.rs
use axum::extract::Json;

pub async fn greet(Json(payload): Json<GreetRequest>) -> impl IntoResponse {
    format!("Hello, {}!", payload.name)
}

#[derive(serde::Deserialize)]
pub struct GreetRequest {
    pub name: String,
}

This handler receives a JSON object containing a name field, and it returns a greeting.

Adding the Route in routes/mod.rs

Now, add a route for the greet handler:

// src/routes/mod.rs
use axum::{routing::{get, post}, Router};
use crate::handlers::{hello_world, current_time, greet};

pub fn create_routes() -> Router {
    Router::new()
        .route("/", get(hello_world))
        .route("/time", get(current_time))
        .route("/greet", post(greet))
}

Testing the New Route

Now, your app can greet users via POST requests to /greet. You can test it with a tool like Postman or curl:

curl -X POST http://localhost:3000/greet -H "Content-Type: application/json" -d '{"name": "Alice"}'

This should return:

Hello, Alice!

Step 4: Challenges and Next Steps

Challenge: Add More Routes

As an exercise, try adding another route that handles PUT or DELETE requests. This will help you become familiar with how Axum handles various HTTP methods. For example, you could add a /update endpoint that updates user information.

pub async fn update_user(Json(payload): Json<UpdateUserRequest>) -> impl IntoResponse {
    format!("Updated user with ID: {}", payload.id)
}

#[derive(serde::Deserialize)]
pub struct UpdateUserRequest {
    pub id: u32,
}

Next Steps: Explore Middleware and Error Handling

Now that your application is modular, you can begin looking into Axum’s powerful middleware features for adding logging, authentication, or even rate limiting. You can also explore error handling to return meaningful error responses when something goes wrong.


Recap and Conclusion

We’ve covered how to structure an Axum application using Rust’s module system. Here’s what we accomplished:

  • Separated handlers and routes into their respective modules for better organization.
  • Used Axum’s Router to wire everything together and handle different HTTP methods.
  • Introduced new routes and handlers like /greet to demonstrate how to expand the app.

This approach will help keep your Axum applications maintainable as they scale, and it allows for easy navigation through your codebase. By organizing routes, handlers, and utilities into separate files and modules, you'll find that your development workflow becomes smoother and more scalable.

Happy coding, and see you next time!