Все уроки
БэкендСредний13 мин чтенияВ моём стеке

Архитектура проекта

Как организовать бэкенд-код, чтобы он пережил рост, новых клиентов и новых разработчиков.

  • Layers
  • Services
  • DTOs
  • Modules
  • Monorepos
Шаг 1/5
Приходит запрос: POST /orders. Router смотрит на URL и метод и выбирает action контроллера, который его обработает.

За 30 секунд

Архитектура — это то, как вы делите код на части с понятными обязанностями: что обрабатывает HTTP, где живут бизнес-правила, как хранятся данные. Хорошая структура позволяет добавить мобильное приложение, админку или нового члена команды, не переписывая всё. Ключевые идеи — слои, сервисы, DTO, валидация на границах и модули по доменам.

Почему я это использую

  • Мне важна архитектура, потому что один бэкенд часто обслуживает сайт, мобильное приложение и админку, и они должны разделять правила, не разделяя при этом каждый эндпоинт.
  • Я структурирую код по доменам, чтобы фича вроде лояльности или склада могла расти, не затрагивая всё остальное.
  • Я использую монорепозитории, потому что общие типы и пакеты для API, веба и приложений устраняют целый класс интеграционных багов.
  • Понятные слои делают код-ревью и работу с AI безопаснее: все знают, куда относится изменение.

Как это работает

01Слои: у каждой части одна задача

Представьте ресторан. Официант принимает заказ (HTTP-слой), шеф решает, как его готовить (бизнес-слой), на складе хранятся продукты (слой данных). Официант никогда не готовит, а шеф никогда не общается с гостями. В коде: контроллеры или обработчики маршрутов разбирают запрос и возвращают ответ; сервисы или use cases применяют бизнес-правила; репозитории или ORM-модели читают и пишут данные. Зависимости направлены внутрь: контроллер знает о сервисе, сервис знает о слое данных, но бизнес-логика ничего не знает про HTTP. Благодаря этому одну и ту же логику можно переиспользовать из API, CLI-команды, задачи в очереди или теста.

  • Presentation: контроллеры, валидация запросов, форматирование ответов.
  • Application: сервисы или use cases, которые оркестрируют задачу (PlaceOrder, RefundPayment).
  • Domain: сущности, value objects и правила (Money, переходы OrderStatus).
  • Infrastructure: база данных, очереди, платёжные шлюзы, почтовые провайдеры.

02Сервисы, use cases и DTO

Сервис объединяет связанные операции одного домена (OrderService). Use case, или экшен, — это одна операция с понятным названием (PlaceOrder). Оба принимают простой ввод и возвращают простой результат. Этот ввод — DTO, Data Transfer Object: небольшой типизированный объект, который описывает ровно те поля, которые принимает операция. DTO не дают сырым массивам из запроса просочиться в бизнес-логику и делают контракт очевидным. Валидируйте на границах: проверяйте форму и типы там, где данные входят в систему (HTTP-запрос, вебхук, импорт CSV, сообщение из очереди), а внутри доверяйте DTO. Бизнес-правила вроде «отменённый заказ нельзя оплатить» всё равно живут в домене, потому что они не про форму ввода.

ts
// Edge: validate and map to a DTO
export const CreateOrderSchema = z.object({
  branchId: z.string().uuid(),
  items: z.array(z.object({ productId: z.string().uuid(), qty: z.number().int().min(1) })).min(1),
});
export type CreateOrderDto = z.infer<typeof CreateOrderSchema>;
 
// Use case: business logic only, no HTTP
export class PlaceOrder {
  constructor(private orders: OrderRepository, private pricing: PricingService) {}
 
  async execute(companyId: string, dto: CreateOrderDto) {
    const quote = await this.pricing.quote(companyId, dto.items);
    return this.orders.create({ companyId, branchId: dto.branchId, ...quote });
  }
}

03Структура папок: по доменам, а не по типам файлов

