$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогМоделирование данных для API: как выбрать сущности, агрегаты и границы ответственности

Моделирование данных для API: как выбрать сущности, агрегаты и границы ответственности

$ sudo teach IT
·20 июля 2026 г.·10 мин·51
Моделирование данных для API: как выбрать сущности, агрегаты и границы ответственности

Покажем подход к проектированию доменной модели под API: где делать преобразования, как избежать «анемичных» сервисов.

Содержание
Почему API ломает доменную модель — и как это предотвратитьБазовые сущности: как выбрать, что “живет” в доменеСущность и ценность: не всё нужно делать доменными сущностямиПрактический критерий: где лежат инвариантыАгрегаты: как определить границы согласованностиЗачем вообще нужны агрегатыПример: Заказ и ПлатёжГраницы ответственности: где делать преобразования под APIПочему “преобразование” — это не одна операцияПравило: домен отвечает за правила, интерфейс — за контрактИзбежать «анемичных» сервисов: практический рецептЧто такое анемичные сервисы (в терминах, которые можно распознать)Рецепт: “умные” агрегаты + команды как вход доменаПример: команда → агрегат → модель для APIДоменные типы и агрегатПриложение: оркестрация сценарияПредставление: модель ответа для APIГде делать агрегаты и вычисления для чтения: не смешивайте с доменомDilemma: “Нужно отдать клиенту агрегаты — значит ли это, что они должны быть в агрегате домена?”Стратегии для чтенияТиповая ловушка: мапперы, которые начинают повторять бизнесСимптомыАнтидот: единый источник истиныТиповая ловушка: “агрегат ради удобства репозитория”Подход к проектированию: от сценариев к моделиКак проектировать API контракты, не превращая их в доменные моделиDTO — это контракт, а не доменКомпромиссы: когда всё-таки нужен “частичный” маппинг в доменеГде именно хранить “агрегаты” и “границы ответственности” в кодовой базеРекомендованная структура модулей (пример)Как не впасть в крайности: “тяжёлый домен” vs “тяжёлый контроллер”Если домен слишком лёгкийЕсли домен слишком тяжёлый

Проектирование 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/модели представления, а не новые доменные сущности.

Практический критерий: где лежат инварианты

Сущность имеет смысл, если у неё есть инварианты и поведение. Например:

  • Заказ изменяется только через валидные переходы статусов.
  • Позиции заказа подчиняются правилам (например, минимальная кратность).
  • Пользователь может иметь не произвольный набор прав, а ограниченный набор.

Если вы видите, что “у сущности только поля, а вся логика — где-то снаружи”, это красный флаг. Логика, связанность и проверки должны быть ближе к данным, чтобы домен оставался истинным источником правил.


Агрегаты: как определить границы согласованности

Зачем вообще нужны агрегаты

Агрегат — это способ держать инварианты в рамках транзакционной границы и управлять согласованностью. Важно понимать: агрегат — не “огромный комбайн”, а граница консистентности.

Когда вы выбираете агрегат, вы отвечаете на два вопроса:

  1. Какие изменения должны быть атомарными?
  2. Какие инварианты должны проверяться единообразно?

Пример: Заказ и Платёж

Допустим, у вас есть Order и Payment. В домене логика может быть такой:

  • Платёж привязывается к заказу.
  • Нельзя перевести заказ в “Оплачен”, пока платёж не подтверждён.
  • Суммы заказа и платежа должны сходиться по правилам.

Здесь возникает выбор: держать ли Payment внутри агрегата заказа или моделировать отдельно?

Вариант A (внутри агрегата): Order содержит Payments как часть агрегата. Инварианты “платёж подтверждён ⇒ заказ оплачен” обеспечиваются в одном агрегате.
Плюс: консистентность сильная. Минус: агрегат может стать тяжёлым и часто обновляемым.

Вариант B (отдельные агрегаты): Order и Payment — независимы. Переход статусов заказа происходит через события/команды (например, ConfirmPayment), а согласованность станет в итоге-согласованной.
Плюс: меньше связности и нагрузка распределяется. Минус: бизнес-должен принять eventual consistency или компенсировать несостыковки.

Выбор — это архитектурная сделка. Но важно: он должен быть сделан по консистентности, а не по форме JSON.


Границы ответственности: где делать преобразования под API

