Logo Craft Homelab Docs Контакты Telegram
DTO, schema, model и entity в Python: как не превратить всё в User Трендовые github проекты в нашем телеграм канале. Подпишись →
23 мая 2026 г.

Почему один класс User ломает архитектуру Python-приложения

В небольшом Python-сервисе соблазнительно завести один класс User и использовать его везде: принять JSON из API, провалидировать форму, сохранить запись в базу, вернуть ответ клиенту и передать объект в бизнес-логику. На старте это выглядит удобно: меньше файлов, меньше преобразований, проще автодополнение в IDE.

Проблема появляется позже. У HTTP-запроса, строки в базе данных и доменной операции разные границы ответственности. Если все они называются User и представлены одним объектом, код постепенно начинает протекать между слоями: API узнаёт о колонках базы, ORM-модель тащит в домен технические поля, а наружу случайно уезжают password_hash, внутренние флаги или поля для аудита.

Разделение DTO, schema, model и entity не нужно делать ради терминологии. Оно полезно, когда помогает явно показать, где проходит граница между транспортом, хранением и бизнес-правилами.

Четыре похожих имени, четыре разные задачи

В типичном backend-проекте эти сущности можно разделить так:

  • DTO — объект для передачи данных между слоями или процессами. Обычно не содержит бизнес-логики и описывает конкретный сценарий обмена.
  • Schema — схема валидации и сериализации: входной HTTP-запрос, ответ API, payload для очереди, конфигурационный объект.
  • Model — модель хранения, чаще всего ORM-класс, отражающий таблицу, связи, индексы и технические поля базы данных.
  • Entity — доменный объект, который выражает правила предметной области и не обязан совпадать со структурой таблицы или JSON.

В реальном проекте названия могут отличаться. Главное — не название папки, а контракт: кто имеет право использовать этот объект и какие детали он скрывает.

Где ломается один универсальный User

Представим сервис пользователей. В базе есть таблица с такими полями:

