wakuwaku

Service builder

Register shared dependencies in a type-level registry, build every component from it, and let the compiler check that nothing is missing.

Every wakuwaku service starts with a ServiceBuilder: a registry of the dependencies your components share, such as database handles, clients, and configuration. Cron jobs, event handlers, and gRPC services are then built from it.

If you know Spring, the builder plays the role of the application context, with two differences. Dependencies are looked up by type while compiling, not when the application starts. And there is no scanning, no annotations, and no proxies: you register values explicitly, and each component names the values it is built from.

ServiceBuilder as a type-level linked list: provide::<B, _>() is resolved at compile time to position There<Here>; a component is built either by cloning one dependency (ServiceCreation) or by a compose_* closure that borrows the whole builderlet services = ServiceBuilder::new(a).push(b).push(c);// ServiceBuilder<C, ServiceBuilder<B, ServiceBuilder<A, ()>>>ServiceBuilder<C, …>that: ServiceBuilder<B, …>that: ServiceBuilder<A, ()>this: Ce.g. Iggy clientHeremost recentthis: Be.g. RedisThere<Here>this: Ae.g. DB poolThere<There<Here>>first registeredthat: ()end of the listC ≠ B: step into thatThe trait solver walks the list at compile time and infers _ = There<Here>A type that was never registered has no ProvideAt impl: a compile error, not a panic.provide::<B, _>()resolved at compile time.clone()cheap: Arc, pool, handlecreate_service(b)ServiceCreation | compose_*componentcron · gRPC · Iggy
Each push wraps the builder in a new node; lookup is by type, with no Any, no downcast and no runtime map. A compose_* closure borrows the whole builder and can provide several services.

Registering and looking up

ServiceBuilder::new registers the first value, push registers each further one, and provide borrows a registered value by its type:

src/main.rs
use wakuwaku::ServiceBuilder;

#[derive(Clone)]
pub struct AppConfig {
    pub grpc_addr: SocketAddr,
}

pub fn basics() {
    let services = ServiceBuilder::new(AppConfig { grpc_addr: "[::]:50051".parse().unwrap() })
        .push(AccountStore::default())
        .push(Mailer::default());

    let config: &AppConfig = services.provide::<AppConfig, _>();
    let mailer: &Mailer = services.provide::<Mailer, _>();
    let _ = (config, mailer);
}

The second type parameter of provide is the position of the value in the builder. Pass _ and the compiler infers it, as long as the type is registered exactly once.

How it works

Each push wraps the builder in a new node, so the builder above has the type

ServiceBuilder<Mailer, ServiceBuilder<AccountStore, ServiceBuilder<AppConfig>>>

The registry is a linked list at the type level. Positions are counted from the most recent push: Here is the head (Mailer), There<Here> is one step back (AccountStore), There<There<Here>> is AppConfig. A lookup is the trait bound ProvideAt<T, Position>, which is implemented only at the positions where a T is stored. Resolving it is the trait solver's job. There is no Any, no downcasting, and no hash map at run time; provide compiles to a field access.

The consequence is the important part: asking for a type that was never registered does not compile (see below).

Two ways to build a component

Every kind of component can be built from the builder in two ways: from one registered dependency, through a trait the component implements, or by a closure that borrows the builder and returns the finished component.

ComponentFrom one registered dependencyBy a closure
Cron job.cron_job::<Job, _>().compose_cron_job(|s| job)
Event handler.iggy_consumer::<Handler, _, _>().compose_iggy_consumer(|s| Arc::new(handler))
gRPC server.grpc_service::<XxxServer<_>, Impl, _>(XxxServer::new).compose_grpc_service(|s| XxxServer::new(implementation))
Registered service.construct_subservice::<Service, _>().compose_subservice(|s| service)

The left column needs the component to implement ServiceCreation; the right column needs nothing but the value. The last row registers the result as a new service instead of a component; see Services built from other services.

Building components: ServiceCreation

A component built the first way does not look its dependencies up itself. It declares the one registered type it is built from by implementing ServiceCreation:

pub trait ServiceCreation {
    type Dep: Clone;

    fn create_service(dep: Self::Dep) -> Self;
}
src/components.rs
pub struct ExpireSessions {
    sessions: SessionStore,
}

