Lauren logoLauren
← Home
Export this page

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:

python
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

python
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 @injectable outermost 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:

python
@injectable()
@dataclass
class Database:
    settings: AppSettings   # resolved from the container

The __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 (the svc: MyService on a plain class without __init__) does not apply to dataclasses.

Required primitive fields — fails

python
@injectable()
@dataclass
class BadSettings:
    db_url: str   # required, no default → MissingProviderError at startup

Workaround: 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

python
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

python
@injectable()
class DbConfig(BaseModel):
    host: str    # required → MissingProviderError
    port: int    # required → MissingProviderError

Workarounds:

python
# 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.

python
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 @injectable for session-bound query results

An ORM model decorated with @injectable produces a single, un-sessionned instance shared across all requests (SINGLETON scope). If you need a session-scoped row fetched from the database, inject the AsyncSession and 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):

python
# 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

python
# ❌ 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

python
import sqlmodel
from lauren import injectable

@injectable()
class Pagination(sqlmodel.SQLModel):
    page: int = 1
    page_size: int = 20

All fields have defaults → the container builds it with no arguments. Works exactly like pydantic.BaseModel.

Table models with required fields — fails

python
@injectable()
class Widget(sqlmodel.SQLModel, table=True):
    __tablename__ = "widgets"
    id: int | None = sqlmodel.Field(default=None, primary_key=True)
    label: str    # required, no default → MissingProviderError

Table 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.

python
# 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-buildConditionuse_value
@dataclassAll required fields must have defaults or be DI-resolvable (non-primitive)✅ always
pydantic.BaseModelAll fields optional (have defaults)✅ always
pydantic.BaseModelAny required primitive field✅ always
sqlalchemy.DeclarativeBaseMapper 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

python
# ❌ 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