Небольшие приложения можно группировать по типу: все контроллеры в одной папке, все модели — в другой. Когда фич становится больше двадцати, это начинает мешать: одно изменение затрагивает пять далёких друг от друга папок. Группировка по доменам — её ещё называют feature-папками или модулями — держит всё, что касается заказов, вместе. Каждый модуль открывает наружу небольшой публичный интерфейс (сервис, несколько типов) и скрывает остальное. Общий сквозной код (авторизация, логирование, хелперы для денег) кладётся в папку shared или core. В Laravel можно сохранить стандартные папки фреймворка и добавить доменные подпапки внутри Models и Services; в NestJS модули естественно ложатся на эту схему.

bash
src/
  modules/
    orders/
      orders.controller.ts
      orders.service.ts
      dto/create-order.dto.ts
      orders.module.ts
    menu/
    loyalty/
  shared/
    auth/  logging/  money/
  config/
    env.ts

04Отдельные API для веба, мобильного приложения и админки

У одного бэкенда часто три аудитории. Публичному сайту, мобильному приложению и админке нужны разные формы ответов, разная авторизация (сессия на cookie, токен, вход для персонала с 2FA), разные лимиты запросов и разные циклы релизов: версия мобильного приложения может прожить на телефонах год, поэтому его API должен сохранять обратную совместимость. Хороший паттерн — отдельные группы маршрутов или контроллеры для каждого канала, которые вызывают одни и те же сервисы. Клиентские фичи, одинаковые для веба и мобильного приложения, можно описать один раз и смонтировать под обоими префиксами. Эндпоинты админки живут в своей группе со строгими правами и аудит-логом. Версионируйте мобильный API явно (v1, v2), чтобы безопасно его развивать.

05Монорепозитории, конфиг и окружение

Монорепозиторий хранит несколько приложений и пакетов в одном репозитории: api, web, admin, mobile, а также общие пакеты — типы, API-клиент, UI-компоненты или конфиг ESLint. pnpm workspaces связывают пакеты локально, а Turborepo (или Nx) запускает задачи в порядке зависимостей с кэшированием, так что пересобирается и тестируется только то, что изменилось. Конфигурация должна следовать правилу twelve-factor: код везде одинаковый, различается окружение. Читайте переменные окружения один раз при старте, валидируйте их по схеме и падайте сразу, если чего-то не хватает. Никогда не коммитьте .env-файлы; вместо этого коммитьте .env.example с плейсхолдерами.

yaml
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
 
# turbo.json
{
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] },
    "test": { "dependsOn": ["^build"] },
    "lint": {}
  }
}

06Как сохранить архитектуру здоровой со временем

Архитектура разрушается по одному «срезанному углу» за раз. Защищайте её правилами, которые проверяют инструменты: lint-правила или dependency-cruiser, запрещающие одному модулю импортировать внутренности другого, strict-режим TypeScript и тесты на уровне сервисов. Когда принимаете крупное решение, пишите короткую архитектурную заметку (ADR, Architecture Decision Record), чтобы следующий человек понимал, почему так. Начинайте с модульного монолита: одно деплоящееся приложение с чёткими границами модулей. Разделить его на сервисы позже легко, если границы чистые, и почти невозможно, если нет. Рефакторьте постоянно и маленькими шагами, а не планируйте одно большое переписывание.

Когда применять

  • Любой продукт, который проживёт дольше нескольких месяцев или вырастет за пределы одного разработчика.
  • Бэкенд, обслуживающий несколько клиентов: сайт, мобильное приложение, админку, партнёров.
  • Несколько приложений с общими типами, UI или логикой, где монорепозиторий убирает дублирование.
  • Перед добавлением крупного нового домена (платежи, склад) в существующую кодовую базу.

Когда не стоит

  • Одноразовые прототипы и спайки, где скорость обучения важнее структуры.
  • Добавление тяжёлых слоёв абстракции «на всякий случай» (репозитории поверх ORM, интерфейсы с одной реализацией); добавляйте их, когда появится вторая потребность.