impl ServiceCreation for ExpireSessions {
    type Dep = SessionStore;
    fn create_service(sessions: SessionStore) -> Self {
        Self { sessions }
    }
}

When you register the component, the builder finds Dep by type, clones it, and passes the clone to create_service. That is why Dep must be Clone, and why it should be cheap to clone: a handle that shares the underlying resource, not the resource itself. Good Dep types are:

  • Db<S, C> handles of wakuwaku::entity (their sources wrap pools or multiplexed connections);
  • sqlx::Pool, redis::aio::MultiplexedConnection, Arc<IggyClient>, and other pool or client handles;
  • your own state behind an Arc, like the SessionStore used on these pages:
src/components.rs
/// Login sessions and when they expire.
#[derive(Clone, Default)]
pub struct SessionStore {
    expires_at: Arc<Mutex<HashMap<String, SystemTime>>>,
}

Each component gets its own clone

Every component built from the same Dep receives a separate clone, taken once, when the component is registered. Shared state must therefore live behind the handle (an Arc, a pool), so that all clones see it.

Composing components with a closure

The compose_* methods take a closure instead of a type parameter. The closure receives the builder by reference, takes what it needs with provide, and returns the finished component. SendInvoices needs both the accounts and the mailer:

src/billing.rs
use std::time::Duration;

use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::{CronJob, CronJobInterval, CronTick};

/// Built from two registered values, so it implements no `ServiceCreation`.
pub struct SendInvoices {
    pub accounts: AccountStore,
    pub mailer: Mailer,
}

impl Processor<CronTick> for SendInvoices {
    type Output = ();
    type Error = MailError;

    async fn process(&self, _tick: CronTick) -> Result<(), MailError> {
        for account in self.accounts.with_open_invoices() {
            self.mailer.send_invoice(&account).await?;
        }
        Ok(())
    }
}

impl CronJob for SendInvoices {
    const INTERVAL: CronJobInterval = CronJobInterval::AfterFinish(Duration::from_secs(60 * 60));
}
src/main.rs
let (_services, cron) = ServiceBuilder::new(AccountStore::default())
    .push(SessionStore::default())
    .push(Mailer::default())
    .cron_job::<ExpireSessions, _>()
    .compose_cron_job(|s| SendInvoices {
        accounts: s.provide::<AccountStore, _>().clone(),
        mailer: s.provide::<Mailer, _>().clone(),
    })
    .start();

What changes compared with ServiceCreation:

  • There is no trait to implement. The closure is the constructor. It can take any number of registered values, do extra work on the way, or ignore its argument and move in a value built beforehand from things that are not registered, as in .compose_cron_job(|_| job).
  • Lookups are still checked by the compiler. Each provide in the closure is an ordinary ProvideAt bound, so asking for a type that was never registered does not compile.
  • Nothing is cloned for you. provide lends a reference; the closure clones the handles it keeps. The returned component is moved into the register center and never cloned afterwards.
  • The component is not registered as a service. It goes into the register center only, so provide cannot find it and no other component can be built from it. To share a value between components, register it with compose_subservice instead.
  • The closure runs right away, at the registration call, just as create_service does.

Both forms chain freely and in any order on the same register center, as cron_job and compose_cron_job do above.

Which one to use

Prefer ServiceCreation when a component is naturally built from one handle. The impl records the dependency next to the type, registration is a single type parameter, and every service and every test builds the component the same way, with create_service.

Use a compose_* method when the component needs several registered values, a value that is not registered (a setting read in main, a client built beforehand), or extra work on the way (such as a gRPC server configured with message size limits). The wiring then sits at the registration call in main, next to the rest of it.

Do not register a struct that only bundles other registered values to make a component fit ServiceCreation; compose the component instead. A struct belongs in the builder when it is a service of its own, with behavior that several components share. That is the next section.

Services built from other services

construct_subservice and compose_subservice build a value in the same two ways, then push it onto the builder. The result is an ordinary registered service: it is found at Here, provide returns it, and it can be the Dep of every component registered after it.

src/billing.rs
/// Account queries on top of the store.
#[derive(Clone)]
pub struct AccountRepo {
    store: AccountStore,
}

