clients
[!NOTE] This is a POC fully implemented by ChatGPT 5.4 with steering from Alex Cyon looking for a simpler DI solution for Rust - inspired by
swift-dependenciesAlso this README (except this NOTE) has been generated by AI.
clients is a concrete-struct dependency injection library for Rust.
The core idea is simple:
- dependencies are plain structs, not traits
- each dependency method is backed by a raw function pointer
- production code resolves concrete clients from
Dependency::live() - tests override individual methods with almost no ceremony
This is intentionally closer to swift-dependencies than to traditional Rust DI crates built around Arc<dyn Trait> or Box<dyn Trait>.
Why this crate exists
Many Rust DI solutions lean on trait objects. That works, but it often adds boilerplate:
- defining traits just for testability
- threading
Arc<dyn Client>orBox<dyn Client>through the app - writing mock structs or mock frameworks
clients takes a different route. A dependency client is a concrete struct whose fields are function pointers. Methods call those function pointers. Tests swap pointers in scoped override layers.
The result is:
- concrete dependency types
- direct call sites
- fast, flat tests
- support for sync and async APIs
Quick start
use ;
client!
client!
How it works
client! expands into a concrete struct whose fields are raw function pointers. get::<Client>() resolves that concrete value from either Dependency::live() or the current override stack, and deps! binds the already-erased function pointers into locals for the current scope. test_deps! works by cloning the concrete client, swapping one or more function pointers, and pushing that value into a scoped global override layer.
The only subtle part is closure erasure: live implementations are written as non-capturing closures, but stored as plain fn(...) -> ... pointers. clients handles that with a small family of arity-specific erasers plus one tightly-scoped unsafe reconstruction step for zero-sized closure types. For the safety discussion, see Safety.
The longer version, including generated code shape, override storage, and a detailed explanation of define_erasers!, lives in HOW_IT_WORKS.md.
Defining clients
Use the client! proc macro to declare a dependency client:
use client;
client!
This generates:
- a concrete
UserClientstruct - methods like
user_client.fetch_user - a
Dependencyimplementation that resolves the live client - a helper module named
user_client - nested helper modules like
user_client::fetch_user
If you omit the implementation on a method, calling it without a test override panics with a descriptive error such as UserClient.fetch_user. That is useful when you want tests to provide the implementation explicitly.
Using dependencies in global functions
The deps! macro binds dependency methods to local names:
use ;
client!
assert_eq!;
This is especially useful in free functions where you do not want to thread a container or context object through the call stack.
Using dependencies as fields
Use #[derive(Depends)] plus #[dep] to build structs from dependencies:
use ;
client!
}
client!
let greeter = from_deps;
assert_eq!;
Depends currently generates:
Default, where#[dep]fields resolve from the dependency system and all other fields useDefault::default()from_deps(), a convenience constructor that forwards toDefault
Example app
See examples/rick_and_morty_cli.rs for a small binary example that:
- declares an
ApiClientwithclient! - uses live
http_client,clock,env,random, andfilesystembuilt-ins - fetches a character from the public Rick and Morty API
- caches successful responses to the system temp directory
Run it with:
If you do not pass an explicit id, the example picks one from a random byte. You can also set DEP_EXAMPLE_CHARACTER_ID to choose the default character id or RICK_AND_MORTY_BASE_URL to point the client at another compatible server.
Async support
Async dependency methods are supported directly:
use ;
client!
}
async
test_deps!
#
assert_eq!;
Client-on-client composition
Clients can depend on other clients directly:
use ;
client!
client! ;
}
}
assert_eq!;
Fine-grained override control
Most tests should use test_deps!, but there is also a lower-level API:
use ;
client!
let _test_scope = new.enter_test;
let override_clock = Clock ;
let _guard = new.set.enter;
assert_eq!;
This is useful when you want to replace a whole client at once or compute an override from the current client via OverrideBuilder::update.
Built-in clients
clients now ships a small built-in client set for common system interactions.
Always available:
clockenvrandomfilesystem
Feature-gated:
uuid:uuidreqwest:http_client
This keeps the default crate small while still letting you opt into UUID generation and an overridable HTTP client only when you need those dependencies.
Safety
clients uses a small amount of unsafe, but it is narrowly scoped. The runtime unsafe is confined to reconstructing zero-sized non-capturing closure values inside the eraser trampolines that turn || ... syntax into plain function pointers. Before that path is taken, the runtime checks mem::size_of::<F>() == 0, which rejects capturing closures and keeps the unsafe logic limited to function items and compiler-generated zero-sized closure types.
The rest of the runtime uses ordinary safe Rust building blocks: TypeId, Any, RwLock, and RAII guards.
(The other unsafe you may notice in tests or examples is just no-op RawWaker boilerplate for polling futures without bringing in an async runtime.)
Performance
For curious engineers, the most useful comparison is not "DI vs no DI", but "clients vs the common Arc<dyn HttpClientTrait> style".
On the synchronous path, once you have already resolved the dependency, clients is in the same performance class and can be a bit faster. A small local no-op microbenchmark on a Macbook Pro M2 Max came out roughly like this:
- resolved
clientsclient: about0.86-0.90 nsper call deps!local function binding: about0.86 nsper callArc<dyn HttpClientTrait>call: about1.16-1.54 nsper call
That matches the implementation: clients ends up doing a raw function-pointer call, while Arc<dyn Trait> does a trait-object call through a vtable. In real HTTP code this difference is usually irrelevant because the network and serialization costs dominate.
There are still real costs to be aware of:
get::<D>()performs a global override lookup guarded by anRwLock- resolved clients are cloned when returned from the override stack
- async dependency methods allocate because they return
BoxFuture - override state is process-global and optimized for application glue and tests, not per-iteration hot loops
The place where clients does lose is repeated resolution. In the same microbenchmark, calling get::<D>() inside the loop was about 12.3 ns per call, because it pays for the global override lookup on every iteration.
So the practical rule is simple: if you resolve once and then call many times, clients is fine and often slightly better than Arc<dyn Trait>. If you repeatedly resolve dependencies inside a hot inner loop, clients is the wrong shape.
That makes clients a poor fit for code such as per-packet networking loops, per-row data processing kernels, schedulers, or other throughput-sensitive inner loops where the dependency method itself does almost no work and the dependency is re-resolved over and over. In those cases, keeping an already-resolved Arc<dyn HttpClientTrait> (or another concrete handle) is more suitable because the lookup cost is paid up front and each call is just one dynamic dispatch.
Supported today
- sync dependency methods
- async dependency methods
- dependency access inside free functions via
deps! - dependency access inside structs via
#[derive(Depends)] - nested dependency scopes
- low-ceremony test overrides via
test_deps! - dependencies implemented in terms of other dependencies
- direct builder-based override control through
OverrideBuilder
Current limitations
- live implementations and test overrides must be non-capturing closures or function items
client!currently supports up to 4 method argumentsDependscurrently supports simple braced structs and does not handle generics or where-clauses- override state is process-global rather than task-local
test_deps!serializes scopes within a process so concurrent tests do not trample each other
Relationship to Rust trait-based DI
clients does not replace all trait-based design. Trait objects are still a good fit when you genuinely need polymorphism as part of the domain model.
This crate is for a narrower problem:
- you want ergonomic dependency injection
- you want very light test setup
- you prefer concrete clients
- you do not want to define traits solely for testability
That trade-off is the entire point of the crate.