Альтернативы

  • Стандартная структура фреймворка

    Обычная структура Laravel или Next.js; подходит для небольших приложений, доменные папки добавляйте по мере роста.

  • Гексагональная / чистая архитектура

    Строгие порты и адаптеры; сильная изоляция, больше шаблонного кода.

  • Микросервисы

    Отдельные деплои для каждого домена; оправданы только при чётких границах и большом масштабе команды.

  • Полирепозиторий

    Один репозиторий на приложение; проще с правами доступа, сложнее делиться кодом и менять контракты одновременно.

Лучшие практики

  1. Держите контроллеры тонкими, а бизнес-логику — в сервисах или use cases.
  2. Валидируйте на каждой точке входа и передавайте внутрь типизированные DTO.
  3. Организуйте код по доменным модулям и открывайте у каждого модуля небольшой публичный интерфейс.
  4. Делайте отдельные группы маршрутов для веба, мобильного приложения и админки с общими сервисами.
  5. Валидируйте переменные окружения при старте и падайте сразу.
  6. Разделяйте типы и API-клиенты через пакеты в монорепозитории.
  7. Фиксируйте крупные решения в коротких ADR.

Частые ошибки

  • Бизнес-правила внутри контроллеров, view или React-компонентов.
  • Одна гигантская папка «utils» или «helpers», от которой зависит всё.
  • Сломанный мобильный API из-за того, что он разделяет формы ответов с сайтом.
  • Переусложнение с первого дня: пять слоёв и интерфейсы ради одного CRUD-экрана.
  • Чтение process.env или env() по всему коду вместо одного типизированного конфига.

Глубже

07Границы закрепляются инструментами, а не благими намерениями

Диаграмма в вики не остановит уставшего разработчика в 6 вечера от импорта внутреннего репозитория чужого модуля. Упавшая проверка в CI — остановит. В TypeScript dependency-cruiser проверяет правила импортов по всему проекту, а в Nx есть lint-правило enforce-module-boundaries для воркспейсов. В PHP Deptrac описывает слои и разрешённые зависимости, а архитектурные тесты Pest выражают правила как тесты, например «контроллеры не могут использовать фасад DB». Хороший первый набор правил небольшой: модули могут импортировать только публичный входной файл другого модуля; доменный код не может импортировать HTTP- и фреймворковые классы; общий код не может импортировать из модулей. Добавьте правила в CI в режиме предупреждений, исправьте существующие нарушения, затем переключите на ошибки. Каждое новое правило должно описывать реальную проблему, которая у вас была, а не теоретическую цель чистоты.

js
// .dependency-cruiser.cjs
module.exports = {
  forbidden: [
    {
      name: 'no-cross-module-internals',
      comment: 'Import other modules only through their index.ts',
      severity: 'error',
      from: { path: '^src/modules/([^/]+)/' },
      to: {
        path: '^src/modules/[^/]+/',
        pathNot: ['^src/modules/$1/', '^src/modules/[^/]+/index[.]ts$'],
      },
    },
    { name: 'shared-stays-generic', severity: 'error', from: { path: '^src/shared/' }, to: { path: '^src/modules/' } },
  ],
};

08Моделирование домена: инварианты, агрегаты и машины состояний

Инвариант — правило, которое всегда должно быть истинным: сумма заказа равна сумме его строк, возвращённый заказ нельзя оплатить. Агрегат — группа объектов, которая вместе защищает такие правила, например заказ со своими строками, и меняется он только через собственные методы, никогда через прямое редактирование строки откуда-то извне. Поля статусов — место, где прячется большинство багов, поэтому моделируйте их как явные машины состояний: таблица разрешённых переходов и одна функция, которая их применяет. Тогда на вопрос «можно ли вернуть деньги за отменённый заказ?» есть один ответ в одном месте, и можно протестировать каждую пару. Добавьте к этому value objects для денег (целые минорные единицы плюс валюта) и количеств — и многие классы багов, например ошибки округления float или отрицательные остатки, исчезают по построению.

