lilydocs
HTTP APIWebSocketConsumer
Fundamentals4 min read

Dependency injection

Combine Singleton workers with delivery-scoped services using the correct lifetimes.

Consumer's DI container separates business services from workers. When a handler receives a message and calls the appropriate service, the same business logic can be reused from HTTP API, WebSocket, or another message contract.

Dependencies and public imports#

The first application's manifest is sufficient. Injectable and ServiceTrait are available through lilyrs::consumer; the handler's Service<T> extractor is under lilyrs::queue. Consumer needs no separate derive, registry, or injection feature dependency.

Choose lifetimes around deliveries#

Lifetime Construction and sharing Suitable use
Singleton Shared within one application container Configuration, shared policy, connection pool
Scoped Resolutions share an instance within one delivery scope Message processing service and delivery state
Transient A new instance for each resolution Short-lived objects that do not require sharing

A Singleton worker can be called by many deliveries. Keeping a Scoped service in its fields would outlive that delivery. Use a Service<T> handler parameter instead. Middleware, guards, and extractors resolving the same Scoped service within one delivery share that instance.

A complete example with a Scoped business service#

The following di.rs file replaces the first application's src/main.rs. It uses the same Cargo manifest, lily.toml, and publisher:

di.rs
use lilyrs::{
    consumer::{Consumer, ConsumerError, Injectable, ServiceTrait},
    queue::{DeliveryCancellation, EventId, Json, QueueHandlerError, Service, queue, queue_service},
};
use serde::Deserialize;
#[derive(Default, Injectable)]
#[service(lifetime = "Scoped")]
struct OrderService;
impl ServiceTrait for OrderService {}
impl OrderService {
    async fn process(&self, id: String) -> Result<(), QueueHandlerError> {
        if id.is_empty() { return Err(QueueHandlerError::permanent("ORDER_ID_REQUIRED")); }
        Ok(())
    }
}
#[derive(Deserialize)]
struct OrderCreated { order_id: String }
#[derive(Default, Injectable)]
#[service(lifetime = "Singleton")]
struct OrderWorker;
impl ServiceTrait for OrderWorker {}
#[queue_service]
impl OrderWorker {
    #[queue("orders.created", version = 1, content = "json")]
    async fn created(&self, orders: Service<OrderService>, _event_id: EventId,
        _cancellation: DeliveryCancellation, Json(event): Json<OrderCreated>) -> Result<(), QueueHandlerError> {
        orders.process(event.order_id).await
    }
}
#[tokio::main]
async fn main() -> Result<(), ConsumerError> { Consumer::run().await }

OrderService rejects an empty ID as a permanent error. Service<OrderService> resolves through the active scope. The _event_id and _cancellation parameters demonstrate metadata and signal access; receiving them alone does not make an external operation idempotent or cancellation-safe.

Constructor injection and interfaces#

Rust
use std::sync::Arc;
use lilyrs::consumer::{Injectable, ServiceTrait};

#[derive(Default, Injectable)]
#[service(lifetime = "Singleton")]
struct OrderPolicy;
impl ServiceTrait for OrderPolicy {}

#[derive(Injectable)]
#[service(lifetime = "Scoped")]
struct OrderApplication {
    #[inject]
    policy: Arc<OrderPolicy>,
}
impl ServiceTrait for OrderApplication {}

This fragment shows a Scoped service retaining a Singleton policy. An #[inject] field must use Arc<T> or Arc<dyn Trait>. Default is unnecessary when every named field is injected; satisfy the derive's Default requirement for runtime fields that are not injected.

Interface registrations use the same container. Once an interface is bound in the application's DI registration, a handler can resolve Service<dyn OrderOperations>. Declaring a Rust trait alone does not create a DI binding. The worker owner must also be a registered service; it need not be Singleton, and a Scoped worker resolves within the active delivery scope.

Initialization, disposal, and external containers#

ServiceTrait::initialize(&mut self) and dispose(&self) are DI lifecycle hooks. Handlers do not invoke them manually. Lily transfers each delivery scope to cleanup after the normal pipeline, and does not treat framework-owned application container disposal as successful before queue runtime closure is established.

A container passed through .container(Arc<ApplicationContainer>) remains caller-owned. Consumer still manages queue runtime shutdown; the parent owner decides when to close the external container. Keep it alive while Consumer users remain active, and do not interpret an error result as proof of drain. Managed Consumer exposes reports for managing this boundary.

Sources for this page v0.1.0

This guide is based on the reviewed framework commit.

QUICK START
Documentation
Source code