Один User сначала кажется удобным: его можно принять в запросе, вернуть из API, сохранить в базу и передать дальше.
Но со временем такой класс смешивает разные границы и перестаёт защищать код.Разбираю на Python-примерах, чем отличаются DTO, schema, model и entity — и когда отдельные классы действительно нужны. Читать далее
В бэкенде довольно быстро один 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.
В терминах 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 — самый расплывчатый термин в этой компании.
В 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 обычно описывает не конкретную сущность, а допустимую форму данных в конкретном месте.
Например, что можно принять в запросе регистрации:
class CreateUserRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=12)По роли это schema входа: она фиксирует, какие поля можно передать, какие обязательны, какие типы и ограничения допустимы.
Но сам класс не обязан называться UserSchema. В проектах Python словом schema часто называют классы Pydantic рядом с API, хотя в имени обычно полезнее отражать конкретный сценарий: запрос создания, запрос обновления, ответ клиенту.
Поэтому имя UserSchema слабое почти по той же причине, что и UserModel.
Оно говорит: «где-то есть какая-то форма пользователя», но не говорит, какая именно.
Имя CreateUserRequest из примера работает лучше: сразу видно, что это вход регистрации, а не какая-то абстрактная схематика.
В исходном описании Мартина Фаулера 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.

Четыре представления пользователя на разных границах бэкенда.
Главное различие видно не по набору полей, а по вопросу, на который отвечает каждый из типов:
Роль | На какой вопрос отвечает | Почему меняется | Примеры имён |
|---|---|---|---|
Доменная entity | Кто это и что с ним можно делать? | Изменились бизнес-правила |
|
ORM-представление | Как это лежит в базе? | Миграция или смена хранения |
|
Model фреймворка | Что этим словом называет конкретный фреймворк? | Изменились правила слоя |
|
Schema | Какая форма данных допустима? | Изменился контракт валидации или документации |
|
DTO | Что и в каком направлении передаём? | Изменился обмен данными |
|
Таблица не требует заводить отдельный класс под каждую строку.
Один класс Pydantic может быть DTO для обработчика, model для Pydantic и источником schema для OpenAPI. Это нормально.
Проблема начинается не от совпадения классов, а от смешивания разных слоев ответственности.
Если база, домен и внешний API меняются по разным причинам, то один общий 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 блокирует конвейер бэкенда.
Один User — нормальное решение, если проект маленький, публичного API нет, доменной логики почти нет, а объект хранения безопасно совпадает с тем, что нужно показать наружу.
Разделять типы заранее ради красивой структуры каталогов не нужно.
Смотреть лучше не на размер проекта и не на число строк, а на причины изменения. Если миграция базы, новая версия API и новое бизнес-правило могут происходить независимо, тогда перед вами уже разные роли — даже когда сегодня у них одинаковые поля.
Один User сам по себе не проблема.
Проблема начинается, когда он одновременно отвечает за базу, домен и внешний API.
Сначала граница. Потом ответственность. Потом имя. Отдельный класс нужен только тогда, когда он действительно что-то защищает.
Михаил Миронов, Табрика co-founder.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Оптимизация без AI: как я автоматизировал API-ручки и типы | -2 | 5 | 29-06-2026 |
| 2 | Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов | 5 | 8 | 07-07-2026 |
| 3 | Авторизация по протоколу OAuth 2.0 в интеграциях | 0 | 7 | 02-07-2026 |
| 4 | База по системному дизайну для начинающих разработчиков ПО на примере. Часть 1. Анализ задачи, декомпозиция, модули | 5 | 7 | 04-07-2026 |
| 5 | DS MCP: как дать AI-агенту знание о вашей дизайн-системе | 7 | 8 | 08-07-2026 |
| 6 | Две недели я делал народную карту заправок. Написать код оказалось самой скучной частью работы | 0 | 5 | 07-07-2026 |
| 7 | Настройка AI-агентов для ускорения бизнес процессов компании | 0 | 7 | 03-07-2026 |
| 8 | Django-кнопка «Наверх»: подключить за минуту вместо очередного велосипеда | 5 | 7 | 28-06-2026 |
| 9 | Spring Security: аутентификация через REST | 0 | 5 | 07-07-2026 |
| 10 | КТЗ показал единицу, а задачу вернули трижды. Что на самом деле ломает процесс требований | 0 | 6 | 24-06-2026 |