ts
type OrderStatus = 'draft' | 'placed' | 'paid' | 'fulfilled' | 'cancelled' | 'refunded';
 
const allowed: Record<OrderStatus, OrderStatus[]> = {
  draft: ['placed', 'cancelled'],
  placed: ['paid', 'cancelled'],
  paid: ['fulfilled', 'refunded'],
  fulfilled: ['refunded'],
  cancelled: [],
  refunded: [],
};
 
export function transition(order: Order, to: OrderStatus): Order {
  if (!allowed[order.status].includes(to)) {
    throw new DomainError('Cannot move order from ' + order.status + ' to ' + to);
  }
  return { ...order, status: to, version: order.version + 1 };
}

09Как общаются модули: прямые вызовы, события и read models

Модули должны сотрудничать, не зная внутренностей друг друга. Используйте прямой вызов публичного сервиса другого модуля, когда ответ нужен сейчас, например checkout запрашивает у pricing расчёт цены. Используйте доменное событие, когда что-то произошло и другие могут отреагировать, например OrderPaid запускает начисление бонусных баллов, письмо с чеком и движение остатков; модулю заказов не нужно знать, кто слушает. Внутри одного процесса события можно отправлять после коммита; как только слушатели живут в другом процессе, записывайте их в outbox в той же транзакции. Избегайте ситуации, когда два модуля пишут в одну таблицу, потому что тогда ни один из них не владеет правилами. Отчёты, которым нужны данные многих модулей, — особый случай: стройте read model, денормализованную таблицу, обновляемую из событий или ночной задачей, вместо одного гигантского запроса, соединяющего таблицы всех модулей.

  • Команда: «сделай это» одному владельцу, может не выполниться, требует ответа.
  • Событие: «это произошло», ноль или больше слушателей, название в прошедшем времени.
  • Read model: копия, оптимизированная под запросы, согласованная в конечном счёте и пересобираемая из источника истины.

10Изменение легаси-систем: швы, strangler fig и anti-corruption layers

Переписывания «большим взрывом» обычно проваливаются: старая система продолжает меняться, а новая за ней гонится. Паттерн strangler fig заменяет систему по частям. Поставьте впереди слой маршрутизации, например reverse proxy или роутер нового приложения, переносите по одному эндпоинту или экрану за раз, а остальное отправляйте в старый код, пока от него ничего не останется. Прежде чем трогать рискованный код, напишите characterization tests, которые фиксируют, что он делает сегодня, даже если это поведение выглядит неправильным, чтобы рефакторинг менял структуру, а не результаты. Когда новому коду нужно общаться с легаси-моделью или внешней ERP со странными названиями и правилами, поставьте между ними anti-corruption layer: переводчик, который отображает их понятия в ваши, чтобы их беспорядок не протекал в ваш домен. Feature flags позволяют переключать трафик постепенно и мгновенно откатываться.

Реальные ситуации

01

Контроллер на 400 строк, который никто не хочет трогать

Проблема

Контроллер оформления заказа разросся до 400 строк, где смешаны валидация, ценообразование, остатки, вызовы оплаты и письма. Каждое изменение что-то ломает, и новые разработчики его избегают.

Как решить

Не переписывайте его разом. Сначала зафиксируйте сегодняшнее поведение feature-тестами для основных путей: успех, нет в наличии, неверный купон, ошибка оплаты. Затем выносите по шагам: валидацию — в FormRequest или схему, ценообразование — в PricingService, весь поток — в action PlaceOrder, который вызывает контроллер, а письма и обновление остатков — в слушатели события OrderPlaced, отправляемого после коммита. Прогоняйте тесты после каждого маленького шага и выкатывайте каждый шаг отдельно. В итоге контроллер сжимается примерно до двадцати строк, а тот же action теперь обслуживает мобильный канал и админку.