Почему “преобразование” — это не одна операция

“Преобразования под API” могут означать разные вещи:

  1. Синхронизация формата ответа: доменные объекты → модели для JSON.
  2. Форматирование/агрегации для чтения: посчитать суммы, собрать список полей, подготовить представление.
  3. Разбор входных данных: запрос клиента → команда/параметры домена.
  4. Разрешение разной гранулярности: “карточка” и “список” требуют разных наборов данных.

Ошибка возникает, когда всё это делают в одном месте (обычно в сервисе приложения или контроллере) и тем самым дублируют бизнес-правила.

Правило: домен отвечает за правила, интерфейс — за контракт

Можно сформулировать так:

  • В домене должны происходить проверки инвариантов и вычисления, которые являются частью бизнес-смысла.
  • В интерфейсе допустимы адаптеры и преобразования “для удобства клиента”, если они не подменяют доменные правила.

Например, вычисление “округлить сумму по валюте” — бизнес. А вот “склеить адрес в одну строку для ответа” — чаще представление.


Избежать «анемичных» сервисов: практический рецепт

Что такое анемичные сервисы (в терминах, которые можно распознать)

Анемичность проявляется так:

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

Это обычно происходит, когда разработчики ориентируются на “как удобно собрать ответ” и “как удобно принять запрос”, игнорируя инварианты.

Рецепт: “умные” агрегаты + команды как вход домена

Почти универсальная практика:

  1. Входные данные API преобразуются в команды приложения (или параметры сценариев).
  2. Приложение загружает агрегат(ы) через репозиторий.
  3. Агрегат выполняет действие, проверяет инварианты и меняет своё состояние.
  4. Приложение сохраняет агрегат и публикует события, если нужно.
  5. Для чтения используются отдельные проекции (или хотя бы отдельные модели), не смешивая вычисление “как показать” с “как обеспечить корректность”.

Ниже — пример каркаса доменной модели и слоя маппинга.


Пример: команда → агрегат → модель для API

Допустим, есть домен заказов и бизнес-операция “создать заказ из корзины”. В API это выглядит как POST /orders.

Доменные типы и агрегат

code
// 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 и не хранит формы ответа. Но он знает правила.

Приложение: оркестрация сценария

code
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

code
// Пример 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: “Нужно отдать клиенту агрегаты — значит ли это, что они должны быть в агрегате домена?”

Не обязательно. Частая ошибка — пытаться хранить в агрегатах всё, что нужно для отчётов и списков. Это ведёт к:

  • тяжёлым транзакциям на чтении/записи,
  • сложным инвариантам, которые зависят от представления,
  • конфликтам конкурентных обновлений.

Решение: разделять модели для чтения и модели для домена.

Стратегии для чтения

  1. Проекции (read models): отдельные структуры данных, обновляемые событиями домена.
  2. On-demand агрегации: вычислять суммы/списки на чтении, но аккуратно отделяя это от доменных инвариантов.
  3. Гибрид: домен хранит исходные факты, а представление строит то, что нужно интерфейсу.

Практическое правило: если “агрегат” для ответа — это производная величина (например, “общая стоимость по статусу за период”), то это чаще read-model. Если же агрегат — это часть инварианта (например, “общая сумма должна соответствовать составу заказа”), тогда это доменная логика.


Типовая ловушка: мапперы, которые начинают повторять бизнес

Симптомы

  • В маппере “toResponse” появляются проверки вроде “если статус такой-то, то поле X должно быть пустым/не null”.
  • В сервисе приложения вы видите “if API клиента хочет поле…”.
  • То же правило встречается и в домене, и в маппере.

Это означает, что “правило” описано не в одном месте. Один и тот же смысл начинает жить в двух моделях — доменной и представления.

Антидот: единый источник истины

Старайтесь:

  • правила в домене,
  • форматовка для клиента — в интерфейсе,
  • проверки корректности входа — в команде/приложении, если это контекст сценария, либо в домене, если это инвариант сущности.

Типовая ловушка: “агрегат ради удобства репозитория”

Иногда команда выбирает агрегаты так, чтобы удобнее было писать запросы в БД. Например:

  • кладут “пользователя + его подписки + его роли” в один агрегат только потому, что так проще сделать JOIN.
  • игнорируют, что подписки меняются независимо и часто.

