rust
64 lines · 8 steps
Composing Axum apps from feature modules
A FeatureModule trait lets each domain own its routes, then build_app nests them under name-based prefixes into one router.
Explained by
highlit
1use axum::{
2 extract::{Path, State},
3 routing::{get, post},
4 Json, Router,
5};
6use serde::Serialize;
7
8pub trait FeatureModule {
9 fn name(&self) -> &'static str;
10 fn router(&self) -> Router<AppState>;
11}
12
13#[derive(Clone)]
14pub struct AppState {
15 pub db: sqlx::PgPool,
16}
17
18pub struct BillingModule;
19
20impl FeatureModule for BillingModule {
21 fn name(&self) -> &'static str {
22 "billing"
23 }
24
25 fn router(&self) -> Router<AppState> {
26 Router::new()
27 .route("/invoices", get(list_invoices))
28 .route("/invoices/{id}/refund", post(refund_invoice))
29 }
30}
31
32#[derive(Serialize)]
33struct Invoice {
34 id: i64,
35 total_cents: i64,
36}
37
38async fn list_invoices(State(state): State<AppState>) -> Json<Vec<Invoice>> {
39 let rows = sqlx::query_as!(Invoice, "SELECT id, total_cents FROM invoices ORDER BY id DESC")
40 .fetch_all(&state.db)
41 .await
42 .unwrap_or_default();
43 Json(rows)
44}
45
46async fn refund_invoice(State(state): State<AppState>, Path(id): Path<i64>) -> Json<Invoice> {
47 let row = sqlx::query_as!(
48 Invoice,
49 "UPDATE invoices SET refunded_at = now() WHERE id = $1 RETURNING id, total_cents",
50 id
51 )
52 .fetch_one(&state.db)
53 .await
54 .expect("invoice not found");
55 Json(row)
56}
57
58pub fn build_app(state: AppState, modules: Vec<Box<dyn FeatureModule>>) -> Router {
59 let mut app = Router::new();
60 for module in modules {
61 app = app.nest(&format!("/{}", module.name()), module.router());
62 }
63 app.with_state(state)
64}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1A trait over routers lets each feature package its endpoints and be assembled generically.
- 2Sharing state via Axum's State extractor keeps handlers decoupled from how the app is wired together.
- 3Nesting routers under a name prefix turns a Vec of boxed modules into a single mountable app.
Related explainers
rust
use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(untagged)]
Parsing flexible JSON shapes with serde
deserialization
enums
json
Intermediate
6 steps
php
<?php namespace App\Services\Checkout;
Validating coupons with Laravel's Pipeline
pipeline
chain of responsibility
transactions
Intermediate
7 steps
rust
use std::cmp::{Ordering, Reverse}; use std::collections::BinaryHeap; use std::fs::File; use std::io::{self, BufRead, BufReader, BufWriter, Lines, Write};
K-way merge of sorted logs in Rust
binary-heap
k-way-merge
streaming-io
Intermediate
8 steps
rust
use axum::{ extract::{Path, State}, response::sse::{Event, KeepAlive, Sse}, };
Streaming import progress with SSE in Axum
server-sent-events
streams
watch-channel
Advanced
7 steps
rust
use std::f64::consts::PI; #[derive(Debug, Clone, Copy)] pub struct LatLng {
Building geographic bounding boxes in Rust
geospatial
value-types
option
Intermediate
7 steps
ruby
class WeeklySignupsReport DEFAULT_WEEKS = 12 def initialize(weeks: DEFAULT_WEEKS, source: User.all)
Building a weekly signups report in Rails
service object
aggregation
group by
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/composing-axum-apps-from-feature-modules-explained-rust-13e3/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.