02

Ломающее изменение, пока старые версии приложения ещё в ходу

Проблема

Ответ заказа должен поменять форму ради новой фичи, но версия 1 мобильного приложения всё ещё установлена на тысячах телефонов, которые нельзя заставить обновиться.

Как решить

Предпочитайте аддитивные изменения: добавляйте новые поля и сохраняйте старые, потому что старые клиенты игнорируют то, чего не знают. Если форма действительно должна измениться, создайте группу роутов v2, контроллеры которой вызывают те же сервисы и используют новый Resource, а v1 оставьте нетронутой. Отправляйте версию приложения в заголовке и логируйте использование по версиям, чтобы знать, когда трафик v1 станет достаточно низким. Объявите удаление заголовками ответа Deprecation и Sunset и подсказкой об обновлении в приложении, добавьте проверку минимальной поддерживаемой версии и удаляйте v1 только после согласованной даты. Контрактные тесты для каждой версии предотвращают случайные поломки.

03

CI в монорепозитории теперь идёт 25 минут

Проблема

Монорепозиторий вырос до шести приложений и десяти пакетов. Каждый pull request собирает и тестирует всё, поэтому даже исправление опечатки ждёт зелёных проверок 25 минут.

Как решить

Запускайте только то, что изменилось. С Turborepo используйте turbo run lint test build --affected, чтобы запускались только пакеты, затронутые веткой, и их зависимые; включите remote caching, чтобы неизменённые задачи восстанавливались, а не выполнялись заново. Убедитесь, что каждая задача правильно объявляет свои outputs и inputs, иначе кэширование будет ошибочным. Разбейте пайплайн на параллельные задачи для lint, типов, юнит-тестов и E2E, а медленные E2E-наборы перенесите в merge queue или на ночь. Проверьте и граф зависимостей: общий пакет, который каждое приложение импортирует ради одного хелпера, вызывает полные пересборки, поэтому разделите его на пакеты поменьше.

Шпаргалка

Запустить задачи только для пакетов, изменённых в этой ветке
turbo run lint test build --affected
Собрать одно приложение и всё, от чего оно зависит
pnpm --filter @app/api... build
Добавить пакет воркспейса как зависимость
pnpm --filter @app/web add @app/types@workspace:*
Проверить правила импортов TypeScript
npx depcruise src --config .dependency-cruiser.cjs
Проверить правила слоёв PHP
vendor/bin/deptrac analyse
Архитектурное правило Pest
arch()->expect('App\Domain')->not->toUse('Illuminate\Http');
Один раз валидировать конфиг при старте
export const env = EnvSchema.parse(process.env);
Имя файла architecture decision record
docs/adr/0007-use-outbox-for-domain-events.md

В моих реальных проектах

  • На платформе для сети ресторанов я разделил контроллеры Laravel на слои Shared, Ui, Mobile и Admin, чтобы веб и мобильное приложение переиспользовали базовую логику через отдельные каналы.
  • На платформе зоотоваров клиентские маршруты описаны один раз и смонтированы в пространствах имён и для веба, и для мобильного приложения; сервисы построены поверх доменных моделей, а ценообразование смоделировано через value objects.
  • Мультитенантный POS SaaS, который я веду вместе с коллегой, — это монорепозиторий на pnpm и Turborepo с 6 приложениями и 10 общими пакетами.
  • Я поддерживаю внутренний монорепозиторий примерно из 10 переиспользуемых пакетов (account, admin UI, forms, core, e-commerce), которые используются примерно на 10 клиентских сайтах.

Вопросы и ответы

Q1Как вы структурируете бэкенд-проект с нуля?