Результат — постоянные блокировки, конфликт правок и рост сложности.

Критерий агрегата — консистентность и инварианты, а не структура таблиц и не удобство ORM.


Подход к проектированию: от сценариев к модели

Чтобы модель не выглядела “придуманной от DTO”, полезно идти от сценариев:

  1. Список use-case’ов: создать заказ, изменить адрес, подтвердить заказ, аннулировать, оплатить.
  2. Для каждого сценария определить:
    • что должно быть атомарным,
    • какие инварианты участвуют,
    • какие поля должны изменяться вместе.
  3. По результату выделить агрегаты.
  4. Определить границы репозиториев.
  5. Для чтения определить:
    • какие представления нужны клиенту,
    • какие поля производны и как их вычислять/хранить.

Так вы избегаете ситуации, когда модель строится “под эндпоинты”, а не под бизнес.


Как проектировать 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,
  • невозможность переиспользовать логику,
  • дом

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Простыми словами о защищенном соединении

Простыми словами о защищенном соединении

Базовый курс для обычных людей: что такое защищённое соединение, как оно устроено, что оно умеет и чего не умеет, как выбрать сервис и настроить его на своих устройствах.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Сервис, который не падает - как построить безопасную зашифрованную сеть

Сервис, который не падает - как построить безопасную зашифрованную сеть

Практический курс по безопасности сети: как собрать собственный сервис защищённого доступа - от пустого сервера до подписки с автовыбором - и, что важнее, как потом не уронить его своими руками.

Перейти к курсу

Другие статьи

Модели данных и типизация в TypeScript: как проектировать контракт между фронтом и API
модели

Модели данных и типизация в TypeScript: как проектировать контракт между фронтом и API

Разберём, как описывать доменные сущности, DTO и error-model на уровне типов, чтобы ловить несовпадения схем до продакшена. Покажем практические шаблоны для типобезопасного маппинга и версионирования контрактов.

15 сентября 2026 г.
1050
Какой первый проект выбрать новичку, чтобы не бросить обучение
первый

Какой первый проект выбрать новичку, чтобы не бросить обучение

Подберём 5–7 идей под уровень “с нуля”, объясним, что делать по шагам и как довести проект до результата без перегруза. В конце — как оформить мини-портфолио и что показать, даже если проект маленький.

23 сентября 2026 г.
910
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1431
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1090
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

Помогу новичку выбрать первый язык без стресса: объясню варианты по целям (веб, боты, анализ данных), что учить в первую очередь и как не потратить месяцы впустую. В конце — краткий план старта на ближайшие недели.

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Почему API ломает доменную модель — и как это предотвратитьБазовые сущности: как выбрать, что “живет” в доменеСущность и ценность: не всё нужно делать доменными сущностямиПрактический критерий: где лежат инвариантыАгрегаты: как определить границы согласованностиЗачем вообще нужны агрегатыПример: Заказ и ПлатёжГраницы ответственности: где делать преобразования под APIПочему “преобразование” — это не одна операцияПравило: домен отвечает за правила, интерфейс — за контрактИзбежать «анемичных» сервисов: практический рецептЧто такое анемичные сервисы (в терминах, которые можно распознать)Рецепт: “умные” агрегаты + команды как вход доменаПример: команда → агрегат → модель для APIДоменные типы и агрегатПриложение: оркестрация сценарияПредставление: модель ответа для APIГде делать агрегаты и вычисления для чтения: не смешивайте с доменомDilemma: “Нужно отдать клиенту агрегаты — значит ли это, что они должны быть в агрегате домена?”Стратегии для чтенияТиповая ловушка: мапперы, которые начинают повторять бизнесСимптомыАнтидот: единый источник истиныТиповая ловушка: “агрегат ради удобства репозитория”Подход к проектированию: от сценариев к моделиКак проектировать API контракты, не превращая их в доменные моделиDTO — это контракт, а не доменКомпромиссы: когда всё-таки нужен “частичный” маппинг в доменеГде именно хранить “агрегаты” и “границы ответственности” в кодовой базеРекомендованная структура модулей (пример)Как не впасть в крайности: “тяжёлый домен” vs “тяжёлый контроллер”Если домен слишком лёгкийЕсли домен слишком тяжёлый