Rust RPC Router: Axum, Tower, and Bevy Style Architecture (E05)
This chapter covers a major architectural refactoring of the Rust production web application blueprint: the introduction of a completely decoupled, modular JSON-RPC router inspired by Axum, Tower, and Bevy patterns. We explore how to build a protocol-agnostic RPC infrastructure in a separate library (lib-rpc), decouple it entirely from web frameworks like Axum, handle flexible handler signatures using advanced trait implementations, and integrate it seamlessly into an HTTP endpoint.
1. Why Do We Need a Custom RPC Router?
The Problem with Toyish Macro Mappings
In earlier iterations of our project, RPC method dispatching relied on simplistic, hardcoded macro mappings. While sufficient for quick prototypes, this approach lacked flexibility, robust type safety, and proper separation of concerns.
The Production Goal: Protocol Agnosticism
In a production-grade system, business logic should not care whether it is being invoked via HTTP, gRPC, WebSockets, or a CLI job worker.
- Decoupling: The core RPC logic must reside in an independent library (
lib-rpc) that has zero knowledge ofaxum, HTTP status codes, or web servers. - Reusability: The exact same RPC router and handlers can be reused across multiple consumer apps, backend job services, or admin tools.
2. Core Mental Model: Axum, Tower, and Bevy Style Handlers
The architectural design of our RPC router borrows heavily from the ergonomic patterns found in popular Rust ecosystems:
- Axum / Tower: Flexible handler signatures where functions can accept various extractors (like
CtxorModelManager) in arbitrary or enforced orders, turning ordinary Rust functions into router endpoints via trait implementations. - Bevy: Resource management patterns where system parameters and context resources are injected cleanly based on type.
Real-Life Analogy: The Restaurant Kitchen Expediters
Analogy: Think of an RPC router as a restaurant kitchen expediter. The front-of-house host (Axum web server) receives orders from customers in a specific format (JSON-RPC over HTTP). The host strips away the HTTP plates and napkins, handing raw order details to the expediter (
lib-rpc). The expediter checks what ingredients and chefs (resources likeCtxandModelManager) are required, as well as the specific customer customization (params), routes the order to the correct cooking station (handler function), and packages the result back up—completely blind to whether the customer is sitting in the dining room or ordering via delivery app.
3. Designing Handler Signatures: Resources and Params
Every RPC handler function in our system accepts two distinct categories of arguments:
- Resources (0 or more): Injected environmental data or context, such as
Ctx(security context) orModelManager(database access handle). These are analogous to Axum extractors or Bevy system resources. - Params (0 or 1): The deserialized payload representing the method arguments (e.g.,
ParamsForCreate,ParamsForUpdate). This is always positioned as the final argument if present.
Example Handler Signatures
// Handler with multiple resources and a create parameter payload
async fn create_project(ctx: Ctx, mm: ModelManager, params: ParamsForCreate) -> Result<Project> { ... }
// Handler with resources only and no parameters
async fn list_projects(ctx: Ctx, mm: ModelManager) -> Result<Vec<Project>> { ... }
// Handler with a single resource and an update parameter payload
async fn update_project(mm: ModelManager, params: ParamsForUpdate) -> Result<Project> { ... }
4. Compile-Time Order Enforcement and Flexibility
Similar to Axum's extractor design, the RPC router enforces strict compile-time rules on argument ordering while providing extreme flexibility in handler composition.
- Resource Placement: Resources can be specified in combinations (up to 5 or 6 via macro generation).
- Param Placement: If parameters are present, they must be the last argument in the handler function signature.
- Compile-Time Safety: If a developer places a resource after the params argument, the Rust compiler immediately catches the misconfiguration and halts compilation, preventing subtle runtime bugs.
// --- COMPILES SUCCESSFULLY ---
async fn create_project(ctx: Ctx, mm: ModelManager, params: ParamsForCreate) -> Result<Project>
// --- COMPILE ERROR (ModelManager placed after params) ---
async fn create_project(ctx: Ctx, params: ParamsForCreate, mm: ModelManager) -> Result<Project>
5. Parameter Normalization and Marker Traits
JSON-RPC specifications allow parameters to be optional or structured in various ways. To normalize this, we define parameter structs for each RPC action and implement a marker trait that provides sensible defaults while allowing custom overrides.
// Example parameter structs for create and update actions
pub struct ParamsForCreate {
pub title: String,
}
pub struct ParamsForUpdate {
pub id: i64,
pub title: Option<String>,
}
Note: A default implementation is automatically provided for parameter deserialization, but developers can override the trait behavior if specific fields need to be strictly required or optional beyond standard parsing rules.
6. The Core Magic: RPC Handler Trait and Type Erasure
To store diverse handler functions with differing signatures inside a unified data structure (like a HashMap or Vec), we utilize Rust's trait object and type erasure patterns.
The RPCHandler Trait Architecture
The RPCHandler trait uses three generic parameters:
T: A tuple representing 0 to 5 resources.Params: A tuple representing 0 or 1 parameters.Result: The output future type.
pub trait RPCHandler<T, Params> {
type Future: Future<Output = Result<serde_json::Value>> + Send;
fn call(&self, resources: RPCResources, params: Option<serde_json::Value>) -> Self::Future;
}
Type Erasure via Wrapper Traits
Because functions in Rust have unique unnameable types, we implement into_handler to box the handler into a trait object (Box<dyn RPCHandlerWrapper>), enabling dynamic collection storage:
// Conceptual mapping of trait object wrapping
pub trait RPCHandlerWrapper {
fn call(&self, resources: RPCResources, params: Option<serde_json::Value>)
-> Pin<Box<dyn Future<Output = Result<serde_json::Value>> + Send>>;
}
Using macro rules, we implement RPCHandler across functions with varying numbers of resource arguments, automating the boilerplate required to extract resources from RPCResources and deserialize JSON values into strongly typed parameters.
7. Integrating the RPC Router into Axum
Now that lib-rpc is fully independent, integrating it into our web server crate (web) is clean and minimal.
Web Server Integration Flow
Implementation Code Structure
Inside the web server code, we assemble the router by extending domain-specific RPC routers:
// Building the unified RPC router in the web server
let mut rpc_router = RpcRouter::default();
rpc_router.add("project.create", create_project);
rpc_router.add("project.list", list_projects);
// Axum handler bridging HTTP requests to the lib-rpc router
async fn rpc_axum_handler(
State(rpc_state): State<RpcState>,
ctx: Ctx,
Json(rpc_request): Json<RpcRequest>,
) -> Result<Json<RpcResponse>> {
let res = rpc_router
.call(rpc_request.method, rpc_state.resources(), rpc_request.params)
.await?;
Ok(Json(res))
}
8. Summary Comparison: Toyish Macro vs. Production RPC Router
Summary and Key Takeaways
- Decoupled Architecture: Moving core routing logic into
lib-rpcensures business logic remains independent of underlying transport protocols like HTTP or WebSockets. - Inspiration from Ecosystem Giants: Combining Axum's extractor ergonomics, Tower's service composition, and Bevy's resource management yields a powerful, ergonomic Rust API.
- Resource and Parameter Separation: Handlers cleanly separate environmental dependencies (
Ctx,ModelManager) from action payloads (ParamsForCreate,ParamsForUpdate). - Compile-Time Order Validation: Enforcing rules such as placing parameter types last prevents developer error at compile time rather than runtime.
- Type Erasure with Traits: Implementing custom traits for functions combined with boxed trait objects allows disparate function signatures to be stored and invoked from a unified router collection.
- Seamless Web Bridging: Axum acts as a thin protocol adapter that extracts state, parses JSON-RPC envelopes, and hands execution off to the decoupled
lib-rpcrouter.