Я начинаю с модульного монолита, организованного по доменам: заказы, каталог, клиенты, платежи — у каждого вместе лежат контроллеры, сервисы, DTO и доступ к данным. Контроллеры тонкие: провалидировать ввод, вызвать сервис, вернуть ресурс. Сервисы содержат бизнес-правила и ничего не знают про HTTP. Сквозные задачи — авторизация, логирование, конфиг и работа с деньгами — уходят в общий слой. Конфигурация берётся из провалидированных переменных окружения. С первого дня я добавляю тесты на уровне сервисов и feature-тесты для ключевых эндпоинтов. Если позже какому-то домену понадобится независимое масштабирование, чистые границы делают его выделение реальным.

Q2Что такое DTO и зачем он нужен?

Data Transfer Object — это простой типизированный объект без поведения, который описывает ровно те данные, которые операция принимает или возвращает. Вместо того чтобы передавать в сервис сырой массив из запроса, вы валидируете запрос и превращаете его, скажем, в CreateOrderDto. Преимущества: контракт сервиса явный и проверяется типами, неизвестные поля не проскочат, один и тот же сервис можно вызвать из HTTP, CLI или очереди с тем же объектом, а тесты писать легко. В NestJS DTO — это классы с декораторами class-validator; в Laravel эту роль играет FormRequest или data-класс.

Q3Должны ли веб, мобильное приложение и админка использовать один API?

Они должны разделять бизнес-логику, но обычно не одни и те же эндпоинты. Мобильные приложения стоят на телефонах месяцами, поэтому их API нужно явное версионирование и обратная совместимость. Эндпоинтам админки нужны более строгие права, аудит-логи и другие формы данных, например массовые операции и фильтры. Сайт может работать на сессиях с cookie, а приложение — на токенах. Поэтому я делаю отдельные группы маршрутов или контроллеры для каждого канала, которые вызывают одни и те же сервисы. Если фича действительно одинакова для клиентов веба и мобильного приложения, я описываю её один раз и монтирую под обоими префиксами. Так остаётся единый источник истины без связывания циклов релизов.

Q4Когда монорепозиторий — хорошая идея?

Когда несколько приложений разделяют код и меняются вместе: API, веб-приложение, админка и мобильное приложение с общими типами, схемами валидации, API-клиентом и UI-компонентами. Монорепозиторий позволяет изменить контракт API и всех его потребителей в одном pull request, запускать только затронутые тесты и держать инструменты единообразными. pnpm workspaces вместе с Turborepo или Nx берут на себя связывание пакетов и кэшированный запуск задач. Он менее полезен, когда приложениями владеют разные команды с отдельными процессами релизов или когда доступ нужно ограничивать по проектам. Тогда лучше работают отдельные репозитории с опубликованными пакетами.

Q5Как вы управляете конфигурацией в разных окружениях?

Код везде одинаковый, конфигурация — из переменных окружения, по принципам twelve-factor. Я читаю переменные в одном месте при старте и валидирую их по схеме — например, через zod или конфиг-файлы Laravel, — и падаю сразу, если чего-то не хватает или формат неверный. Остальной код читает типизированный конфиг и никогда не обращается к окружению напрямую. Секреты приходят из хостинг-платформы или менеджера секретов, никогда — из репозитория; в репозитории лежит .env.example с плейсхолдерами. Feature-флаги покрывают поведение, которое должно меняться без деплоя. У каждого окружения — локального, staging и продакшена — свои учётные данные и базы данных.

Вопросы уровня senior

S1Когда бы вы разделили модульный монолит на отдельные сервисы?

Только когда конкретная сила оправдывает цену. Хорошие причины: модулю нужно совсем другое масштабирование, как конвейеру изображений или realtime-шлюзу; отдельной команде нужно деплоиться независимо; части нужна изоляция ради безопасности или комплаенса, например для данных карт; или ей лучше подходит другой рантайм. Плохие причины: это кажется современным или код запутан, потому что запутанный модуль становится запутанным сервисом с добавленными сетевыми вызовами. Перед разделением модуль уже должен общаться только через публичный интерфейс и события, владеть своими таблицами и не иметь межмодульных транзакций. Тогда выделение — это в основном перенос кода и замена вызовов внутри процесса на HTTP или сообщения, плюс добавление retries, таймаутов, идемпотентности и трейсинга, и именно это — настоящая постоянная цена.

