The Rust Engineering Handbook / Chapter 64
Async Traits, Service Abstractions, and Testing
Design async interfaces whose future bounds, readiness, dispatch costs, cancellation behavior, and test seams remain explicit.
The storage boundary in relay-service has two legitimate users with incompatible needs.
The ingestion path chooses its backend at compile time. It wants inlining opportunities, no mandatory allocation per call, and a returned future that can move onto a multithreaded executor. The plugin host loads one of several backends behind a stable runtime-selected handle. It accepts virtual dispatch and a boxed future in exchange for type erasure.
Trying to make one trait signature hide that decision weakens the API. Static and dynamic dispatch are not interchangeable spellings; they expose different future types, bounds, allocation behavior, compatibility constraints, and testing seams. The design should offer a deliberate static core and an explicit dynamic adapter where runtime selection is genuinely required.
That decision is the beginning of an async service abstraction, not the end. The interface must also say when it is ready to accept work, which request state the future borrows, whether the future is Send, who owns cancellation, and how time and I/O become controllable in tests.
Start with the future shape
An async method returns a future whose hidden state can capture the receiver, arguments, and locals that live across suspension. In a trait, these two forms express closely related designs:
trait LocalStore {
async fn get(&self, key: String) -> Result<Option<Vec<u8>>, StoreError>;
}
trait SendStore {
fn get(
&self,
key: String,
) -> impl Future<Output = Result<Option<Vec<u8>>, StoreError>> + Send;
}
Rust stabilized async functions and return-position impl Trait in traits in Rust 1.75. Return-position impl Trait in a trait acts as an anonymous associated type: each implementation chooses a concrete hidden return type, while generic callers use only the bounds promised by the trait.
The bare async fn form does not let a public trait author spell an extra Send bound on the returned future. That matters when callers need to spawn or otherwise move the future across threads. Desugaring to fn -> impl Future + Send makes the commitment explicit. Adding Send later can reject existing implementations and is therefore an API compatibility decision, not a harmless optimization.
Do not add Send reflexively. A single-threaded executor, GUI loop, embedded system, or thread-affine resource may deliberately use non-Send futures. Offer the contract the application needs. If a library must serve both local and multithreaded contexts, separate traits or generated variants can make the choice explicit instead of imposing the strongest bound everywhere.
Edition 2024 capture rules allow return-position opaque types to capture in-scope generic parameters and lifetimes automatically. That convenience does not erase borrowing. A future returned from get(&self, ...) can borrow self, so it cannot outlive the store. Taking an owned request can reduce accidental borrowing of caller stack data, but the receiver may still be captured. Document whether a call owns its inputs and whether cancellation returns or discards them.

