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:
passWhy decorators, not configuration files?
NestJS popularized the decorator-driven approach in the JavaScript ecosystem, and Lauren adopts it for Python for the same reasons:
-
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. -
Refactoring safety. When you rename a class, your IDE renames the decorator too. When you move a class to a different module, the
@injectabledecorator moves with it. Configuration files become stale the moment you rename something; decorators are refactoring-safe by construction. -
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 toUserService. -
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:
| Scope | Lifetime | Example Use Case |
|---|---|---|
SINGLETON | One instance per application lifetime | Database connections, configuration, caches |
REQUEST | One instance per HTTP request | Request-scoped identity, per-request transaction, tenant context |
TRANSIENT | New instance per injection | Non-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:
passWhy 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:
-
No inheritance contamination. An
ABCrequires 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. -
Third-party integration. If you want to inject a class from a third-party library as a
CacheService, you cannot make it inherit from yourABC— you don't control its source code. With Protocol, any class that hasgetandsetmethods satisfies the interface, regardless of its inheritance hierarchy. -
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.
RedisCacheServicein production,InMemoryCacheServicein tests,MemcachedCacheServicein staging — all behindCacheService. - Mock in tests. Inject a mock
CacheServicewithout coupling your tests to Redis. - Decouple modules. Module A exports
CacheService(a Protocol). Module B importsCacheServicewithout 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 itWhy 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:
-
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
Noneor 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. -
Hidden dependencies. Field injection makes it easy to add dependencies without realizing it — you just add another
@Autowiredfield. Constructor injection forces every dependency to be listed in the constructor signature, making the full dependency surface visible at a glance. -
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 = cacheLauren 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 existsThis 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:
- Module Discovery — Scan all
@Moduledecorators, resolve imports/exports, detect circular imports between modules. - Binding Registration — Register all
@injectableand Protocol bindings in the container. Fail if duplicate bindings exist for the same key without explicit overrides. - Dependency Graph Construction — Inspect constructor annotations for every injectable and controller, building a directed graph of dependencies.
- Cycle Detection — Traverse the dependency graph and fail if any cycles are found. A cycle means the container cannot determine construction order.
- Scope Assignment — Assign scopes based on
@injectable(scope=...)declarations. Warn about scope mismatches (e.g., a SINGLETON depending on a REQUEST-scoped service). - Lifecycle Hook Ordering — Topologically sort
@post_constructand@pre_destructhooks so that initialization runs in dependency order and teardown runs in reverse. - 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:
-
Initialization order is undefined. When
@post_constructhooks run on cyclic services, there is no correct order —OrderService.post_construct()might run beforePaymentServicehas been injected into it, causing aNoneTypeerror. -
Tight coupling. A cycle between two services means they can never be understood independently. Any change to
OrderServicemight affectPaymentServiceand vice versa. This coupling compounds over time until the cycle becomes an unmaintainable hairball. -
Testability nightmare. You cannot test
OrderServicein isolation because it requiresPaymentService, which requiresOrderService, 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:
- Last-wins — silently discard earlier registrations. This hides bugs — you registered
EmailSenderfirst, but the container ignores it becauseSmsSenderwas registered later. - Error — fail if duplicate bindings exist. This forces you to use multi-binding explicitly, which is what Lauren does.
- 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:
-
Hash stability. In Python,
Repository[User]creates a new_GenericAliasobject each time it is subscripted. Two separateRepository[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.GenericTokenpre-computes its hash from(origin, args)and uses identity comparison for the origin class, which is deterministic and portable. -
Open generic support. Lauren needs to distinguish between "closed" generic tokens like
Repository[User](where all type parameters are concrete) and "open" generic tokens likeRepository[T](whereTis still aTypeVar). Open tokens are used for generic provider registration — they match any closed specialization during resolution. This distinction cannot be expressed with raw_GenericAliasobjects, but it is natural withGenericToken.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:
passWhen 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:
passIn 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:
passWhen LaurenFactory.create(OrderModule) runs, the seven-phase startup:
- Discovers all modules and their import/export relationships
- Registers every provider — including
GenericToken(Repository, (Order,))andGenericToken(Repository, (Product,)) - Builds the dependency graph:
OrderController → OrderService → Repository[Order], Repository[Product], CacheService, EventBus - Detects no cycles (the graph is a DAG)
- Assigns scopes:
OrderServiceis SINGLETON,CacheServiceis bound toRedisCacheService(SINGLETON by default) - Orders lifecycle hooks:
DatabaseService.post_construct()first, thenOrderService.warm_cache() - 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
| Feature | Lauren | NestJS | FastAPI | Spring | Guice |
|---|---|---|---|---|---|
| Startup validation | 7-phase, fail-fast | Partial | None | Partial | Partial |
| Generic DI | Full (GenericToken) | N/A (no generics in JS) | None | Partial (ParameterizedType) | Full (TypeLiteral) |
| Protocol binding | Native (typing.Protocol) | N/A | None | Interfaces (ABC) | Interfaces |
| Scope management | 3 scopes, compile-time | 4 scopes | None | 5+ scopes | Custom |
| Cycle detection | Startup, with suggestions | Runtime | None | Runtime (lazy) | Startup |
| Multi-bindings | Explicit multi=True | Multi-providers | None | @Qualifier | Multi-binders |
| Module encapsulation | Imports/exports | Imports/exports | None | Packages | Modules (partial) |
| Lifecycle hooks | Topological ordering | Topological ordering | None | @PostConstruct | None |
| Decorator-driven API | Full | Full | Partial | Annotations (Java) | Module API |
| Constructor injection | Only option | Only option | Depends (runtime) | Optional | Only 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.
| Decision | Rationale |
|---|---|
| Decorator-driven API | Refactoring-safe, type-checker friendly, co-located with code |
| Constructor injection only | No partially-constructed objects, all dependencies visible |
| Three scopes only | Covers 99% of use cases; additional scopes create more bugs than they solve |
| Protocol over ABC | Structural typing, third-party integration, post-hoc abstraction |
| Seven-phase startup | Targeted error messages for each class of problem |
| Cycle prohibition | Forces proper decomposition; cycles indicate missing abstractions |
| Explicit multi-binding | Prevents accidental shadowing; makes intent clear |
| GenericToken for generics | Portable, hash-stable, supports open/closed distinction |
| Generic providers over factories | Startup-time validation of generic dependencies |
| Module boundaries | Encapsulation, refactoring safety, clear public API surface |
| Topological lifecycle hooks | Safe 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
Older post
No older post yet.
Newer post
No newer post yet.
