В бэкенде довольно быстро один 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?
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.
Одна таблица, чтобы не путать роли
Главное различие видно не по набору полей, а по вопросу, на который отвечает каждый из типов:
|
Роль |
На какой вопрос отвечает |
Почему меняется |
Примеры имён |
|---|---|---|---|
|
Доменная entity |
Кто это и что с ним можно делать? |
Изменились бизнес-правила |
|
|
ORM-представление |
Как это лежит в базе? |
Миграция или смена хранения |
|
|
Model фреймворка |
Что этим словом называет конкретный фреймворк? |
Изменились правила слоя |
|
|
Schema |
Какая форма данных допустима? |
Изменился контракт валидации или документации |
|
|
DTO |
Что и в каком направлении передаём? |
Изменился обмен данными |
|
Таблица не требует заводить отдельный класс под каждую строку.
Один класс 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 достаточно
Один User — нормальное решение, если проект маленький, публичного API нет, доменной логики почти нет, а объект хранения безопасно совпадает с тем, что нужно показать наружу.
Разделять типы заранее ради красивой структуры каталогов не нужно.
Смотреть лучше не на размер проекта и не на число строк, а на причины изменения. Если миграция базы, новая версия API и новое бизнес-правило могут происходить независимо, тогда перед вами уже разные роли — даже когда сегодня у них одинаковые поля.
Источники
Вывод
Один User сам по себе не проблема.
Проблема начинается, когда он одновременно отвечает за базу, домен и внешний API.
Сначала граница. Потом ответственность. Потом имя. Отдельный класс нужен только тогда, когда он действительно что-то защищает.
Михаил Миронов, Табрика co-founder.
ссылка на оригинал статьи https://habr.com/ru/articles/1054614/