S2Стоит ли clean или hexagonal architecture того для типичного продукта?

Основная идея стоит всегда: бизнес-правила не должны зависеть от HTTP, ORM или SDK вендора, а интеграции стоят за интерфейсами, которыми владеете вы. Полная церемония — часто нет. Обёртывание Eloquent или Prisma в репозитории с одной реализацией, двойное маппирование каждой сущности и порт для каждого класса утраивают код CRUD-экрана и замедляют команду. Я применяю это выборочно: строгие ports and adapters вокруг платежей, ERP, SMS и всего, что могу заменить или должен подделать в тестах, и чистый доменный слой для сложных правил вроде ценообразования или лояльности. Простой CRUD в админке остаётся близко к фреймворку. Проверка — есть ли у абстракции второе реальное применение, например тестовый fake или запланированный второй провайдер.

S3Как поддерживать согласованность данных между модулями без общих транзакций?

Внутри монолита с одной базой всё ещё можно использовать одну транзакцию, но я избегаю ситуации, когда одна транзакция пишет в таблицы нескольких модулей, потому что это связывает их схемы. Вместо этого модуль-владелец коммитит своё изменение и в той же транзакции записывает доменное событие в таблицу outbox. Другие модули реагируют на outbox асинхронно и идемпотентно. Это делает систему согласованной в конечном счёте, поэтому интерфейс и процессы должны мириться с короткой задержкой, например бонусные баллы появляются секундой позже. Для многошаговых бизнес-процессов вроде «зарезервировать остаток, списать деньги, подтвердить» я моделирую сагу с явными компенсирующими шагами, а не делаю вид, что это атомарно. Компромисс — больше проектирования заранее в обмен на модули, которые могут развиваться и позже чисто разделяться.

S4Как вы организуете отчётность по многим модулям?

Отчётность — то место, где границы обычно ломаются: кто-то пишет один SQL-запрос, соединяющий таблицы всех модулей, и теперь ни один модуль не может поменять свою схему. Я отделяю чтения для отчётов от транзакционной модели. Маленькие продукты могут использовать представления базы, которыми владеет модуль отчётности, — это даёт одно место для обновления при изменении схем. Крупные получают read models: денормализованные таблицы или отдельную аналитическую базу, которые питаются доменными событиями или плановыми выгрузками и имеют форму ровно под дашборды. Они согласованы в конечном счёте и могут быть пересобраны из источника истины, а это ещё и делает тяжёлые отчёты безвредными для продакшен-базы. Компромисс — поддержка пайплайна и небольшая неактуальность, с которой большинство бизнес-отчётов легко мирится.

Глоссарий

Bounded context
Часть системы, где слово имеет одно точное значение, например «клиент» в биллинге и в поддержке.
Инвариант
Бизнес-правило, которое всегда должно выполняться, например «остаток никогда не уходит ниже нуля».
Агрегат
Группа объектов, изменяемых вместе через одну точку входа, чтобы их инварианты оставались истинными.
Value object
Маленький неизменяемый тип, определяемый своим значением, вроде Money или EmailAddress, который сам себя валидирует.
Read model
Копия данных, подготовленная для быстрых запросов или отчётов и обновляемая из источника истины.
Anti-corruption layer
Слой перевода, который не даёт внешней или легаси-модели протечь в ваш собственный домен.
Strangler fig
Постепенная замена старой системы, при которой функции по одной направляются в новый код.
Characterization test
Тест, фиксирующий, что существующий код делает сегодня, чтобы рефакторинг мог доказать, что ничего не изменилось.
Связность и зацепление
Связность (cohesion): насколько связаны вещи внутри модуля. Зацепление (coupling): насколько модули зависят друг от друга. Первое — высоко, второе — низко.

Есть идея продукта?Давайте создадим его.

Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.