Architecture
This page provides a conceptual overview of how connectrpc-axum works internally.
Overview
connectrpc-axum bridges the Connect protocol with Axum's handler model through multiple crates:
| Crate | Purpose |
|---|---|
connectrpc-axum | Server runtime library - layers, extractors, response types |
connectrpc-axum-client | Client library - HTTP client, streaming, interceptors |
connectrpc-axum-core | Shared protocol types - compression, error codes, envelope framing |
connectrpc-axum-build | Build-time code generation from proto files |
The core modules in the client library:
| Module | Purpose |
|---|---|
client.rs | ConnectClient<I> - main client type, generic over interceptor chain |
builder.rs | ClientBuilder<I> - fluent API for client configuration |
config/interceptor.rs | Unified interceptor system (Interceptor, MessageInterceptor) |
transport/ | HTTP transport abstraction (HyperTransport) |
request.rs | Request encoding, FrameEncoder for streaming |
response.rs | Response types, Streaming<T>, FrameDecoder |
The core modules in the server runtime library:
| Module | Purpose |
|---|---|
context/ | Protocol detection, compression config, message limits, timeouts |
message/{request,response}.rs | Request/response primitives (decode, encode, compress) |
layer/ | Middleware layers (BridgeLayer, ConnectLayer) |
message/ | ConnectRequest<T> extractor and ConnectResponse<T> wrapper |
handler.rs | Handler wrappers that implement axum::handler::Handler |
tonic/ | Optional gRPC interop and extractor support |
Request Lifecycle
Layer Stack
Requests flow through a three-layer middleware stack:
HTTP Request
↓
┌─────────────────────────────────────────────┐
│ BridgeLayer │ ← Size limits, streaming detection
│ ┌───────────────────────────────────────┐ │
│ │ Tower CompressionLayer │ │ ← HTTP body compression (unary only)
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ ConnectLayer │ │ │ ← Protocol detection, context
│ │ │ ┌───────────────────────────┐ │ │ │
│ │ │ │ Handler │ │ │ │ ← Your RPC handlers
│ │ │ └───────────────────────────┘ │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
↓
HTTP ResponseBridgeLayer (outermost) - see layer/bridge.rs:
- Checks
Content-Lengthagainst receive size limits (on compressed body) - Detects Connect streaming requests (
application/connect+*) - For streaming: prevents Tower compression by setting identity encoding
Tower CompressionLayer (middle):
- Standard HTTP body compression for unary RPCs
- Uses
Accept-Encoding/Content-Encodingheaders
ConnectLayer (innermost) - see layer/connect.rs:
- Validates content-type and returns HTTP 415 for unsupported types
- Parses
Content-Typeto determine encoding (JSON/Protobuf) - Parses
?encoding=query param for GET requests - Validates
Connect-Protocol-Versionheader when required - Parses timeout from
Connect-Timeout-Msheader - Builds
ConnectContextand stores it in request extensions
Compression Paths
The Connect protocol uses different compression mechanisms for unary vs streaming RPCs:
Unary RPCs - HTTP body compression:
Request → BridgeLayer (size check) → Tower decompress → ConnectLayer → Handler
↓
Response ← BridgeLayer ← Tower compress ← ConnectLayer ← Handler response- Uses standard
Accept-Encoding/Content-Encodingheaders - Handled entirely by Tower's
CompressionLayer - BridgeLayer checks compressed body size before decompression
Streaming RPCs - per-envelope compression:
Request → BridgeLayer (bypass Tower) → ConnectLayer → Handler
↓
Each message envelope compressed individually- Uses
Connect-Accept-Encoding/Connect-Content-Encodingheaders - BridgeLayer sets
Accept-Encoding: identityto prevent Tower from interfering context/envelope_compression.rsprovides codec implementations
Code Structure
The request/response processing follows this module hierarchy:
context/ ← Configuration and protocol state
protocol.rs RequestProtocol enum, detection
envelope_compression.rs Per-message compression
limit.rs Message size limits
timeout.rs Request timeout
↓
message/{request,response}.rs ← Low-level encode/decode functions
↓
layer/ ← Middleware that builds context
bridge.rs BridgeLayer/BridgeService
connect.rs ConnectLayer/ConnectService
↓
message/ ← Axum extractors and response types
request.rs ConnectRequest<T>, Streaming<T>
response.rs ConnectResponse<T>, StreamBody<S>Handlers receive a ConnectRequest<T> extractor that reads the ConnectContext from request extensions, then uses message/request.rs functions to decode the message. Response encoding uses message/response.rs in the reverse path.
Axum Extractor Support in Tonic Handlers
When using tonic-compatible handlers, axum's FromRequestParts extractors need access to HTTP request parts. The challenge: tonic consumes the HTTP request before your handler runs.
The solution is FromRequestPartsLayer in tonic/parts.rs:
HTTP Request
↓
FromRequestPartsLayer ← Clones method, uri, version, headers into extensions
↓
Tonic gRPC Server ← Consumes HTTP request, but extensions survive
↓
Your Handler ← Reconstructs RequestContext from:
- CapturedParts (from extensions)
- extensions (from tonic::Request)Key insight: http::Extensions cannot be cloned, but it can be moved. The layer captures clonable parts (CapturedParts), and the handler later combines them with the owned extensions to build a complete RequestContext for extraction.
Code Generation
ConnectHandlerWrapper
The ConnectHandlerWrapper<F> type transforms user functions into axum-compatible handlers. It's a newtype wrapper with multiple impl Handler<T, S> blocks, each with different trait bounds:
User function: async fn(E1, E2, ..., ConnectRequest<Req>) -> ConnectResponse<Resp>
where E1, E2, ... : FromRequestParts
↓
ConnectHandlerWrapper<F> implements Handler<T, S>
↓
Axum can route to itHandlers can include any types implementing FromRequestParts before the ConnectRequest<T> parameter, just like regular axum handlers. The compiler selects the appropriate impl based on the handler signature:
| Pattern | Request Type | Response Type |
|---|---|---|
| Unary | ConnectRequest<Req> | ConnectResponse<Resp> |
| Server streaming | ConnectRequest<Req> | ConnectResponse<StreamBody<St>> |
| Client streaming | ConnectRequest<Streaming<Req>> | ConnectResponse<Resp> |
| Bidi streaming | ConnectRequest<Streaming<Req>> | ConnectResponse<StreamBody<St>> |
See handler.rs for the implementation.
Tonic-Compatible Handlers
For tonic-style handlers (trait-based), the library uses a factory pattern with boxed calls:
User trait impl: async fn method(&self, req: tonic::Request<Req>) -> Result<Response<Resp>, Status>
↓
IntoFactory trait converts to BoxedCall
↓
TonicCompatibleHandlerWrapper adapts to axum Handler
↓
Axum can route to itThe "2-layer box" approach (same pattern axum uses for Handler → MethodRouter):
- Factory layer:
IntoFactorytrait producesBoxedCall<Req, Resp>- a type-erased callable - Wrapper layer:
TonicCompatibleHandlerWrapperimplementsHandlerfor the boxed call
One caveat: axum uses a trait for the factory layer, while we use closures. See this discussion for the design rationale.
This allows generated code to work with user-provided trait implementations without knowing concrete types at compile time. See tonic/handler.rs for the boxed call types and factory traits.
Multi-Stage Code Generation
Code generation uses staged passes to avoid type duplication while keeping serde and tonic output aligned:
Pass 1: Prost + Connect
proto files → prost_build → Message types (Rust structs)
→ Connect service builders
→ File descriptor setPass 1.5: pbjson serde generation
File descriptor set → pbjson_build → {package}.serde.rs
→ Appended into Pass 1 filesPass 2: Tonic server (optional)
File descriptor set → tonic_build → Server traits/stubs
→ Uses extern_path to reference Pass 1 typesPass 3: Tonic client (optional)
File descriptor set → tonic_build → Client stubs
→ Uses extern_path to reference Pass 1 typesThe key is extern_path: tonic passes don't regenerate message types, they reference Pass 1 output. pbjson is a separate builder, so extern mappings for pbjson must be configured separately when needed (for example with with_pbjson_config).
See CompileBuilder in connectrpc-axum-build for the type-state pattern that enforces valid configurations at compile time.
MakeServiceBuilder
MakeServiceBuilder combines multiple services and applies cross-cutting infrastructure:
let app = MakeServiceBuilder::new()
.add_router(hello_service_router) // Connect service
.add_router(echo_service_router) // Another Connect service
.add_grpc_service(grpc_server) // Tonic gRPC service
.add_axum_router(health_router) // Plain axum routes (bypass ConnectLayer)
.build();The builder handles:
- Wrapping Connect routes with
ConnectLayerfor protocol handling - Wrapping routes with
BridgeLayerfor compression bridging - Adding Tower
CompressionLayerfor HTTP body compression - Routing gRPC services through
ContentTypeSwitch(by Content-Type header) - Passing plain axum routes through without Connect processing
User provides:
├── Connect routers (from generated builders)
├── gRPC services (tonic)
└── Plain axum routers
MakeServiceBuilder adds:
├── BridgeLayer
├── CompressionLayer
├── ConnectLayer (for Connect routes only)
└── ContentTypeSwitch (routes gRPC vs Connect)
Output: Single axum RouterFor mixed Connect/gRPC deployments, ContentTypeSwitch routes by Content-Type header:
application/grpc*→ Tonic gRPC server- Otherwise → Axum routes (Connect protocol)
See service_builder.rs for the implementation.
Client Interceptor System
The client provides a unified interceptor system for cross-cutting concerns like authentication, logging, and message transformation.
Trait Hierarchy
Two user-facing traits, both internally unified:
| Trait | Purpose | Use Case |
|---|---|---|
Interceptor | Header-level access only | Auth headers, trace IDs, logging procedure names |
MessageInterceptor | Typed message access | Validation, message transformation, per-message logging |
Both traits are wrapped internally to InterceptorInternal via adapter types:
HeaderWrapper<I>- wrapsInterceptorimplementationsMessageWrapper<I>- wrapsMessageInterceptorimplementations
This enables zero-cost composition via Chain<A, B> without dynamic dispatch.
Context Types
Interceptors receive context objects with relevant information:
| Type | Fields | Used In |
|---|---|---|
RequestContext | procedure, headers (mutable) | on_request |
ResponseContext | procedure, headers (read-only) | on_response |
StreamContext | procedure, stream_type, request_headers, response_headers | Streaming methods |
Builder API
ConnectClient and ClientBuilder use a single type parameter I for the interceptor chain (defaults to ()):
// No interceptors
let client = ConnectClient::builder("http://localhost:3000")
.build()?;
// With header-level interceptor
let client = ConnectClient::builder("http://localhost:3000")
.with_interceptor(AuthInterceptor::new("Bearer token"))
.build()?;
// With message-level interceptor
let client = ConnectClient::builder("http://localhost:3000")
.with_message_interceptor(LoggingInterceptor)
.build()?;
// Chaining multiple interceptors
let client = ConnectClient::builder("http://localhost:3000")
.with_interceptor(AuthInterceptor::new("Bearer token"))
.with_message_interceptor(ValidationInterceptor)
.with_interceptor(TracingInterceptor)
.build()?;Each with_interceptor or with_message_interceptor call wraps the interceptor and composes it with the existing chain:
with_interceptor(i)returnsClientBuilder<Chain<I, HeaderWrapper<J>>>with_message_interceptor(i)returnsClientBuilder<Chain<I, MessageWrapper<J>>>
Convenience Types
| Type | Purpose |
|---|---|
HeaderInterceptor | Add a single header to all requests |
ClosureInterceptor | Quick header-level interception via closure |
Execution Order
For requests, interceptors run in the order added (first added = first to run). For responses, interceptors run in reverse order (middleware unwinding pattern).
See config/interceptor.rs for the implementation.