How Lauren Does Dependency Injection

And why every design decision was made the way it was.

Dependency injection is one of those topics that every framework claims to do well, but few actually get right. Most Python web frameworks either skip it entirely (Flask, FastAPI), bolt it on as an afterthought (Django), or implement it in ways that fight Python's dynamic nature rather than embracing it. Lauren takes a different path — one that is deeply informed by the lessons of NestJS, Rust's Axum, Java Spring, and Google Guice, but fundamentally designed for Python's unique type system and runtime semantics.

This post walks through every major design decision in Lauren's dependency injection system, explaining not just what the framework does, but why each choice was made. If you've ever wondered why some DI systems feel like fighting the language while others feel like the language was built for them, this is the post for you.

The Core Philosophy: Metadata-First, Fail-Fast

Before diving into any specific feature, you need to understand the two principles that drive every DI decision in Lauren:

Metadata-first means that every dependency, every binding, every module boundary, and every lifecycle hook is declared through decorators and type annotations — not through imperative API calls scattered across your codebase. The framework reads this metadata at startup, builds an immutable execution graph, and then never reflects on your code again during request handling. The request path is pure traversal of a pre-compiled graph — no reflection, no registration, no lookup by string keys.

Fail-fast means that if your dependency graph is broken — a missing binding, a circular dependency, a scope conflict, an unresolvable generic type parameter — you find out at startup, not at 3 AM when a user hits an endpoint that triggers a RuntimeError. Lauren's seven-phase startup validation guarantees that if the application boots successfully, every dependency can be resolved for every route.

Why these two principles? Because Lauren was designed for production systems where downtime is expensive and debugging DI failures in production is even more expensive. A framework that discovers a missing dependency at runtime is a framework that ships bugs to users. A framework that validates the entire dependency graph before accepting a single request is a framework that lets you sleep at night.

Consider the alternative: FastAPI's Depends resolves dependencies at request time by calling your callable. There is no graph, no validation, no startup check. If you have a circular dependency between two Depends callables, you discover it when a user triggers the recursion. If you forgot to register a dependency, you get a 500 at runtime. Lauren considers this unacceptable for production workloads, and the entire DI architecture is designed to prevent it.


The Building Blocks: Decorators as Declarative Configuration

Lauren uses three primary decorators to declare the dependency graph:

from lauren import injectable, controller, Module

@injectable(scope="SINGLETON")
class DatabaseService:
    def __init__(self, config: Configuration):
        self.connection = create_connection(config.db_url)

@controller("/users")
class UserController:
    def __init__(self, db: DatabaseService, cache: CacheService):
        self.db = db
        self.cache = cache

@Module(
    imports=[DatabaseModule, CacheModule],
    providers=[DatabaseService, CacheService, UserController],
    exports=[UserController],
)
class UserModule:
    pass

Why decorators, not configuration files?

NestJS popularized the decorator-driven approach in the JavaScript ecosystem, and Lauren adopts it for Python for the same reasons:

  1. Co-location. The configuration lives next to the code it configures. When you open UserController, you immediately see its route prefix from @controller("/users"). You don't need to cross-reference a separate YAML or JSON file to understand how the class is wired.

  2. Refactoring safety. When you rename a class, your IDE renames the decorator too. When you move a class to a different module, the @injectable decorator moves with it. Configuration files become stale the moment you rename something; decorators are refactoring-safe by construction.

  3. Type-checker friendliness. Decorators that accept type parameters work seamlessly with mypy and pyright. String-keyed configuration (like container.register("user_service", UserService)) defeats type checkers entirely — there is no way for mypy to verify that "user_service" corresponds to UserService.

  4. No magic strings. Every binding is identified by a type, not by a string that someone typed in a config file at 2 AM and misspelled. Types are verified by the interpreter at import time and by type checkers statically.

Why not use a fluent API?

