Build a Smaller Axum
The reconstruction should preserve the adaptation pipeline, not mimic Axum’s surface syntax.
Build one request type, one service protocol, path and method routing, typed extraction, response conversion, middleware, capacity policy, cancellation, and graceful draining.
1. Start with a concrete synchronous server core
Define Request { method, path, headers, body } and Response { status, body }.
Route one path with a match, parse one body, call one function, and return one
response. Write tests before adding networking.
2. Introduce the service protocol
trait Service<Req> {
type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;
fn call(&mut self, req: Req) -> Self::Future;
}
First implement it with Ready. Then return boxed async futures. Observe which
lifetimes force the future to own its inputs.
3. Separate path and method routing
Create a PathRouter whose endpoints are MethodRouter values. Preserve 404
versus 405 behavior. Store captured path parameters in a type map or a simpler
request-extension structure.
4. Build extraction without variadic generics
Implement a parts extractor and one body extractor. Manually support handlers with zero, one, and two arguments. This makes the reason for Axum’s tuple macro obvious before you imitate it.
trait FromParts<S>: Sized {
type Rejection: IntoResponse;
async fn from_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection>;
}
Ensure the body-consuming extractor is last.
5. Normalize output and failure
Add IntoResponse for strings, status/body tuples, and Result<T, E>. Create a
private application error that logs its source chain but returns a stable public
message. Make the outer router error type Infallible.
6. Add middleware as a generic wrapper
Implement logging and timeout wrappers that each contain another Service.
Trace the nested type produced by two wrappers. Then introduce one boxed route
boundary and compare compile-time readability with runtime indirection.
7. Add Tokio at the edge
Run an accept loop and spawn one connection task per socket. You may use a tiny line-oriented protocol before integrating Hyper. The point is to see that the handler future is nested inside a connection task rather than automatically becoming its own task.
8. Make overload observable
Place a semaphore-backed concurrency limit around one slow route. Test that the limit is enforced. Then compare three explicit policies: wait for a permit, reject immediately, or wait only until a deadline.
There is no universally correct choice; the boundary must name the product policy.
9. Test cancellation and draining
Start a handler, drop its request future, and record which destructors run. Try again after spawning detached work. Finally stop admission, signal existing connections, and wait for an active-task counter to reach zero. Add a drain deadline so a forever-pending handler cannot block shutdown forever.
10. Compare with production
Map your pieces back to serve, Hyper’s connection driver, Router,
MethodRouter, Handler, FromRequestParts, FromRequest, IntoResponse,
Tower Layer, and HandleError.
The smaller version omits production HTTP correctness, protocol upgrades, HTTP/2 multiplexing, optimized matching, connect information, body utilities, macro diagnostics, and the full Tower ecosystem. Its architectural center should still explain ownership of the body, how handler functions become services, where futures are polled, how overload is bounded, and what happens when a request disappears.