impl ServiceCreation for AccountRepo {
    type Dep = AccountStore;
    fn create_service(store: AccountStore) -> Self {
        Self { store }
    }
}

/// Billing logic shared by several components. Cheap to clone: it holds handles.
#[derive(Clone)]
pub struct Billing {
    accounts: AccountRepo,
    mailer: Mailer,
}
src/main.rs
let services = ServiceBuilder::new(AccountStore::default())
    .push(Mailer::default())
    // From one registered dependency, through `ServiceCreation`.
    .construct_subservice::<AccountRepo, _>()
    // From several, through a closure.
    .compose_subservice(|s| Billing {
        accounts: s.provide::<AccountRepo, _>().clone(),
        mailer: s.provide::<Mailer, _>().clone(),
    });

// Both jobs implement `ServiceCreation` with `type Dep = Billing`.
let (_services, cron) = services
    .cron_job::<CloseBillingPeriod, _>()
    .cron_job::<RemindOverdue, _>()
    .start();

Some details:

  • These are methods of ServiceBuilder itself, and each one adds a node like push does. Register services before entering a register center, or on the builder that a finishing method gives back.
  • A registered service is cloned for every component built from it, like any Dep, so it should hold handles (Arcs, pools, clients) rather than the resources themselves.
  • The difference from compose_* is only where the result goes: compose_subservice registers it in the builder, compose_cron_job and its siblings put it in a register center.

Register centers take the builder and give it back

Each kind of component has a register center: a type-level list of components, filled from the builder. The first registration enters it from ServiceBuilder; further registrations, in either form, chain onto it; a finishing method hands the builder back:

ComponentRegister withFinish withYou get back
Cron job.cron_job::<Job, _>(), .compose_cron_job(|s| …).start()(ServiceBuilder, CronRuntime)
Event handler.iggy_consumer::<Handler, _, _>(), .compose_iggy_consumer(|s| …).start(client, config).await?(ServiceBuilder, ConsumerRuntime)
gRPC service.grpc_service::<XxxServer<_>, Impl, _>(XxxServer::new), .compose_grpc_service(|s| …).into_router_fn()(ServiceBuilder, impl FnOnce(Router<L>) -> Router<L>)
any of them.into_parts()(ServiceBuilder, the unstarted register center)

Every registration method takes the builder by value and stores it in the returned register-center builder (as its public builder field), so the next registration can look up its dependencies too. The finishing method returns it, and you pass it on to the next kind of component:

src/main.rs
let services = ServiceBuilder::new(AccountStore::default())
    .push(SessionStore::default())
    .push(AuditLog::default())
    .push(Mailer::default());

let (services, consumers) = services
    .iggy_consumer::<SendWelcomeMail, _, _>()
    .start(iggy.clone(), ConsumerConfig::new("shop", "accounts", vec![0]))
    .await?;

let (services, add_grpc) = services
    .grpc_service::<AccountsServer<_>, AccountsService, _>(AccountsServer::new)
    .compose_grpc_service(|s| {
        AdminServer::new(AdminService {
            store: s.provide::<AccountStore, _>().clone(),
            audit: s.provide::<AuditLog, _>().clone(),
        })
    })
    .into_router_fn();

let (_services, cron) = services.cron_job::<ExpireSessions, _>().start();

Components are created eagerly, at the registration call: that is when Dep is cloned and create_service runs, or when the closure runs. The complete program, including the order in which these steps should run and how to stop everything, is on the Lifecycle page. Details of each register center are on Cron jobs, gRPC, and Consuming events.

into_parts returns the builder and the unstarted register center separately, for code that starts the list later or extends it with push outside the chain. A component built by hand needs no into_parts: register it with the matching compose_* method.

Duplicate types

Registering two values of the same type is allowed, but _ can no longer pick one, so name the position with Here and There:

use wakuwaku::services::builder::{Here, There};

#[derive(Clone)]
pub struct HttpClient {
    pub base_url: &'static str,
}

let services = ServiceBuilder::new(HttpClient { base_url: "https://payments.example" })
    .push(HttpClient { base_url: "https://mail.example" });

// `Here` is the most recent push; each `There` steps one push back.
let mail = services.provide::<HttpClient, Here>();
let payments = services.provide::<HttpClient, There<Here>>();