Some frameworks (like Guice's AbstractModule.configure()) use a fluent binding API:

# What Lauren explicitly does NOT do
bind(DatabaseService).to(PostgresService).in_scope(SINGLETON)

Lauren rejected this approach for Python because fluent APIs work well in languages with compile-time type safety (Java, C#), where the compiler catches mismatched types. In Python, a fluent API becomes a runtime-only contract — you can bind DatabaseService to a string, and you won't find out until the binding is resolved. Decorators combined with type annotations give you the same expressiveness with the added benefit of static analysis.


Scoping: Why Three Scopes Are Enough

Lauren supports exactly three dependency scopes:

ScopeLifetimeExample Use Case
SINGLETONOne instance per application lifetimeDatabase connections, configuration, caches
REQUESTOne instance per HTTP requestRequest-scoped identity, per-request transaction, tenant context
TRANSIENTNew instance per injectionNon-cached computations, per-use factories

Why not more scopes?

Spring has five scopes (singleton, prototype, request, session, application). Guice has custom scope extensions. Why does Lauren stop at three?

The answer is practical: every additional scope is a vector for bugs. A SESSION scope requires session storage, which requires distributed state, which requires serialization, which requires that your injectable objects are pickle-able. That is a constraint that leaks into every service you write. A THREAD scope is meaningless in an async framework where a single coroutine may hop across multiple OS threads. A CUSTOM scope requires the developer to implement lifecycle management correctly — and lifecycle management is exactly the kind of infrastructure code that the framework should handle, not the application developer.

Lauren's three scopes cover 99% of real-world use cases. SINGLETON is the default because it is the safest: no shared mutable state between requests (the instance is created once and never re-created), no threading concerns (the instance is immutable after construction), and no memory leaks (the instance lives as long as the application). REQUEST exists because per-request state is a genuine need — tenant isolation, per-request database transactions, request-scoped tracing IDs — and it must be managed by the framework to avoid leaking state between requests. TRANSIENT exists for the rare case where you genuinely need a fresh instance every time, though it is used sparingly because it defeats caching and makes the dependency graph harder to reason about.

Why SINGLETON is the default

In NestJS, the default scope is also singleton. In Spring, the default scope is singleton. In Guice, the default scope is singleton. There is a reason every mature DI framework defaults to singleton: it is the scope with the fewest failure modes. A singleton cannot accidentally share state between requests because it is typically read-only after construction. A singleton does not leak memory because it lives as long as the application. A singleton does not suffer from threading issues because it is constructed once, before any requests arrive.

When you reach for REQUEST scope, you should have a specific reason — and that reason should be "I need state that is isolated to a single HTTP request." When you reach for TRANSIENT scope, you should have an even more specific reason. Lauren's default nudges you toward the safest option and makes you opt in to more complex scopes explicitly.


Protocol Binding: Why Lauren Chose Structural Typing

Python's typing.Protocol enables structural subtyping — a class satisfies a Protocol by having the right methods, without explicitly inheriting from it. Lauren uses Protocols as the primary way to bind interfaces to implementations:

from typing import Protocol
from lauren import injectable, Module

class CacheService(Protocol):
    async def get(self, key: str) -> bytes | None: ...
    async def set(self, key: str, value: bytes, ttl: int | None = None) -> None: ...

@injectable()
class RedisCacheService:
    async def get(self, key: str) -> bytes | None:
        return await self.redis.get(key)

    async def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
        await self.redis.set(key, value, ex=ttl)

@Module(
    providers=[
        (CacheService, RedisCacheService),  # Bind Protocol to implementation
    ],
    exports=[CacheService],
)
class CacheModule:
    pass

Why Protocols instead of ABCs?

Python has two ways to define interfaces: abc.ABC (nominal subtyping) and typing.Protocol (structural subtyping). Lauren chose Protocol for three reasons:

  1. No inheritance contamination. An ABC requires the implementation to explicitly inherit from it: class RedisCacheService(CacheService, ABC). This means your implementation now depends on your abstraction, which is backwards — the abstraction should depend on the domain, and the implementation should depend on the abstraction. Protocol inverts this: the implementation is a plain class with no dependency on the interface definition.

  2. Third-party integration. If you want to inject a class from a third-party library as a CacheService, you cannot make it inherit from your ABC — you don't control its source code. With Protocol, any class that has get and set methods satisfies the interface, regardless of its inheritance hierarchy.

  3. Post-hoc abstraction. In real codebases, you often discover the interface after writing the implementations. Protocol lets you extract the interface without modifying existing classes. ABC forces you to refactor every implementation to inherit from the new base class.

Why not just use the concrete class?

You can inject concrete classes directly in Lauren:

@injectable()
class RedisCacheService:
    ...

@controller("/users")
class UserController:
    def __init__(self, cache: RedisCacheService):  # Works fine
        ...

And for small applications, that is perfectly fine. Protocol binding becomes valuable at scale, when you need to:

  • Swap implementations per environment. RedisCacheService in production, InMemoryCacheService in tests, MemcachedCacheService in staging — all behind CacheService.
  • Mock in tests. Inject a mock CacheService without coupling your tests to Redis.
  • Decouple modules. Module A exports CacheService (a Protocol). Module B imports CacheService without knowing or caring whether it is backed by Redis, Memcached, or an in-memory dict.

Lauren supports both patterns because different scales demand different tradeoffs. But the framework nudges you toward Protocol binding through its module system — when you export a service from a module, exporting the Protocol rather than the concrete class makes the module boundary a true abstraction boundary.


Constructor Injection: Why It Is the Only Option

Lauren only supports constructor injection. There is no @Autowired-style field injection, no setter injection, no method injection (except for Depends[T] in route handler parameters, which is a different mechanism for request-time resolution, not DI container injection).

@injectable()
class OrderService:
    def __init__(self, db: DatabaseService, cache: CacheService, event_bus: EventBus):
        self.db = db              # Required — cannot construct without it
        self.cache = cache        # Required — cannot construct without it
        self.event_bus = event_bus  # Required — cannot construct without it

Why no field injection?

Field injection — where the DI container sets attributes on an already-constructed object — is popular in Java Spring because it reduces boilerplate:

// Spring field injection (what Lauren does NOT support)
@Service
class OrderService {
    @Autowired
    private DatabaseService db;  // Set by the container after construction
    
    @Autowired
    private CacheService cache;  // Set by the container after construction
}

Lauren rejects this pattern for three reasons:

  1. Incomplete objects. With field injection, there is a window between object construction and dependency injection where the object exists in an invalid state — its fields are None or unset. If any method is called during this window, it crashes. With constructor injection, the object is fully constructed and valid the moment __init__ returns.

  2. Hidden dependencies. Field injection makes it easy to add dependencies without realizing it — you just add another @Autowired field. Constructor injection forces every dependency to be listed in the constructor signature, making the full dependency surface visible at a glance.

  3. Immutability. Constructor injection allows dependencies to be stored as read-only attributes (using __slots__ or properties without setters). Field injection requires mutable attributes, which opens the door to accidental mutation.

Why no setter injection?

Setter injection is sometimes advocated for optional dependencies:

# What Lauren does NOT support
class OrderService:
    def set_cache(self, cache: CacheService):
        self.cache = cache

Lauren handles optional dependencies differently: if a dependency is truly optional, use Optional[T] in the constructor and the container will inject None if no binding exists:

@injectable()
class OrderService:
    def __init__(self, db: DatabaseService, cache: Optional[CacheService] = None):
        self.db = db
        self.cache = cache  # None if no CacheService binding exists

This keeps the dependency visible in the constructor signature while making its optionality explicit through Python's own Optional type. The container validates at startup that all non-optional dependencies are satisfied, and allows optional ones to be absent.


The Seven-Phase Startup: Why Validation Beats Convenience

When you call LaurenFactory.create(AppModule), the framework executes a seven-phase startup sequence before the application accepts any requests. This is not arbitrary ceremony — each phase validates a specific aspect of the dependency graph:

  1. Module Discovery — Scan all @Module decorators, resolve imports/exports, detect circular imports between modules.
  2. Binding Registration — Register all @injectable and Protocol bindings in the container. Fail if duplicate bindings exist for the same key without explicit overrides.
  3. Dependency Graph Construction — Inspect constructor annotations for every injectable and controller, building a directed graph of dependencies.
  4. Cycle Detection — Traverse the dependency graph and fail if any cycles are found. A cycle means the container cannot determine construction order.
  5. Scope Assignment — Assign scopes based on @injectable(scope=...) declarations. Warn about scope mismatches (e.g., a SINGLETON depending on a REQUEST-scoped service).
  6. Lifecycle Hook Ordering — Topologically sort @post_construct and @pre_destruct hooks so that initialization runs in dependency order and teardown runs in reverse.
  7. Final Validation — Verify that every dependency referenced in the graph has a corresponding binding. Verify that all generic type parameters are concrete and resolvable.

Why seven phases instead of one?

You could imagine a simpler startup that just tries to instantiate everything and sees what happens. The problem is error messages. When a monolithic startup fails, you get a stack trace that points to the symptom (e.g., TypeError: __init__() missing 1 required positional argument: 'cache'), not the cause (e.g., CacheService is provided by CacheModule which is not imported by UserModule).

Each phase produces targeted, actionable error messages. Phase 4 (cycle detection) tells you exactly which services form the cycle and suggests which dependency to break. Phase 5 (scope assignment) tells you that OrderService(SINGLETON) depends on RequestCache(REQUEST), explains why this is a problem (a singleton outlives any individual request, so the request-scoped dependency would be stale), and suggests either making OrderService request-scoped or making RequestCache a singleton. Phase 7 (validation) lists every unresolved dependency with the module that needs it and the modules that could provide it.

Why not lazy resolution?

Some frameworks (like FastAPI) resolve dependencies lazily — they only check if a dependency can be constructed when a route is actually called. This is convenient during development because you can start the application with an incomplete graph and fix things incrementally. It is dangerous in production because it means an incomplete graph can deploy successfully, and the first user to hit the broken route gets a 500 error.

Lauren's seven-phase startup is a deliberate tradeoff: it makes the development feedback loop slightly longer (you must fix all dependency errors before the app starts), but it makes the production failure rate dramatically lower. For teams that practice CI/CD, this is the right tradeoff — a build that fails at startup is a build that never reaches production.


Cycle Detection: Why Circuits Break Applications

A dependency cycle occurs when service A depends on service B, and service B depends on service A (directly or transitively). Lauren detects all cycles at startup and refuses to boot:

DependencyCycleError: Circular dependency detected: OrderService → PaymentService → OrderService Suggestion: Break the cycle by introducing an interface. 1. Extract the shared logic into a new service that both depend on. 2. Use Protocol binding to decouple OrderService from PaymentService. 3. Consider using an EventBus to communicate between them asynchronously.

Why not allow cycles with lazy resolution?

Some frameworks (like Spring) allow cycles when using field injection — they construct both objects with None fields, then retroactively inject the references. This "works" but creates several problems:

  1. Initialization order is undefined. When @post_construct hooks run on cyclic services, there is no correct order — OrderService.post_construct() might run before PaymentService has been injected into it, causing a NoneType error.

  2. Tight coupling. A cycle between two services means they can never be understood independently. Any change to OrderService might affect PaymentService and vice versa. This coupling compounds over time until the cycle becomes an unmaintainable hairball.

  3. Testability nightmare. You cannot test OrderService in isolation because it requires PaymentService, which requires OrderService, which requires... You end up with complex mock setups that duplicate the production dependency graph.

Lauren's decision to prohibit cycles is a design choice that forces you to decompose your services properly. In practice, cycles almost always indicate a missing abstraction — a shared concept that both services depend on, which should be extracted into its own service. The error message even suggests this, pointing you toward the correct architectural fix rather than letting you paper over the problem with lazy proxies.


Multi-Bindings: When You Need More Than One

Sometimes you genuinely need multiple implementations of the same interface injected into the same consumer. Lauren supports this through multi-bindings:

from lauren import injectable, Module
from typing import Protocol

class NotificationSender(Protocol):
    async def send(self, message: str) -> None: ...

@injectable()
class EmailSender:
    async def send(self, message: str) -> None:
        await self.smtp_client.send(message)

@injectable()
class SmsSender:
    async def send(self, message: str) -> None:
        await self.twilio_client.send(message)

@injectable()
class PushSender:
    async def send(self, message: str) -> None:
        await self.fcm_client.send(message)

@Module(
    providers=[
        (NotificationSender, EmailSender, multi=True),
        (NotificationSender, SmsSender, multi=True),
        (NotificationSender, PushSender, multi=True),
    ],
    exports=[NotificationSender],
)
class NotificationModule:
    pass

@injectable()
class NotificationService:
    def __init__(self, senders: list[NotificationSender]):
        self.senders = senders  # [EmailSender, SmsSender, PushSender]

    async def broadcast(self, message: str) -> None:
        for sender in self.senders:
            await sender.send(message)

Why explicit multi-binding?

Without explicit multi-binding, the container would have to choose between three behaviors when multiple implementations of the same interface are registered:

  1. Last-wins — silently discard earlier registrations. This hides bugs — you registered EmailSender first, but the container ignores it because SmsSender was registered later.
  2. Error — fail if duplicate bindings exist. This forces you to use multi-binding explicitly, which is what Lauren does.
  3. Auto-collect — automatically inject all implementations as a list. This is magical — it works until it doesn't, and when it breaks (because you accidentally registered a test mock as a multi-binding), the error is incomprehensible.

Lauren chooses option 2 with an escape hatch: if you want multiple bindings of the same type, you must declare multi=True. This makes the intent explicit and prevents accidental shadowing. The container then injects all multi-bound implementations as a list[T], which is type-safe and verifiable at startup.


Generic Type Injection: Solving Python's Hardest DI Problem

This is where Lauren's DI system goes beyond every other Python framework. Python's type system erases generic type parameters at runtime — Repository[User] and Repository[Order] both appear as just Repository to the DI container. This forces developers into boilerplate subclasses, string-keyed workarounds, or factory patterns that lose startup-time validation.

Lauren solves this with GenericToken — a hashable, comparable key that uniquely identifies a parameterized generic type:

from typing import Generic, TypeVar
from lauren import injectable, controller, get, Module

T = TypeVar("T")

@injectable()
class Repository(Generic[T]):
    def __init__(self, db: Database):
        self.db = db

    async def find_by_id(self, id: int) -> T: ...
    async def save(self, entity: T) -> None: ...

@Module(
    providers=[
        Repository[User],   # Registered as GenericToken(Repository, (User,))
        Repository[Order],  # Registered as GenericToken(Repository, (Order,))
    ]
)
class AppModule:
    pass

@controller("/users")
class UserController:
    def __init__(self, repo: Repository[User]):  # Resolves to the User-specific binding
        self.repo = repo

    @get("/")
    async def list_users(self) -> list[User]:
        return await self.repo.find_all()

Why is this hard in Python?

In .NET, the CLR preserves generic type information at runtime — Repository<User> and Repository<Order> are genuinely different types with different metadata. In Java, Spring uses ParameterizedType and Guice uses TypeLiteral<> to reify generic type arguments. Python has no such mechanism at the language level.

What Python does have since 3.9 is __origin__ and __args__ on generic aliases:

>>> Repository[User].__origin__
<class 'Repository'>
>>> Repository[User].__args__
(User,)

This is enough to reconstruct the type information at runtime — but only if you know where to look. Lauren's GenericTypeResolver inspects constructor annotations using typing.get_type_hints(cls.__init__, include_extras=True), decomposes generic aliases into origin + args, and constructs GenericToken instances that serve as unique keys in the binding registry.

Why GenericToken instead of using the alias directly?

You might wonder why Lauren introduces a new GenericToken class instead of using Repository[User] directly as a dictionary key. Two reasons:

  1. Hash stability. In Python, Repository[User] creates a new _GenericAlias object each time it is subscripted. Two separate Repository[User] expressions produce two different objects that happen to compare equal but have different identities. Using them as dictionary keys works (because __hash__ and __eq__ are implemented), but it depends on CPython implementation details that are not guaranteed across Python implementations. GenericToken pre-computes its hash from (origin, args) and uses identity comparison for the origin class, which is deterministic and portable.

  2. Open generic support. Lauren needs to distinguish between "closed" generic tokens like Repository[User] (where all type parameters are concrete) and "open" generic tokens like Repository[T] (where T is still a TypeVar). Open tokens are used for generic provider registration — they match any closed specialization during resolution. This distinction cannot be expressed with raw _GenericAlias objects, but it is natural with GenericToken.is_open().

Why generic providers?

Direct registration (Repository[User] in the providers list) works when you know all the type parameter combinations at module definition time. But what if you want the container to construct Repository[T] on demand for any T? This is where generic providers come in:

from lauren import generic_provider, Module

@generic_provider(Repository)
def provide_repository(
    entity_type: type[T],
    db: Database,
) -> Repository[T]:
    table_name = entity_type.__name__.lower() + "s"
    return Repository(entity_type, db, table=table_name)

@Module(
    providers=[
        provide_repository,  # Handles Repository[T] for any T
    ]
)
class AppModule:
    pass

When the container needs Repository[User], it calls provide_repository(User, db_instance). The entity_type parameter is automatically injected from the type argument — the container passes the concrete type as a runtime value.

This design was chosen over the alternative — requiring developers to pre-register every type parameter combination — because it scales better. In a project with 20 entities and 3 generic services, that is 60 registrations in each module. With generic providers, it is 3 registrations total. The tradeoff is that the provider must be deterministic and side-effect-free, because the container may call it at any time and must always produce the same result for the same type arguments.

Why not just use factories?

The factory pattern is the standard Python workaround for generic DI:

@injectable()
class RepositoryFactory:
    def create(self, entity_type: type[T]) -> Repository[T]:
        return Repository(entity_type, self.db)

Lauren's generic providers are fundamentally different from factories in one critical way: the container can validate generic providers at startup. When a generic provider is registered for Repository, the container can verify at startup that every Repository[T] dependency in the graph can be satisfied by that provider — because the provider's own dependencies (Database in the example above) are checked during the seven-phase startup. A factory hides this: the container cannot see what the factory will produce until it is called at runtime, which means the startup validation is incomplete.

This is the same reason Lauren prefers constructor injection over field injection: it makes the entire dependency graph visible to the framework, enabling comprehensive validation. Generic providers extend this principle to generic types — the provider declares its dependencies in its function signature, just like a constructor, and the container validates them at startup.


Module Boundaries: Encapsulation at the DI Level

Lauren's module system is directly inspired by NestJS, and it serves the same purpose: encapsulation. A module declares which services it provides, which it exports (makes available to other modules), and which other modules it imports:

@Module(
    imports=[DatabaseModule, CacheModule],
    providers=[UserRepository, UserService],
    exports=[UserService],
)
class UserModule:
    pass

In this example, UserRepository is an internal implementation detail of UserModule — it is provided but not exported. Other modules cannot inject UserRepository directly. They can only inject UserService, which is the module's public API.

Why enforce module boundaries?

Without module boundaries, every service is accessible from everywhere, and the dependency graph degenerates into a tangled web where changing any service risks breaking any other service. Module boundaries enforce the same principle as public vs private in Java or pub vs private in Rust: they make the intended API surface explicit and prevent accidental coupling to implementation details.

The practical benefit is refactoring safety. When you need to change UserRepository — say, replacing SQLAlchemy with raw SQL — you can do so confidently because no code outside UserModule depends on it. The only contract UserModule has with the rest of the application is UserService, and as long as UserService's public methods remain the same, the change is invisible to consumers.

Why circular import detection?

Module A imports Module B, and Module B imports Module A. This creates a circular dependency at the module level that mirrors the circular dependency at the service level. Lauren detects this during Phase 1 of startup and fails with a clear error message.

Circular module imports are worse than circular service imports because they indicate architectural decomposition failure. If Module A needs something from Module B and Module B needs something from Module A, the shared functionality should be extracted into Module C, which both A and B import. This is the same refactoring that the cycle detection suggests for service-level cycles, applied at the module level.


Lifecycle Hooks: Controlled Initialization and Teardown

Lauren supports @post_construct and @pre_destruct lifecycle hooks on injectable services:

from lauren import injectable, post_construct, pre_destruct

@injectable(scope="SINGLETON")
class DatabaseService:
    def __init__(self, config: Configuration):
        self.config = config
        self.pool = None

    @post_construct
    async def initialize(self) -> None:
        """Called after all dependencies are injected."""
        self.pool = await create_connection_pool(self.config.db_url)
        await self.pool.execute("SELECT 1")  # Verify connectivity

    @pre_destruct
    async def shutdown(self) -> None:
        """Called before the application shuts down."""
        if self.pool:
            await self.pool.close()

Why dedicated hooks instead of __init__?

__init__ is synchronous. Database connections, HTTP clients, file handles, and other resources require async initialization. Lauren could work around this by making __init__ async, but that would break Python's object model — __init__ is called by __new__ and cannot be a coroutine.

The @post_construct hook runs after the DI container has fully constructed the object and injected all dependencies. This means the hook can safely use any dependency, including other services that are also going through their own @post_construct phase. The hooks are ordered topologically — if DatabaseService depends on Configuration, then Configuration.post_construct() runs before DatabaseService.post_construct().

Why topological ordering?

Consider this scenario: CacheService depends on DatabaseService, and both have @post_construct hooks. If CacheService.post_construct() runs first and tries to use DatabaseService, it might access a database connection that has not been established yet. Topological ordering ensures that DatabaseService.post_construct() runs first, establishing the connection pool, and then CacheService.post_construct() runs, safely warming the cache from the database.

The reverse ordering applies to @pre_destruct: CacheService.pre_destruct() runs first (flushing pending writes to the database), and then DatabaseService.pre_destruct() runs (closing the connection pool). This prevents the common bug where a shutdown hook tries to write to a database that has already been disconnected.


How It All Fits Together: A Complete Example

Let's put everything together in a realistic example — an e-commerce order processing service:

from typing import Generic, TypeVar, Protocol, Optional
from lauren import (
    injectable, controller, Module, get, post,
    generic_provider, post_construct, pre_destruct,
)

T = TypeVar("T")

# --- Protocols (Interfaces) ---

class CacheService(Protocol):
    async def get(self, key: str) -> bytes | None: ...
    async def set(self, key: str, value: bytes, ttl: int | None = None) -> None: ...

class EventBus(Protocol):
    async def publish(self, event: str, payload: dict) -> None: ...

# --- Generic Repository ---

@injectable()
class Repository(Generic[T]):
    def __init__(self, db: DatabaseService):
        self.db = db

    async def find_by_id(self, id: int) -> Optional[T]: ...
    async def save(self, entity: T) -> None: ...

# --- Generic Provider ---

@generic_provider(Repository)
def provide_repository(entity_type: type[T], db: DatabaseService) -> Repository[T]:
    return Repository(entity_type, db)

# --- Domain Services ---

@injectable(scope="SINGLETON")
class OrderService:
    def __init__(
        self,
        order_repo: Repository[Order],
        product_repo: Repository[Product],
        cache: CacheService,
        event_bus: EventBus,
    ):
        self.order_repo = order_repo
        self.product_repo = product_repo
        self.cache = cache
        self.event_bus = event_bus

    @post_construct
    async def warm_cache(self) -> None:
        """Pre-load popular products into cache."""
        products = await self.product_repo.find_all(limit=100)
        for p in products:
            await self.cache.set(f"product:{p.id}", p.json(), ttl=3600)

# --- Controller ---

@controller("/orders")
class OrderController:
    def __init__(self, order_service: OrderService):
        self.order_service = order_service

    @get("/")
    async def list_orders(self) -> list[Order]:
        return await self.order_service.order_repo.find_all()

    @post("/")
    async def create_order(self, body: Json[CreateOrderRequest]) -> Order:
        order = await self.order_service.create_order(body)
        await self.order_service.event_bus.publish("order.created", order.dict())
        return order

# --- Modules ---

@Module(
    providers=[DatabaseService],
    exports=[DatabaseService],
)
class DatabaseModule:
    pass

@Module(
    providers=[
        (CacheService, RedisCacheService),
    ],
    exports=[CacheService],
)
class CacheModule:
    pass

@Module(
    providers=[
        (EventBus, RabbitMQEventBus),
    ],
    exports=[EventBus],
)
class EventBusModule:
    pass

@Module(
    imports=[DatabaseModule, CacheModule, EventBusModule],
    providers=[
        provide_repository,           # Generic provider for Repository[T]
        Repository[Order],            # Concrete binding
        Repository[Product],          # Concrete binding
        OrderService,
        OrderController,
    ],
    exports=[OrderService, OrderController],
)
class OrderModule:
    pass

When LaurenFactory.create(OrderModule) runs, the seven-phase startup:

  1. Discovers all modules and their import/export relationships
  2. Registers every provider — including GenericToken(Repository, (Order,)) and GenericToken(Repository, (Product,))
  3. Builds the dependency graph: OrderController → OrderService → Repository[Order], Repository[Product], CacheService, EventBus
  4. Detects no cycles (the graph is a DAG)
  5. Assigns scopes: OrderService is SINGLETON, CacheService is bound to RedisCacheService (SINGLETON by default)
  6. Orders lifecycle hooks: DatabaseService.post_construct() first, then OrderService.warm_cache()
  7. Validates that every dependency is satisfied and every generic type parameter is concrete

If any of these checks fail, the application does not start. The developer gets a precise error message pointing to the exact service and missing dependency. This is the core value proposition of Lauren's DI: if it boots, it works.


Comparison with Other Frameworks

FeatureLaurenNestJSFastAPISpringGuice
Startup validation7-phase, fail-fastPartialNonePartialPartial
Generic DIFull (GenericToken)N/A (no generics in JS)NonePartial (ParameterizedType)Full (TypeLiteral)
Protocol bindingNative (typing.Protocol)N/ANoneInterfaces (ABC)Interfaces
Scope management3 scopes, compile-time4 scopesNone5+ scopesCustom
Cycle detectionStartup, with suggestionsRuntimeNoneRuntime (lazy)Startup
Multi-bindingsExplicit multi=TrueMulti-providersNone@QualifierMulti-binders
Module encapsulationImports/exportsImports/exportsNonePackagesModules (partial)
Lifecycle hooksTopological orderingTopological orderingNone@PostConstructNone
Decorator-driven APIFullFullPartialAnnotations (Java)Module API
Constructor injectionOnly optionOnly optionDepends (runtime)OptionalOnly option

The key differentiator is startup validation combined with generic type injection. No other Python framework validates the entire dependency graph at boot time. No other Python framework supports generic types as first-class injectable keys. Lauren's position is unique: it brings the type safety of .NET and Java DI to Python while respecting Python's dynamic nature through decorator-driven metadata rather than annotation processors or code generation.


The Design Decisions, Summarized

Every design decision in Lauren's DI system traces back to one principle: if it can be validated at startup, it must be validated at startup.

DecisionRationale
Decorator-driven APIRefactoring-safe, type-checker friendly, co-located with code
Constructor injection onlyNo partially-constructed objects, all dependencies visible
Three scopes onlyCovers 99% of use cases; additional scopes create more bugs than they solve
Protocol over ABCStructural typing, third-party integration, post-hoc abstraction
Seven-phase startupTargeted error messages for each class of problem
Cycle prohibitionForces proper decomposition; cycles indicate missing abstractions
Explicit multi-bindingPrevents accidental shadowing; makes intent clear
GenericToken for genericsPortable, hash-stable, supports open/closed distinction
Generic providers over factoriesStartup-time validation of generic dependencies
Module boundariesEncapsulation, refactoring safety, clear public API surface
Topological lifecycle hooksSafe initialization order, safe teardown order

Lauren's dependency injection is not the simplest DI system you will ever use. It is not designed to be simple — it is designed to be correct. Every constraint the framework imposes (no cycles, no field injection, no lazy resolution) exists because the alternative creates problems that are harder to debug than the constraint is to work around. If you have ever spent hours debugging a NoneType error caused by a lazily-injected dependency, or traced a production outage to a circular dependency that only manifested under load, you understand why Lauren makes these tradeoffs.

The reward for accepting these constraints is a development experience where, once the application starts, you can trust the dependency graph completely. No surprises at runtime. no missing dependencies in production, no circular initialization order bugs. The DI container becomes infrastructure you never think about — which is exactly what infrastructure should be.

Comments

Comment integration is ready, but `NEXT_PUBLIC_GISCUS_REPO_ID` and `NEXT_PUBLIC_GISCUS_CATEGORY_ID` are not configured yet.

Older post

No older post yet.

Newer post

No newer post yet.