Моделирование данных для API: как выбрать сущности, агрегаты и границы ответственности
Покажем подход к проектированию доменной модели под API: где делать преобразования, как избежать «анемичных» сервисов.
Содержание
Моделирование данных для API: как выбрать сущности, агрегаты и границы ответственности
Проектирование API почти всегда упирается не в HTTP, а в данные. Как их представить? Где проводить преобразования между «тем, как хранят» и «тем, как нужно отдавать клиенту»? Почему одни команды справляются с ростом продукта, а другие тонут в «анемичных» сервисах, толстых контроллерах и бесконечных DTO?
В этой статье разберём практический подход к построению доменной модели под API: как выбирать сущности и агрегаты, где держать инварианты, как разделять ответственность между слоем домена, приложением и представлением, и как избежать типичных ловушек вроде “всё ради удобства API” или, наоборот, “никаких преобразований — пусть клиент сам разбирается”.
Почему API ломает доменную модель — и как это предотвратить
На практике большинство систем стартуют с одного простого сценария: есть таблицы, есть CRUD, есть контроллер, который берёт данные из БД и отдаёт JSON. Пока модель маленькая — всё работает.
Проблема начинается, когда:
- появляются бизнес-инварианты (например, нельзя создать заказ без подтверждения адреса),
- возникают сложные статусы и переходы,
- нужно отдавать данные в разных проекциях (список, карточка, отчёт),
- бизнес требует изменения правил, а API — изменений формы ответа.
Если доменная модель «подстраивается» под API, правила начинают расползаться по краям: в контроллерах, в сервисах, в преобразователях. Появляется анемия: сущности превращаются в пассивные структуры, а бизнес-логика — в набор обработчиков и “if-ов”.
Чтобы избежать этого, нужно заранее договориться о границах ответственности:
- Домен (domain): хранит инварианты и поведение сущностей.
- Приложение (application): оркестрирует сценарии: транзакции, вызовы домена, работа с репозиториями.
- Представление/контракты (interface, presentation): преобразует доменные объекты в модели для API и обратно (в рамках допустимого).
Ключевой вопрос: где именно делать преобразования данных для API, чтобы они не разрушали домен?
Базовые сущности: как выбрать, что “живет” в домене
Сущность и ценность: не всё нужно делать доменными сущностями
Терминология из DDD помогает, но не превращайте её в религию. Условимся:
- Сущность (Entity) имеет идентичность и обычно живёт во времени: у неё есть уникальный ключ, который важен для домена.
- Значение (Value Object) описывает состояние без идентичности. Важны равенство и неизменяемость по смыслу (например, Money, Address, Email).
В контексте API не каждая “структура ответа” должна стать сущностью. Если клиенту нужен список строк, это не означает, что у вас должна быть сущность “OrderListItem”. Как правило, для API нужны DTO/модели представления, а не новые доменные сущности.
Практический критерий: где лежат инварианты
Сущность имеет смысл, если у неё есть инварианты и поведение. Например:
- Заказ изменяется только через валидные переходы статусов.
- Позиции заказа подчиняются правилам (например, минимальная кратность).
- Пользователь может иметь не произвольный набор прав, а ограниченный набор.
Если вы видите, что “у сущности только поля, а вся логика — где-то снаружи”, это красный флаг. Логика, связанность и проверки должны быть ближе к данным, чтобы домен оставался истинным источником правил.
Агрегаты: как определить границы согласованности
Зачем вообще нужны агрегаты
Агрегат — это способ держать инварианты в рамках транзакционной границы и управлять согласованностью. Важно понимать: агрегат — не “огромный комбайн”, а граница консистентности.
Когда вы выбираете агрегат, вы отвечаете на два вопроса:
- Какие изменения должны быть атомарными?
- Какие инварианты должны проверяться единообразно?
Пример: Заказ и Платёж
Допустим, у вас есть Order и Payment. В домене логика может быть такой:
- Платёж привязывается к заказу.
- Нельзя перевести заказ в “Оплачен”, пока платёж не подтверждён.
- Суммы заказа и платежа должны сходиться по правилам.
Здесь возникает выбор: держать ли Payment внутри агрегата заказа или моделировать отдельно?
Вариант A (внутри агрегата): Order содержит Payments как часть агрегата. Инварианты “платёж подтверждён ⇒ заказ оплачен” обеспечиваются в одном агрегате.
Плюс: консистентность сильная. Минус: агрегат может стать тяжёлым и часто обновляемым.
Вариант B (отдельные агрегаты): Order и Payment — независимы. Переход статусов заказа происходит через события/команды (например, ConfirmPayment), а согласованность станет в итоге-согласованной.
Плюс: меньше связности и нагрузка распределяется. Минус: бизнес-должен принять eventual consistency или компенсировать несостыковки.
Выбор — это архитектурная сделка. Но важно: он должен быть сделан по консистентности, а не по форме JSON.
Границы ответственности: где делать преобразования под API
Почему “преобразование” — это не одна операция
“Преобразования под API” могут означать разные вещи:
- Синхронизация формата ответа: доменные объекты → модели для JSON.
- Форматирование/агрегации для чтения: посчитать суммы, собрать список полей, подготовить представление.
- Разбор входных данных: запрос клиента → команда/параметры домена.
- Разрешение разной гранулярности: “карточка” и “список” требуют разных наборов данных.
Ошибка возникает, когда всё это делают в одном месте (обычно в сервисе приложения или контроллере) и тем самым дублируют бизнес-правила.
Правило: домен отвечает за правила, интерфейс — за контракт
Можно сформулировать так:
- В домене должны происходить проверки инвариантов и вычисления, которые являются частью бизнес-смысла.
- В интерфейсе допустимы адаптеры и преобразования “для удобства клиента”, если они не подменяют доменные правила.
Например, вычисление “округлить сумму по валюте” — бизнес. А вот “склеить адрес в одну строку для ответа” — чаще представление.
Избежать «анемичных» сервисов: практический рецепт
Что такое анемичные сервисы (в терминах, которые можно распознать)
Анемичность проявляется так:
- сущности — просто data-структуры без методов поведения,
- сервисы приложения превращаются в длинные сценарии с десятками проверок и прямой манипуляцией полями сущностей,
- репозитории отдают сущности, но домен не гарантирует корректность изменений,
- в итоге логика размазана: часть в контроллере, часть в сервисе, часть в мапперах.
Это обычно происходит, когда разработчики ориентируются на “как удобно собрать ответ” и “как удобно принять запрос”, игнорируя инварианты.
Рецепт: “умные” агрегаты + команды как вход домена
Почти универсальная практика:
- Входные данные API преобразуются в команды приложения (или параметры сценариев).
- Приложение загружает агрегат(ы) через репозиторий.
- Агрегат выполняет действие, проверяет инварианты и меняет своё состояние.
- Приложение сохраняет агрегат и публикует события, если нужно.
- Для чтения используются отдельные проекции (или хотя бы отдельные модели), не смешивая вычисление “как показать” с “как обеспечить корректность”.
Ниже — пример каркаса доменной модели и слоя маппинга.
Пример: команда → агрегат → модель для API
Допустим, есть домен заказов и бизнес-операция “создать заказ из корзины”. В API это выглядит как POST /orders.
Доменные типы и агрегат
// Value Object
class Money {
readonly amount: number;
readonly currency: string;
constructor(amount: number, currency: string) {
if (!Number.isFinite(amount)) throw new Error("Invalid amount");
if (!currency) throw new Error("Currency is required");
this.amount = amount;
this.currency = currency;
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("Currency mismatch");
}
return new Money(this.amount + other.amount, this.currency);
}
}
type OrderStatus = "Draft" | "Confirmed";
class Order {
private readonly id: string;
private status: OrderStatus;
private total: Money;
private constructor(id: string, total: Money) {
this.id = id;
this.total = total;
this.status = "Draft";
}
static create(id: string, itemsTotal: Money): Order {
if (itemsTotal.amount <= 0) {
throw new Error("Order total must be positive");
}
return new Order(id, itemsTotal);
}
confirm(): void {
if (this.status !== "Draft") {
throw new Error("Only draft orders can be confirmed");
}
// доменные инварианты могли бы требовать проверок адреса, согласий и т.п.
this.status = "Confirmed";
}
// Read model fragments (не для удобства API, а для консистентного чтения домена)
getId(): string {
return this.id;
}
getStatus(): OrderStatus {
return this.status;
}
getTotal(): Money {
return this.total;
}
}
Заметьте: домен не “знает” про JSON и не хранит формы ответа. Но он знает правила.
Приложение: оркестрация сценария
type CreateOrderCommand = {
orderId: string;
itemsTotal: { amount: number; currency: string };
};
interface OrderRepository {
getById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
class CreateOrderHandler {
constructor(private repo: OrderRepository) {}
async handle(cmd: CreateOrderCommand): Promise<{ orderId: string }> {
const existing = await this.repo.getById(cmd.orderId);
if (existing) throw new Error("Order already exists");
const money = new Money(cmd.itemsTotal.amount, cmd.itemsTotal.currency);
const order = Order.create(cmd.orderId, money);
// Если у вас есть сценарии, где подтверждение сразу — делайте это здесь
// или в отдельной команде. Важно: решение может быть доменным/сценарным, но не “показным”.
order.confirm();
await this.repo.save(order);
return { orderId: order.getId() };
}
}
Представление: модель ответа для API
// Пример DTO для API
type OrderResponse = {
id: string;
status: string;
total: { amount: number; currency: string };
};
function toOrderResponse(order: Order): OrderResponse {
return {
id: order.getId(),
status: order.getStatus(),
total: {
amount: order.getTotal().amount,
currency: order.getTotal().currency,
},
};
}
Эта функция не содержит бизнес-логики. Она отвечает только за контракт.
Где делать агрегаты и вычисления для чтения: не смешивайте с доменом
Dilemma: “Нужно отдать клиенту агрегаты — значит ли это, что они должны быть в агрегате домена?”
Не обязательно. Частая ошибка — пытаться хранить в агрегатах всё, что нужно для отчётов и списков. Это ведёт к:
- тяжёлым транзакциям на чтении/записи,
- сложным инвариантам, которые зависят от представления,
- конфликтам конкурентных обновлений.
Решение: разделять модели для чтения и модели для домена.
Стратегии для чтения
- Проекции (read models): отдельные структуры данных, обновляемые событиями домена.
- On-demand агрегации: вычислять суммы/списки на чтении, но аккуратно отделяя это от доменных инвариантов.
- Гибрид: домен хранит исходные факты, а представление строит то, что нужно интерфейсу.
Практическое правило: если “агрегат” для ответа — это производная величина (например, “общая стоимость по статусу за период”), то это чаще read-model. Если же агрегат — это часть инварианта (например, “общая сумма должна соответствовать составу заказа”), тогда это доменная логика.
Типовая ловушка: мапперы, которые начинают повторять бизнес
Симптомы
- В маппере “toResponse” появляются проверки вроде “если статус такой-то, то поле X должно быть пустым/не null”.
- В сервисе приложения вы видите “if API клиента хочет поле…”.
- То же правило встречается и в домене, и в маппере.
Это означает, что “правило” описано не в одном месте. Один и тот же смысл начинает жить в двух моделях — доменной и представления.
Антидот: единый источник истины
Старайтесь:
- правила в домене,
- форматовка для клиента — в интерфейсе,
- проверки корректности входа — в команде/приложении, если это контекст сценария, либо в домене, если это инвариант сущности.
Типовая ловушка: “агрегат ради удобства репозитория”
Иногда команда выбирает агрегаты так, чтобы удобнее было писать запросы в БД. Например:
- кладут “пользователя + его подписки + его роли” в один агрегат только потому, что так проще сделать JOIN.
- игнорируют, что подписки меняются независимо и часто.
Результат — постоянные блокировки, конфликт правок и рост сложности.
Критерий агрегата — консистентность и инварианты, а не структура таблиц и не удобство ORM.
Подход к проектированию: от сценариев к модели
Чтобы модель не выглядела “придуманной от DTO”, полезно идти от сценариев:
- Список use-case’ов: создать заказ, изменить адрес, подтвердить заказ, аннулировать, оплатить.
- Для каждого сценария определить:
- что должно быть атомарным,
- какие инварианты участвуют,
- какие поля должны изменяться вместе.
- По результату выделить агрегаты.
- Определить границы репозиториев.
- Для чтения определить:
- какие представления нужны клиенту,
- какие поля производны и как их вычислять/хранить.
Так вы избегаете ситуации, когда модель строится “под эндпоинты”, а не под бизнес.
Как проектировать API контракты, не превращая их в доменные модели
DTO — это контракт, а не домен
DTO/Request/Response модели имеют право быть “неидеальными”. Их задача — стабильно и предсказуемо описывать интерфейс.
Но не позволяйте DTO “протекать” в домен:
- не делайте доменные методы, принимающие JSON-формат,
- не храните специфичные для API поля как обязательные инварианты домена.
Хороший паттерн — переводить вход из контракта в доменные типы (Value Objects) на границе приложения.
Компромиссы: когда всё-таки нужен “частичный” маппинг в домене
Есть ситуации, когда преобразования неотделимы от инвариантов. Например, домен требует нормализовать телефон (формат E.164), иначе инвариант “валидный телефон” нарушается. Тогда нормализацию можно делать:
- в фабрике Value Object (
PhoneNumber.parse(...)), - в валидаторе Value Object,
- в доменном методе, который использует результат нормализации.
Это всё ещё “преобразование”, но оно встроено в бизнес-смысл. Смысл не в удобстве ответа, а в корректности состояния домена.
Где именно хранить “агрегаты” и “границы ответственности” в кодовой базе
Рекомендованная структура модулей (пример)
domain/entities/(Entity)value-objects/aggregates/(если выделяете отдельно)domain-events/
application/commands/command-handlers/queries/(если используете CQRS)transaction/(орchestrations)
infrastructure/repositories/реализацииevent-bus/db/
presentation/- controllers/handlers
- request/response schemas
- mappers to/from domain/read models
При таком разнесении легче следить за тем, чтобы “модель для API” не начинала заменять доменную.
Как не впасть в крайности: “тяжёлый домен” vs “тяжёлый контроллер”
Если домен слишком лёгкий
Появляются:
- много кода в сервисах приложения,
- сущности без поведения,
- сложные сценарии, которые невозможно читать.
Выход: перемещать инварианты и поведение в агрегаты/Value Objects.
Если домен слишком тяжёлый
Появляются:
- доменные классы, которые знают всё о БД и API,
- невозможность переиспользовать логику,
- дом
Комментарии
Пока нет комментариев