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 (an Id, a Username) 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 User or a valid App, 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.