В бэкенде довольно быстро один User начинает работать сразу в нескольких сценариях.

Сначала его удобно вернуть из API. Потом этим же классом принимают регистрацию. Потом его сохраняют в базу. Потом в него докидывают status, created_at, password_hash и пару полей для бизнес-логики:

class User(BaseModel):
    id: str | None = None
    email: EmailStr | None = None
    password: str | None = None
    password_hash: str | None = None
    status: UserStatus | None = None
    created_at: datetime | None = None

С виду удобно: один класс на все случаи.

А потом выясняется, что:

  • при регистрации password внезапно необязателен;

  • в ответ API случайно протащился password_hash;

  • id отсутствует там, где без него объект не имеет смысла;

  • изменение таблицы начинает ломать внешний контракт;

  • по типу User уже невозможно понять, какие поля в конкретном месте гарантированы.

Проблема не в названии User, а в том, что одним типом пытаются описать объекты, которые принадлежат разным границам и меняются по разным причинам.

Эта статья не про обязательные восемь классов на каждую таблицу. Вопрос проще и важнее: что именно сейчас лежит перед нами — DTO, schema, model или entity?

DTO, schema, model и entity расходятся от одного User.
DTO, schema, model и entity расходятся от одного User.

Entity: объект, который остаётся собой

В терминах Domain-Driven Design entity — это объект с идентичностью, которая сохраняется во времени и переживает изменение остальных атрибутов.

Пользователь может сменить email или имя, но остаться тем же пользователем. Поэтому его определяет не набор текущих полей, а идентификатор и правила, по которым меняется состояние:

class User:
    def __init__(self, user_id: int, email: str) -> None:
        self.id = user_id
        self._email = email

    @property
    def email(self) -> str:
        return self._email

    def change_email(self, new_email: str) -> None:
        self._email = new_email

Здесь User — не пакет данных. У него есть идентичность и явно прописанная операция, выражающая допустимое изменение состояния.

Но само слово entity при этом перегружено. Например, в ORM им часто называют объект, отображённый на строку таблицы:

class UserEntity(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    public_id: Mapped[str] = mapped_column(String(26), unique=True)
    email: Mapped[str] = mapped_column(String(320), unique=True)
    password_hash: Mapped[str] = mapped_column(String(255))
    status: Mapped[str] = mapped_column(String(32))
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

У этого класса другая ответственность: таблица, колонки, ограничения и типы базы. Его меняют из-за миграции или особенностей ORM, а не потому, что в домене появился новый способ изменить email.

В маленьком сервисе доменный объект и класс ORM вполне могут совпадать. Но если слои уже разделены, то имя UserEntity становится двусмысленным: это доменная entity или объект ORM?

Обычно проще оставить короткое User главному понятию домена, а хранилище назвать UserRecord, UserRow или UserOrmModel.

Model: слово, ничего не значащее без контекста

Model — самый расплывчатый термин в этой компании.

В Django model — класс, который описывает хранимые данные и обычно соответствует таблице в базе:

class User(models.Model):
    email = models.EmailField(unique=True)
    name = models.CharField(max_length=255)

В Pydantic model — это наследник класса BaseModel с типизированными полями. Он принимает внешние данные, валидирует и преобразует их, после чего гарантирует форму получившегося объекта:

class User(BaseModel):
    id: str
    email: EmailStr
    name: str

Оба класса честно называются model. Просто слово закреплено за разными абстракциями.

Внутри конкретного фреймворка это редко мешает. User в models.py Django и так читается однозначно. Суффикс Model здесь почти ничего не добавляет.

Путаница начинается, когда в одном приложении рядом живут Django-модель, Pydantic-модель, модель внешнего API-клиента и доменный объект. Имя UserModel сообщает только одно: перед нами какая-то модель пользователя. Каждый раз придется выяснять, какой цели она служит.

Поэтому запрещать Model бессмысленно. Но вне устойчивого контекста лучше назвать роль точнее: UserRecord, UserResponse, CreateUserRequest.

Schema: форма данных на границе

schema обычно описывает не конкретную сущность, а допустимую форму данных в конкретном месте.

Например, что можно принять в запросе регистрации:

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

По роли это schema входа: она фиксирует, какие поля можно передать, какие обязательны, какие типы и ограничения допустимы.

Но сам класс не обязан называться UserSchema. В проектах Python словом schema часто называют классы Pydantic рядом с API, хотя в имени обычно полезнее отражать конкретный сценарий: запрос создания, запрос обновления, ответ клиенту.

Поэтому имя UserSchema слабое почти по той же причине, что и UserModel.
Оно говорит: «где-то есть какая-то форма пользователя», но не говорит, какая именно.

Имя CreateUserRequest из примера работает лучше: сразу видно, что это вход регистрации, а не какая-то абстрактная схематика.

DTO: объект для передачи, а не модель мира

В исходном описании Мартина Фаулера DTO переносит данные между процессами и помогает сократить количество удалённых вызовов.

В современном бэкенде это слово часто используют шире: DTO называют объект, который несёт данные через API, очередь, RPC или между слоями приложения.

Ключевой момент: DTO не притворяется пользователем из домена.
Он не решает, можно ли сменить email, не проверяет бизнес-инварианты и не знает, как данные лежат в базе. Он просто фиксирует набор полей, который нужно передать в конкретном направлении, без оглядки на доменную логику:

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


class UserResponse(BaseModel):
    id: str
    email: EmailStr
    status: UserStatus
    created_at: datetime

Оба класса относятся к пользователю, но это разные контракты:

  • CreateUserRequest несёт данные внутрь операции регистрации. Поэтому в нём есть password, но нет id и created_at: пользователь ещё не создан.

  • UserResponse несёт данные наружу. Поэтому в нём есть id, status и created_at, но нет password.
    Условный password_hash не должен появляться ни там, ни там: это деталь хранения, а не часть публичного обмена данными.

Именно поэтому универсальный UserDto быстро превращается в мессиво из опциональных полей. Запрос, ответ и сообщение брокера меняются независимо, но общий класс заставляет их притворяться одним контрактом.

Суффикс Dto при этом не обязателен. CreateUserRequest часто говорит больше, чем CreateUserDto: из имени уже видно действие и направление.

Но также есть и другая сторона монеты: отдельный DTO не нужен для каждого вызова локального метода.
Если данные не пересекают реальную границу и новый тип ничего не защищает, то маппинг становится чистой церемонией и оверхедом. Фаулер разбирает этот перекос в заметке Local DTO.

Четыре представления пользователя на разных границах backend-системы.
Четыре представления пользователя на разных границах бэкенда.

Одна таблица, чтобы не путать роли

Главное различие видно не по набору полей, а по вопросу, на который отвечает каждый из типов:

Роль

На какой вопрос отвечает

Почему меняется

Примеры имён

Доменная entity

Кто это и что с ним можно делать?

Изменились бизнес-правила

User

ORM-представление

Как это лежит в базе?

Миграция или смена хранения

UserRecord, UserRow

Model фреймворка

Что этим словом называет конкретный фреймворк?

Изменились правила слоя

User, UserReadModel

Schema

Какая форма данных допустима?

Изменился контракт валидации или документации

CreateUserRequest, UserResponse

DTO

Что и в каком направлении передаём?

Изменился обмен данными

CreateUserRequest, UserResponse, UserCreatedEvent

Таблица не требует заводить отдельный класс под каждую строку.
Один класс Pydantic может быть DTO для обработчика, model для Pydantic и источником schema для OpenAPI. Это нормально.

Проблема начинается не от совпадения классов, а от смешивания разных слоев ответственности.
Если база, домен и внешний API меняются по разным причинам, то один общий User быстро становится свалкой, с которой каждый раз приходится считаться.

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

Проблема универсального объекта не в том, что он некрасивый, а в том, что по нему нельзя понять, какие поля в этом месте гарантированы.

Если почти всё стало опциональным, то тип перестаёт защищать код.
Запрос регистрации может оказаться без password, ответ API — без id, объект для сохранения — без password_hash.
То есть ошибка всплывает не там, где объект собрали, а где-нибудь дальше, в случайной ветке, в этом и коварность такого решения.

Отдельный неприятный случай — когда наружу начинают отдавать объект хранения. Запись в ORM действительно содержит данные пользователя, поэтому возникает соблазн сериализовать её автоматически:

def serialize_record(record: UserRecord) -> dict[str, object]:
    return {
        column.key: getattr(record, column.key)
        for column in record.__table__.columns
    }

Кода мало, но контракт теперь определяется таблицей. Добавили колонку — она может попасть в API. Переименовали поле для миграции — сломали клиента. Завели password_hash — получили вполне реальный шанс утечки.

Перегруженный универсальный User блокирует конвейер backend-системы.
Перегруженный универсальный User блокирует конвейер бэкенда.

Когда одного User достаточно

Один User — нормальное решение, если проект маленький, публичного API нет, доменной логики почти нет, а объект хранения безопасно совпадает с тем, что нужно показать наружу.

Разделять типы заранее ради красивой структуры каталогов не нужно.
Смотреть лучше не на размер проекта и не на число строк, а на причины изменения. Если миграция базы, новая версия API и новое бизнес-правило могут происходить независимо, тогда перед вами уже разные роли — даже когда сегодня у них одинаковые поля.

Источники

Вывод

Один User сам по себе не проблема.
Проблема начинается, когда он одновременно отвечает за базу, домен и внешний API.

Сначала граница. Потом ответственность. Потом имя. Отдельный класс нужен только тогда, когда он действительно что-то защищает.

Михаил Миронов, Табрика co-founder.