clients 0.1.0

Concrete-struct dependency injection for Rust using function pointers instead of trait objects
Documentation

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-dependencies Also 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> or Box<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 clients::{client, deps, test_deps};

#[derive(Debug, Clone, PartialEq, Eq)]
struct User {
    id: u64,
    name: String,
}

#[derive(Clone, Debug, PartialEq, Eq)]
enum UserClientError {
    Unavailable,
}

client! {
    pub struct UserClient as user_client {
        pub fn fetch_user(id: u64) -> Result<User, UserClientError>;
    }
}

client! {
    pub struct Clock as clock {
        pub fn now_millis() -> u64 = || 0;
    }
}

pub fn greeting_for_user(id: u64) -> Result<String, UserClientError> {
    deps! {
        fetch_user = user_client.fetch_user,
        now = clock.now_millis,
    }

    let user = fetch_user(id)?;
    let now = now();
    Ok(format!("Hello, {} @ {}", user.name, now))
}

#[test]
fn greeting_uses_flat_test_overrides() {
    test_deps! {
        user_client.fetch_user => |id| Ok(User { id, name: "Blob".into() }),
        clock.now_millis => || 1234,
    }

    let result = greeting_for_user(42).unwrap();
    assert_eq!(result, "Hello, Blob @ 1234");
}

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 clients::client;

#[derive(Debug, Clone, PartialEq, Eq)]
struct User {
    id: u64,
    name: String,
}

#[derive(Clone, Debug, PartialEq, Eq)]
enum UserClientError {
    Unavailable,
}

client! {
    pub struct UserClient as user_client {
        pub fn fetch_user(id: u64) -> Result<User, UserClientError>;
    }
}

This generates:

  • a concrete UserClient struct
  • methods like user_client.fetch_user
  • a Dependency implementation 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 clients::{DependencyError, client, deps};

client! {
    struct Clock as clock {
        fn now_millis() -> u64 = || 1234;
    }
}

fn now_string() -> Result<String, DependencyError> {
    deps! {
        now = clock.now_millis,
    }

    Ok(now().to_string())
}

assert_eq!(now_string().unwrap(), "1234");

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 clients::{DependencyError, Depends, client};

#[derive(Debug, Clone, PartialEq, Eq)]
struct User {
    id: u64,
    name: String,
}

client! {
    struct UserClient as user_client {
        fn fetch_user(id: u64) -> Result<User, DependencyError> = |id| {
            Ok(User { id, name: format!("User {id}") })
        };
    }
}

client! {
    struct Clock as clock {
        fn now_millis() -> u64 = || 1234;
    }
}

#[derive(Depends)]
struct Greeter {
    #[dep]
    user_client: UserClient,
    #[dep]
    clock: Clock,
}

impl Greeter {
    fn greeting_for_user(&self, id: u64) -> Result<String, DependencyError> {
        let user = self.user_client.fetch_user(id)?;
        Ok(format!("Hello, {} @ {}", user.name, self.clock.now_millis()))
    }
}

let greeter = Greeter::from_deps();
assert_eq!(greeter.greeting_for_user(7).unwrap(), "Hello, User 7 @ 1234");

Depends currently generates:

  • Default, where #[dep] fields resolve from the dependency system and all other fields use Default::default()
  • from_deps(), a convenience constructor that forwards to Default

Example app

See examples/rick_and_morty_cli.rs for a small binary example that:

  • declares an ApiClient with client!
  • uses live http_client, clock, env, random, and filesystem built-ins
  • fetches a character from the public Rick and Morty API
  • caches successful responses to the system temp directory

Run it with:

cargo run --example rick_and_morty_cli --features reqwest -- 1

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 clients::{DependencyError, client, deps, test_deps};

client! {
    struct AsyncClock as async_clock {
        async fn now_millis() -> u64 = || async { 4321 };
    }
}

async fn read_now() -> Result<u64, DependencyError> {
    deps! {
        now = async_clock.now_millis,
    }

    Ok(now().await)
}

test_deps! {
    async_clock.now_millis => || async { 9999 },
}

# fn block_on<F: std::future::Future>(future: F) -> F::Output {
#     use std::pin::Pin;
#     use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
#     let mut future = Box::pin(future);
#     unsafe fn clone(_: *const ()) -> RawWaker { RawWaker::new(std::ptr::null(), &VTABLE) }
#     unsafe fn wake(_: *const ()) {}
#     unsafe fn wake_by_ref(_: *const ()) {}
#     unsafe fn drop(_: *const ()) {}
#     static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop);
#     let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
#     let mut context = Context::from_waker(&waker);
#     loop {
#         match Pin::as_mut(&mut future).poll(&mut context) {
#             Poll::Ready(value) => break value,
#             Poll::Pending => std::thread::yield_now(),
#         }
#     }
# }
assert_eq!(block_on(read_now()).unwrap(), 9999);

Client-on-client composition

Clients can depend on other clients directly:

use clients::{DependencyError, client, deps};

client! {
    struct Clock as clock {
        fn now_millis() -> u64 = || 1234;
    }
}

client! {
    struct Formatter as formatter {
        fn formatted_now() -> Result<String, DependencyError> = || {
            deps! {
                now = clock.now_millis,
            }

            Ok(format!("now={}", now()))
        };
    }
}

assert_eq!(formatter::get().formatted_now().unwrap(), "now=1234");

Fine-grained override control

Most tests should use test_deps!, but there is also a lower-level API:

use clients::{OverrideBuilder, client, erase_sync_0, get};

client! {
    struct Clock as clock {
        fn now_millis() -> u64 = || 0;
    }
}

let _test_scope = OverrideBuilder::new().enter_test();

let override_clock = Clock {
    now_millis: erase_sync_0(|| 1234),
};

let _guard = OverrideBuilder::new().set(override_clock).enter();

assert_eq!(get::<Clock>().now_millis(), 1234);

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:

  • clock
  • env
  • random
  • filesystem

Feature-gated:

  • uuid: uuid
  • reqwest: 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 clients client: about 0.86-0.90 ns per call
  • deps! local function binding: about 0.86 ns per call
  • Arc<dyn HttpClientTrait> call: about 1.16-1.54 ns per 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 an RwLock
  • 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 arguments
  • Depends currently 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.