Keep the static path concrete
The lab’s primary storage trait promises Send futures without requiring boxing:
pub trait Storage: Send + Sync {
fn put(
&self,
key: String,
value: Vec<u8>,
) -> impl Future<Output = Result<(), StoreError>> + Send;
fn get(
&self,
key: String,
) -> impl Future<Output = Result<Option<Vec<u8>>, StoreError>> + Send;
}
An implementation may use async fn syntax:
impl Storage for FakeStorage {
async fn get(&self, key: String) -> Result<Option<Vec<u8>>, StoreError> {
self.get_impl(key).await
}
}
Generic code such as fn ingest<S: Storage>(store: &S, ...) is statically dispatched. The returned future has one concrete compiler-generated type for each implementation and method. This avoids a mandatory heap allocation and virtual call at the interface, although the implementation can still allocate internally. Monomorphization may increase compile time and retained code size, and concrete types can spread through middleware composition.
The opaque return type exposes only declared bounds. A caller cannot assume Unpin, Clone, a particular size, or additional auto traits. Public API authors must anticipate bounds that generic consumers need. This is one reason async trait design is more consequential than replacing a synchronous return type with async.
Several fixes that compile can still damage the boundary:
- Adding
async movemay take ownership of captured values, but it does not make a futureSendwhen a captured value is notSend. - Wrapping a backend in
Arc<Mutex<_>>may satisfy ownership errors while serializing unrelated operations or holding a synchronous guard across.await. - Adding
'staticcan force callers to allocate or clone state that naturally belongs to the call lifetime. - Boxing can erase a difficult type, but it cannot manufacture a missing
Sendguarantee or correct cancellation behavior. - Cloning the request to satisfy middleware can duplicate large bodies or create two owners for an operation identity.
When a future fails a Send requirement, inspect every value live across each .await. Shorten the lifetime of a guard, move thread-affine work to an appropriate local executor, or change the interface to transfer owned state. Choose the repair from the concurrency model rather than from the shortest compiler suggestion.
Static dispatch is a good default when the implementation is selected by a generic parameter, application wiring, or an enum with a small closed set of variants. An enum can preserve static futures while providing runtime selection among known backends; the cost is coupling the enum to that closed set and potentially producing a larger combined future state.
Put type erasure at the actual dynamic boundary
A trait with a method returning impl Future, including an async fn, is not dyn-compatible. A trait object needs one call ABI and a return type with known representation. The lab defines a separate object-safe interface:
pub type BoxFuture<'a, T> =
Pin<Box<dyn Future<Output = T> + Send + 'a>>;
pub trait DynStorage: Send + Sync {
fn get<'a>(
&'a self,
key: String,
) -> BoxFuture<'a, Result<Option<Vec<u8>>, StoreError>>;
}
Pin<Box<...>> gives the future a stable address and erases its concrete type. dyn Future supplies virtual dispatch for polling, and allocation makes room for an unknown future size. The 'a ties the erased future to the receiver borrow. Send states that the erased future may move between threads; Send + Sync on the storage object states separate properties of the backend value.
The adapter is mechanical and visible:
fn get<'a>(&'a self, key: String) -> BoxFuture<'a, Result<_, _>> {
Box::pin(self.get_impl(key))
}
This cost may be negligible beside network I/O, or material in a high-rate in-memory layer. Measure it in the context where the boundary is called. More importantly, place it once at a plugin, dependency-injection, or heterogeneous collection boundary rather than boxing every internal middleware layer out of habit.
Procedural-macro crates can generate boxed async trait implementations, and trait-variant tools can generate Send and local variants. They are useful policy encoders, but generated signatures still have allocation, lifetime, object-safety, and MSRV consequences. Review the expanded contract and pin third-party behavior.
Associated future types provide another design when callers or middleware need to name and constrain the future family. Generic associated types can relate the future lifetime to &self. The signatures are more verbose, and implementations may need a named future type or a box depending on stable language capabilities. Prefer them when the named relationship enables real composition; do not expose machinery merely to imitate a synchronous trait.
An enum adapter is a useful middle ground:
enum Backend { Local(LocalStore), Remote(RemoteStore) }
It supports runtime choice within a closed set and can delegate without a trait object. The enum’s size is at least that of its largest variant plus a tag, and adding variants couples the dispatcher to implementations. It is a strong application-internal option and a poor open plugin protocol.
Function-valued interfaces can be simpler for one operation: accept a closure returning a future rather than define a multi-method trait. They compose well for tests and policy injection, but related operations, readiness state, and shared configuration often justify a named service type. Choose the smallest abstraction that carries the full contract.
A service is a readiness protocol plus a call
A request/response trait that exposes only async fn call leaves admission ambiguous. Does calling enqueue without limit? Wait for a permit inside the future? Fail immediately? Retain the entire request while waiting? Chapter 62’s bounded-flow model belongs at this interface.
A service abstraction can separate readiness from execution:
pub trait Service<Request> {
type Response;
type Error;
type CallFuture<'a>: Future<Output = Result<Self::Response, Self::Error>>
+ Send
+ 'a
where
Self: 'a;
fn poll_ready(&self, cx: &mut Context<'_>)
-> Poll<Result<(), Self::Error>>;
fn call(&self, request: Request) -> Self::CallFuture<'_>;
}
Readiness is a contract, not a performance hint. It should correspond to capacity the caller may rely on for the next call, with documented invalidation rules. If readiness merely samples a queue and call races for capacity later, middleware cannot enforce a meaningful bound. Some designs reserve a permit during readiness and consume it in call; others combine reservation and call in one future. Either can work if ownership is explicit.
The error channels also differ. Readiness errors describe inability to accept new work, possibly because a dependency is closed or unhealthy. Call errors describe an accepted request’s outcome. Overload before ownership transfer may be safely retried elsewhere; an ambiguous call error after a write requires the idempotency logic from Chapter 63.
Readiness has a concurrency race when many callers poll the same shared service. If ten tasks observe Ready for one remaining slot, the signal was advisory rather than reserving. A service using &mut self can serialize readiness and call through one handle; cloned handles need shared permit ownership or a call path that can still return a typed overload outcome. Document whether a successful readiness observation applies to exactly one subsequent call and whether dropping the caller releases the reservation.
Cancellation between readiness and call is a first-class test. The reserved permit must return. Cancellation after call begins must follow the call’s effect protocol. A middleware layer must not retain a permit indefinitely because an inner future was dropped before it reached a bookkeeping branch; RAII guards are well suited to the local part of this lifecycle.
Mutable versus shared receivers encode concurrency policy. &mut self can ensure one readiness/call interaction at a time through the type system, while clones or buffer layers create concurrency elsewhere. &self permits concurrent calls only if the implementation’s internals support them. Sync does not promise unlimited useful concurrency or fairness.
Middleware transforms contracts, not just values
Middleware is often drawn as nested wrappers:
admission → tracing → deadline → retry/idempotency → backend
Order changes semantics. A deadline outside retry bounds the whole logical operation; a timeout created inside retry may reset on every attempt. Tracing outside retry yields one logical span with attempt children; tracing only inside may fragment the request. Admission outside expensive parsing protects memory; admission after parsing may retain large bodies while waiting. Idempotency must wrap every effect-producing attempt under one logical key.
For each layer, specify:
- request fields it consumes, adds, or clones;
- whether it changes readiness or reserves capacity;
- whether its future is
Sendand what it borrows; - behavior when the future is dropped;
- error translation and whether effect ambiguity survives translation;
- allocation, buffering, and concurrency introduced;
- metrics attached to logical operation versus physical attempt.
A timeout layer that maps every elapsed attempt to Unavailable destroys information needed for safe retry. A retry layer that clones an arbitrary request may duplicate non-cloneable streams or operation identities. A buffer layer can make a non-ready service appear ready by moving the queue into itself. Composability requires preserving contracts through the stack.
Type-heavy middleware can produce large future types, long compiler diagnostics, and code duplication. Box at a deliberately chosen architectural seam when compile time, binary size, or API stability outweighs the per-call allocation. Do not treat static dispatch as automatically faster end to end; measure the system bottleneck.
Keep the core independent of one runtime
Rust’s Future and Poll are runtime-neutral. Timer constructors, spawn handles, socket types, cancellation tokens, and test clocks usually are not. A reusable library becomes runtime-coupled when its public signatures expose those concrete types or when core logic calls a global runtime directly.
Choose coupling intentionally:
- A Tokio application layer can use Tokio timers and sockets directly; hiding them may add needless abstraction.
- A reusable protocol or domain library can accept an already-created stream/sink, a deadline value, or a narrow capability trait.
- Pure policy code—retry classification, deadline allocation, idempotency transitions—can remain synchronous and deterministic.
- Adapters can translate runtime-specific cancellation, timers, and I/O into the core service contract.
Avoid a universal “runtime” trait containing spawn, sleep, network, filesystem, randomness, and wall time. Such an interface becomes hard to implement faithfully and encourages tests that mock everything while proving little. Introduce the smallest seam required by the policy being tested.
Spawning is particularly consequential. A method returning a future keeps work attached to the caller’s lifecycle. A method that internally spawns can outlive the call, require 'static, alter panic propagation, and make cancellation indirect. If background ownership is part of the abstraction, return a handle or attach the task to a supervisor rather than concealing detachment.
Deterministic time needs an injected or controlled clock
Tests that sleep for 100 ms and expect a timeout at 90 ms are scheduler tests disguised as policy tests. They become slow and flaky under load. Separate the layers:
- Test deadline arithmetic with explicit
nowvalues. - Test runtime timer integration with the runtime’s controlled clock.
- Test real network cancellation and late effects in a bounded integration environment.
The lab’s deadline is a pure value model. Its Tokio test uses #[tokio::test(start_paused = true)], so a two-second timeout around a sixty-second sleep advances without wall-clock delay. This verifies timer and drop behavior reproducibly for the pinned runtime.
Controlled time has traps. A runtime may advance automatically only when no other work can progress. Busy loops and blocking calls still prevent scheduling. Code that reads the operating system clock directly will not follow the test clock. Random jitter should use an injected, seedable source, and tests should assert ranges and budget properties rather than one production-random sequence.
A test executor is useful when the behavior under test depends on poll order, wake registration, or cooperative progress. Keep those tests narrow. Most service policy should not require manually polling opaque futures; stateful fakes and controlled clocks produce clearer evidence.
Prefer a stateful fake when interaction semantics matter
A mock usually asserts expected calls: put once with these arguments, then get. That can be useful at an adapter boundary, but overspecified call order makes refactoring painful and may not model storage behavior.
A fake implements a smaller working system. The lab’s FakeStorage stores bytes in a mutex-protected map and records calls. Both static and dynamic interfaces operate against it:
Storage::put(&fake, "event-1".into(), vec![9]).await?;
assert_eq!(
Storage::get(&fake, "event-1".into()).await?,
Some(vec![9])
);
A production-oriented fake can model latency, finite readiness permits, injected failures before and after a commit boundary, duplicate idempotency keys, cancellation observation, and late completion. Its behavior must be simpler than the real dependency but faithful to the contract being tested.
Use mocks for narrow interaction obligations such as “the adapter sends one cancellation frame with this operation ID.” Use fakes for state transitions and cross-call behavior. Use a real dependency for protocol, transaction, framing, and driver behavior that a fake would merely assume. A strong test suite uses all three at their proper boundary.
Test interface substitutability, not only each implementation. A shared contract suite can run against the fake, in-memory backend, and real adapter and require the same visible outcomes for put/get, conflict, overload, cancellation, and idempotent replay. Backend-specific tests then cover properties the common trait does not promise, such as transaction isolation or persistence across restart.
Avoid a fake that is stronger than production in the ways that matter. An in-memory map usually completes atomically, never returns partial I/O, and is instantly ready. If all application tests use that behavior, middleware ordering and ambiguity paths remain untested. Add explicit scripted transition points—before admission, after request receipt, before commit, after commit, before reply—rather than random failures that cannot be reproduced.
The fake’s event log is also an observability oracle. Assert that one logical request creates attempt spans with a shared operation ID, that retry suppression records its reason, and that losing operations close with cancelled_local or late_result rather than ordinary success. These assertions keep telemetry semantics aligned with control flow.
Do not put nondeterministic failure scripting behind global mutable state. Give each test an owned script or fake instance. Record an event trace that includes logical operation ID, attempt number, readiness reservation, commit transition, cancellation, and result. Assertions against that trace explain failures better than timeout-based guesses.
Test cancellation as a lifecycle
Dropping a future is easy to trigger and hard to interpret unless the test observes owned resources. For a service call, assert the relevant subset:
- readiness permits return;
- child tasks finish or are joined;
- borrowed request state is no longer retained;
- transport requests are cancelled or the connection is quarantined;
- remote effects are absent, committed once, or reconcilable by idempotency key;
- late results are counted and safely discarded or replayed;
- middleware spans close with an outcome distinct from dependency failure.
Cancellation tests should exercise multiple suspension points: before admission, while waiting for readiness, during send, after a partial write, while awaiting a reply, and after remote commit but before reply observation. Property-style state-machine tests can generate transition sequences, while integration tests validate the actual driver at the dangerous boundaries.
Panic behavior also belongs in the interface. A future may panic when polled; a spawned task reports panic through its join mechanism. Decide whether middleware catches unwinds, lets them reach a supervisor, or aborts according to process policy. Do not translate invariant-violating panics into ordinary retryable service errors.
Design exercise: one storage interface, two consumers
Design an async storage boundary for relay-service with a compile-time-selected production backend and runtime-selected diagnostic plugins.
Produce these artifacts:
- A static trait signature. State receiver type, request ownership, output, error taxonomy, lifetime capture,
Send/Syncrequirements, and MSRV. - A dynamic adapter signature using a boxed future. Mark the allocation and virtual-dispatch points and explain why runtime selection is worth them.
- A readiness protocol. Define whether readiness reserves capacity, how long the reservation lasts, and what happens if the caller is cancelled between readiness and call.
- A middleware order containing admission, tracing, absolute deadline, retry, idempotency, and backend dispatch. For each layer, describe error and cancellation transformation.
- A runtime-coupling map. Keep pure retry/deadline/idempotency policy separate from timer, spawn, socket, and cancellation-token adapters.
- A test matrix using pure arithmetic tests, a controlled clock, a stateful fake, a narrow mock, and one real protocol integration test.
- A cancellation trace at three suspension points, including permit, child-task, connection, and remote-effect ownership.
Reject a design that uses bare async fn in a public trait while assuming its future is Send; calls Box::pin everywhere without a dynamic requirement; reports ready before reserving meaningful capacity; or uses wall-clock sleeps as its only timeout evidence.
Async interface review
- Static and dynamic consumers have intentionally different interfaces or a justified shared adapter.
- Opaque future bounds include every auto trait generic callers require.
- Non-
Sendcontexts are not excluded without reason. - Receiver and argument ownership make lifetime capture understandable.
- Boxing and virtual dispatch occur at named boundaries and are measured when relevant.
- Readiness has reservation semantics and cannot hide an unbounded queue.
- Middleware order preserves one absolute deadline and one logical idempotency identity.
- Error translation retains effect ambiguity.
- Runtime-specific timers, spawns, I/O, and cancellation remain in deliberate adapters.
- Internally spawned work has a supervisor and observable completion.
- Time tests are controlled; randomness is seeded or injected.
- Fakes model state, mocks check narrow interactions, and integration tests prove real protocol behavior.
- Cancellation tests inspect resources and remote effects, not only returned errors.
These abstractions are sufficient to assemble a bounded production service without pretending the type system specifies the entire distributed protocol. The next engineering step is integration: listener ownership, admission, parsing, per-request deadlines, service readiness, idempotent storage, shutdown, observability, and load evidence must agree in one running relay-service.
Sources and version note
The Rust Reference describes return-position impl Trait in traits as an anonymous associated type and documents Edition 2024 automatic capture. Its dyn compatibility rules exclude methods with opaque return types and async functions. The Rust project’s stabilization announcement, “Announcing async fn and return-position impl Trait in traits”, explains the Rust 1.75 baseline, public-trait Send decision, static dispatch, dynamic-dispatch limitation, and trait-variant option.
The async-resilience-lab compiles both an unboxed impl Future + Send storage trait and a Pin<Box<dyn Future + Send>> dynamic adapter on Rust 1.97.0 with Rust 1.85 declared as MSRV. Its service trait and fake are teaching models rather than a compatibility promise for any ecosystem service crate. Tokio controlled-time behavior is pinned to Tokio 1.52.3. Changes to the boundary require renewed API-compatibility and performance evidence, plus a review of whether its readiness protocol still reserves meaningful capacity in the integrated service.
Continue reading
Full table of contents