class UserModel(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(unique=True, index=True)
    password_hash: Mapped[str]
    is_active: Mapped[bool] = mapped_column(default=True)
    created_at: Mapped[datetime]
    updated_at: Mapped[datetime]

Если вернуть этот объект напрямую из API, появляется риск раскрыть password_hash. Если принять его же на вход при регистрации, клиент сможет прислать id, is_active или created_at. Если передать ORM-объект в доменную логику, бизнес-код начнёт зависеть от SQLAlchemy-сессии, lazy loading и конкретной схемы таблицы.

Без явных границ команда начинает чинить симптомы: exclude={"password_hash"} в одном endpoint, dict.pop() в другом, ручные проверки в третьем. Такие правки сложно ревьюить, и они плохо масштабируются.

API-схемы: вход и выход не должны совпадать

Для HTTP-слоя удобно держать отдельные схемы. Например, вход регистрации и публичный ответ:

from pydantic import BaseModel, EmailStr, Field

class UserCreateSchema(BaseModel):
    email: EmailStr
    password: str = Field(min_length=12)

class UserResponseSchema(BaseModel):
    id: int
    email: EmailStr
    is_active: bool

UserCreateSchema описывает то, что клиент имеет право прислать. В ней нет id, created_at и password_hash. UserResponseSchema описывает безопасный публичный вид пользователя. В ней нет пароля, даже если это хеш.

Это особенно важно для FastAPI и похожих фреймворков: схема становится частью публичного контракта, документации OpenAPI и автотестов. Когда вход и выход разделены, случайное изменение базы не превращается в breaking change для клиентов.

DTO: данные для конкретного сценария

DTO полезен между слоями приложения. Например, API-слой уже провалидировал запрос и хочет передать в use case только то, что нужно для регистрации:

from dataclasses import dataclass

@dataclass(frozen=True)
class RegisterUserDTO:
    email: str
    raw_password: str

Такой объект не обязан быть Pydantic-моделью и не обязан знать про HTTP. Его можно создать из REST-запроса, CLI-команды, теста или сообщения из очереди. Use case получает стабильный контракт и не зависит от того, откуда пришли данные.

Для ответов из application-слоя также можно использовать отдельный DTO:

@dataclass(frozen=True)
class RegisteredUserDTO:
    id: int
    email: str

После этого API-слой сам решает, как сериализовать результат: в JSON, GraphQL-ответ или событие.

ORM-модель: техническая правда базы данных

ORM-модель должна оставаться в infrastructure-слое. Её задача — корректно описать хранение: таблицы, типы колонок, индексы, связи, каскады, constraints.

В ORM-классах нормально видеть детали, которые не относятся к публичному API:

  • password_hash, deleted_at, version, tenant_id;
  • lazy-связи и back references;
  • индексы, уникальные ограничения, server defaults;
  • поля для миграций и совместимости со старой схемой.

Плохой признак — когда эти детали начинают появляться в обработчиках HTTP или бизнес-правилах. Если endpoint проверяет user_model.deleted_at is None, а доменный сервис вызывает session.refresh(user), слои уже перемешались.

Entity: место для бизнес-инвариантов

Доменная entity может быть обычным классом или dataclass. Её задача — держать правила, которые важны для бизнеса, а не для базы:

@dataclass
class User:
    id: int | None
    email: str
    password_hash: str
    is_active: bool = True

    def deactivate(self) -> None:
        if not self.is_active:
            return
        self.is_active = False

    def change_email(self, new_email: str) -> None:
        if not new_email:
            raise ValueError("email must not be empty")
        self.email = new_email

Такой объект проще тестировать без базы и веб-фреймворка. Он может не совпадать с ORM-моделью один к одному: часть полей вычисляется, часть хранится в других таблицах, часть вообще не нужна для конкретного сценария.

В простом CRUD-сервисе отдельная entity может быть избыточной. Но если появляются состояния, переходы, ограничения и сценарии вроде «нельзя деактивировать последнего администратора», бизнес-объект становится полезнее универсального ORM-класса.

Маппинг — не лишний шум, а контроль границ

Главный аргумент против разделения — необходимость преобразований. Да, код вида model_to_entity() и entity_to_model() появляется. Но это цена за явные границы.

def user_model_to_entity(model: UserModel) -> User:
    return User(
        id=model.id,
        email=model.email,
        password_hash=model.password_hash,
        is_active=model.is_active,
    )


def registered_user_to_response(dto: RegisteredUserDTO) -> UserResponseSchema:
    return UserResponseSchema(id=dto.id, email=dto.email, is_active=True)

Маппинг лучше держать рядом с границей слоя: ORM-преобразования — в repository, API-преобразования — в router или presenter. Тогда изменения базы не расползаются по обработчикам, а изменения внешнего API не требуют переписывать домен.

Когда можно не усложнять

Не каждый скрипт требует четырёх классов на одну сущность. Для маленькой админки или внутреннего CRUD без сложной логики можно использовать Pydantic-схемы и ORM-модели без отдельного домена. Важно не вводить абстракции раньше, чем они окупаются.

Практическое правило такое:

  • если объект пересекает внешний API — заведите отдельные input/output schemas;
  • если данные идут между application-слоем и интерфейсами — используйте DTO;
  • если класс отражает таблицу — не выпускайте его за пределы persistence-слоя без необходимости;
  • если появились бизнес-инварианты — перенесите их в entity или доменный сервис.

Итог

Проблема не в том, что в проекте есть несколько классов с похожими полями. Проблема в обратном: когда один User одновременно представляет JSON-запрос, строку в базе, публичный ответ и бизнес-сущность. Такой объект становится слишком важным, слишком хрупким и слишком опасным для изменений.

Разделение DTO, schema, model и entity помогает держать архитектуру читаемой. API остаётся безопасным, база может меняться без каскада правок в роутерах, а бизнес-правила тестируются отдельно от инфраструктуры. Для Python-проектов это не догма, а простой способ не потерять контроль над границами приложения по мере роста кода.