Registration methods take the same position parameter:

let (_services, cron) = ServiceBuilder::new(HttpClient { base_url: "https://payments.example" })
    .push(HttpClient { base_url: "https://mail.example" })
    .cron_job::<PingPayments, There<Here>>() // the payments client
    .start();

Positions shift whenever you add a push, so in application code prefer giving each role its own type. A newtype costs nothing and keeps _ working:

#[derive(Clone)]
pub struct PaymentsClient(pub HttpClient);

#[derive(Clone)]
pub struct MailClient(pub HttpClient);

let services = ServiceBuilder::new(PaymentsClient(HttpClient { base_url: "https://payments.example" }))
    .push(MailClient(HttpClient { base_url: "https://mail.example" }));

let payments = services.provide::<PaymentsClient, _>();

Handles that differ in their type parameters are different types already: a Db<PgSource, ReadOnly> for the replica and a Db<PgSource, ReadWrite> for the primary can both be registered and looked up with _.

Writing generic code with ProvideAt

Functions that only need some builder containing a type can be generic over the builder. Bound it with ProvideAt and a position parameter; callers never name the position:

src/config.rs
use wakuwaku::services::builder::{ProvideAt, ServiceBuilderPosition};

/// Works with any builder that has exactly one `AppConfig` somewhere in it.
pub fn grpc_addr<S, I>(services: &S) -> SocketAddr
where
    S: ProvideAt<AppConfig, I>,
    I: ServiceBuilderPosition,
{
    services.provide_at().grpc_addr
}

A function can also register a group of dependencies on top of any builder, which is a convenient way to split wiring into modules (similar in spirit to a Spring @Configuration class):

src/sessions.rs
/// Registers the session infrastructure on top of any builder.
pub fn with_sessions<H, C>(
    services: ServiceBuilder<H, C>,
) -> ServiceBuilder<Mailer, ServiceBuilder<SessionStore, ServiceBuilder<H, C>>> {
    services.push(SessionStore::default()).push(Mailer::default())
}
src/main.rs
let services = with_sessions(ServiceBuilder::new(AppConfig {
    grpc_addr: "[::]:50051".parse().unwrap(),
}));
let addr = grpc_addr(&services);

Reading the compile errors

The two errors you will meet are short once you know what they mean. Both outputs below are real, with paths and help: lists trimmed.

A dependency that was never registered. Here ExpireSessions needs a SessionStore, but only a Mailer was registered:

let (_services, cron) = ServiceBuilder::new(Mailer) // no SessionStore registered
    .cron_job::<ExpireSessions, _>()
    .start();
error[E0277]: the trait bound `(): ProvideAt<SessionStore, _>` is not satisfied
   |
37 |         .cron_job::<ExpireSessions, _>()
   |          ^^^^^^^^ the trait `ProvideAt<SessionStore, _>` is not implemented for `()`
   |
   = note: required for `ServiceBuilder<Mailer>` to implement `ProvideAt<SessionStore, There<_>>`
note: required by a bound in `ServiceBuilder::<Head, Chain>::cron_job`

Read it as "the search for SessionStore reached (), the end of the list". The first type in ProvideAt<…> is the missing dependency. provide::<Mailer, _>() on a builder without a Mailer produces the same error.

An ambiguous lookup. Two values of the same type are registered and _ is used:

#[derive(Clone)]
struct Db(&'static str);

let services = ServiceBuilder::new(Db("primary")).push(Db("replica"));
let _db: &Db = services.provide::<Db, _>();
error[E0283]: type annotations needed
  |
8 |     let _db: &Db = services.provide::<Db, _>();
  |                             -------       ^ cannot infer type of the type parameter `I` declared on the method `provide`
  |
  = note: multiple `impl`s satisfying `ServiceBuilder<Db, ServiceBuilder<Db>>: ProvideAt<Db, _>` found in the `wakuwaku` crate:
          - impl<Head, T, Chain, I> ProvideAt<T, There<I>> for ServiceBuilder<Head, Chain>
          - impl<T, Chain> ProvideAt<T, Here> for ServiceBuilder<T, Chain>;

Fix it with an explicit position or, better, a newtype.

On this page