Injecting Data Models into the DI Container
Lauren's DI container works with any Python class that carries the
@injectable marker — including @dataclass, Pydantic BaseModel,
SQLAlchemy DeclarativeBase subclasses, and SQLModel. However, each
type has its own constructor contract, which affects whether the container
can build it automatically or whether you need to provide the instance
yourself.
The one rule that governs everything
The DI container can build a type if and only if every required constructor parameter either (a) has a default value, or (b) is itself resolvable from the container.
Primitive types (str, int, float, bool, bytes, …) are never
registered as providers. Any required primitive constructor parameter
therefore triggers a MissingProviderError at startup — not at request
time.
The universal escape hatch is
use_value:
use_value(provide=MyModel, value=MyModel(field="value"))This bypasses construction entirely and hands a pre-built instance to every consumer.
@dataclass
All-defaulted fields — works out of the box
from dataclasses import dataclass
from lauren import injectable, module
@injectable()
@dataclass
class AppSettings:
db_url: str = "sqlite:///•"
pool_size: int = 5
debug: bool = False
@module(providers=[AppSettings])
class AppModule: ...The container calls AppSettings() with no arguments; every field uses
its default.
Note: Decorator order matters
@injectable()must come before@dataclass(outer decorator applied last). The reverse order works too, but placing@injectableoutermost is the conventional style.
Injectable (non-primitive) fields — also works
If a dataclass field is annotated with another @injectable class, the
DI container resolves it as a normal constructor dependency:
@injectable()
@dataclass
class Database:
settings: AppSettings # resolved from the containerThe __init__ generated by @dataclass includes settings as a
parameter, so inspect.signature(Database) exposes it and DI picks it
up.
Warning: Field injection is disabled for dataclasses
Lauren's class-body field injection deliberately skips entries in
__dataclass_fields__to avoid confusing data fields with DI dependencies. Constructor injection (via the generated__init__) always works; class-body field injection (thesvc: MyServiceon a plain class without__init__) does not apply to dataclasses.
Required primitive fields — fails
@injectable()
@dataclass
class BadSettings:
db_url: str # required, no default → MissingProviderError at startupWorkaround: either give the field a default, use
use_value(provide=BadSettings, value=BadSettings(db_url="...")), or
use a use_factory that reads the value from the environment.
pydantic.BaseModel
Pydantic v2 publishes a __signature__ where all fields appear as
keyword-only parameters. The DI container reads this signature
exactly as it would any other class.
All-optional fields — works
from pydantic import BaseModel
from lauren import injectable
@injectable()
class DbConfig(BaseModel):
host: str = "localhost"
port: int = 5432
name: str = "mydb"The container calls DbConfig() with no arguments; Pydantic supplies
every field's default. The resulting instance is a proper Pydantic model
with validation applied.
Required fields — fails
@injectable()
class DbConfig(BaseModel):
host: str # required → MissingProviderError
port: int # required → MissingProviderErrorWorkarounds:
# Option 1: give every field a default
class DbConfig(BaseModel):
host: str = "localhost"
port: int = 5432
# Option 2: provide a ready-made instance
use_value(provide=DbConfig, value=DbConfig(host="db.prod", port=5432))
# Option 3: use_class pointing at a subclass with defaults
class ProdDbConfig(DbConfig):
host: str = "db.prod"
port: int = 5433
use_class(provide=DbConfig, use=ProdDbConfig)
# Option 4: use_factory reading from the environment
use_factory(provide=DbConfig, factory=lambda: DbConfig(
host=os.environ["DB_HOST"],
port=int(os.environ["DB_PORT"]),
))sqlalchemy.orm.DeclarativeBase
SQLAlchemy's mapper generates __init__(self, **kwargs) for every
mapped class. From the DI container's perspective, the constructor
signature has a single **kwargs parameter (kind VAR_KEYWORD), which
is always skipped. So Model() is called with no arguments.
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from lauren import injectable
class Base(DeclarativeBase):
pass
@injectable()
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]This works — Product() is called with no args and produces an
uninitialised ORM instance (all mapped columns are None). This is
useful for sentinel/stub values but is not a substitute for a real
session-bound query result.
Warning: Do not use
@injectablefor session-bound query resultsAn ORM model decorated with
@injectableproduces a single, un-sessionned instance shared across all requests (SINGLETON scope). If you need a session-scoped row fetched from the database, inject theAsyncSessionand query inside the handler or a REQUEST-scoped service instead.
use_value for populated instances
To inject a real, pre-populated ORM object (e.g. a static lookup table entry loaded once at startup):
# Somewhere in your startup code or a use_factory:
_settings_row = Settings(key="theme", value="dark")
use_value(provide=Settings, value=_settings_row)What not to do — subclassing to add @injectable
# ❌ This raises sqlalchemy.exc.NoForeignKeysError at import time
@injectable()
class InjectableProduct(Product): # Product is already mapped
__tablename__ = "injectable_products" # no FK to "products"SQLAlchemy's joined-table inheritance requires a foreign key from the
child table to the parent. Don't subclass a mapped model to attach
@injectable; decorate the original class directly instead.
sqlmodel.SQLModel
SQLModel combines Pydantic validation with SQLAlchemy's ORM. Its DI
behaviour depends on whether the model is declared as a table model
(table=True) or a schema model (Pydantic-only, the default).
Schema models (table=False, the default) — same as Pydantic
import sqlmodel
from lauren import injectable
@injectable()
class Pagination(sqlmodel.SQLModel):
page: int = 1
page_size: int = 20All fields have defaults → the container builds it with no arguments.
Works exactly like pydantic.BaseModel.
Table models with required fields — fails
@injectable()
class Widget(sqlmodel.SQLModel, table=True):
__tablename__ = "widgets"
id: int | None = sqlmodel.Field(default=None, primary_key=True)
label: str # required, no default → MissingProviderErrorTable models with all-defaulted fields — works, but use with care
If every field has a default, the container can call Widget(). The
same caveat as SQLAlchemy applies: the result is an uninitialised,
un-sessionned object.
Recommended pattern for table models
# Provide a pre-built instance, or inject the session and query:
use_value(provide=Widget, value=Widget(label="default"))Quick-reference table
| Type | @injectable auto-build | Condition | use_value |
|---|---|---|---|
@dataclass | ✅ | All required fields must have defaults or be DI-resolvable (non-primitive) | ✅ always |
pydantic.BaseModel | ✅ | All fields optional (have defaults) | ✅ always |
pydantic.BaseModel | ❌ | Any required primitive field | ✅ always |
sqlalchemy.DeclarativeBase | ✅ | Mapper generates **kwargs → always callable with no args, produces uninitialised instance | ✅ always |
sqlmodel.SQLModel (schema) | ✅ | Same as Pydantic | ✅ always |
sqlmodel.SQLModel (table) | ✅/❌ | Same as Pydantic — depends on whether fields have defaults | ✅ always |
Anti-pattern summary
# ❌ Required primitive fields — startup fails
@injectable()
@dataclass
class Bad:
url: str # no default, str is not a DI provider
# ❌ Subclassing a mapped ORM model for @injectable — SQLAlchemy error
@injectable()
class BadChild(MappedParent):
__tablename__ = "bad_child" # no FK → NoForeignKeysError
# ✅ Universal workaround for all cases
use_value(provide=MyModel, value=MyModel(url="sqlite:///•"))
# ✅ All-defaults config dataclass
@injectable()
@dataclass
class GoodSettings:
url: str = "sqlite:///•"
# ✅ Inject a non-primitive dependency from another service
@injectable()
@dataclass
class Repo:
db: Database # Database is @injectable → resolved from container