3.4. Domain Layer
The Domain Layer is the core of the application — its models, value objects, and the abstract contracts other layers implement. It has no framework or infrastructure dependencies: no ORM, no HTTP client, no CLI code. This is what makes it trivial to test and safe to reuse regardless of which database, web framework, or delivery mechanism a project ends up using.
3.4.1. What belongs here
Models (
domain/models/) — the business entities themselves.Value Objects (
domain/value_objects/) — small, self-validating types for attributes with specific business meaning (anId, aUsername) instead of bare primitives.Filters (
domain/filters/) — the vocabulary a repository query is expressed in (EqualFilter,LikeFilter,InFilter,MinFilter,MaxFilter), independent of any specific database.Interfaces (
<concept>/interface/) — abstract contracts for domain-level concepts that have more than one concrete shape (see below).Exceptions specific to domain rules (e.g.
domain/models/exceptions/).
3.4.2. What to avoid here
Business logic in other layers. If a rule is about what makes a valid
Useror a validApp, it belongs on the model itself (as a validator), not scattered across use cases.Direct persistence. The Domain Layer never talks to a database — that’s what
Port/Adapter(Application/Infrastructure) are for.External dependencies. No framework imports, no infrastructure imports. If a domain file needs to import something from
infrastructure/or a third-party framework, that logic belongs in a different layer.UI or presentation concerns. The Domain Layer doesn’t know or care how its data ends up on screen.
3.4.3. Naming convention: Interface, not I + name
A domain-level abstract contract uses the ``Interface`` suffix, not an I prefix (FilterInterface, not IFilter), and lives in an interface/ subfolder next to the concept it belongs to — not in a shared domain/interfaces/ grab-bag. For example, domain/filters/interface/__init__.py defines the contract every concrete filter (in domain/filters/) implements:
# domain/filters/interface/__init__.py
from abc import ABC, abstractmethod
from typing import TypeVar
from domain.models.core.field import Field
T = TypeVar("T", covariant=True)
class FilterInterface(ABC):
@abstractmethod
def __init__(
self,
model_property: Field[T],
value: T,
include: bool | None = None,
not_param: bool | None = None,
):
pass
3.4.4. Example: a domain model
Models are Pydantic-backed (via the framework’s own NewModel/Model base classes), not plain classes — this gives every model validation for free. domain/models/app.py:
from typing import Literal
from domain.models import NewModel
from domain.models.core.pydantic import field_validator
class App(NewModel):
name: str
version: str
environment: Literal['development', 'production']
debug_mode: bool
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
if not value:
raise ValueError("Name cannot be empty")
return value
Notice the validator comes from domain.models.core.pydantic, not pydantic directly — that’s the only file in domain/ allowed to import Pydantic itself, so a project that ever needs to drop Pydantic only has to rewrite that one file. When adding a validator to your own model, import field_validator (and anything else Pydantic-related) from there too, not from pydantic.
A model that also has a database identity extends Model[IdType] instead, adding an id field — see domain/models/user.py:
from domain.models import Model, NewModel
from domain.models.core.field import Field
from domain.value_objects.id import Id
class UserId(Id):
pass
class NewUser(NewModel):
username: Field[str]
class User(NewUser, Model[UserId]):
pass
Splitting NewUser (the data needed to create one) from User (a persisted one, with an id) is a recurring pattern — it’s what lets a Repository accept a NewUser for create() but return a full User back.
3.4.5. Example: a filter
Concrete filters (domain/filters/__init__.py) are thin subclasses of a shared FilterBase, parameterized by the value type they compare against:
from typing import Any, SupportsInt, TypeVar
from domain.filters.base import FilterBase
T = TypeVar("T", str, SupportsInt)
class EqualFilter(FilterBase[T]):
pass
class LikeFilter(FilterBase[str]):
pass
class InFilter(FilterBase[list[Any]]):
pass
A repository query is built by passing a list of these — e.g. [EqualFilter(User.id, some_id)] — instead of any database-specific query syntax; each Adapter (see the Infrastructure Layer) is responsible for translating them into its own backend’s query language.