Архитектура и данные

Модульный монолит: как получить простоту монолита и не превратить проект в один большой файл

Есть неприятный сценарий, который повторяется во многих веб-проектах.

Первая версия приложения выглядит аккуратно:

app/
├── routes/
├── services/
├── models/
└── utils/

Проходит несколько месяцев.

Добавляются:

регистрация;
личный кабинет;
проекты;
файлы;
сообщения;
платежи;
уведомления;
админ-панель.

Ещё через год оказывается, что:

projectService

импортирует:

paymentService;
notificationService;
userService;
fileService.

paymentService в ответ импортирует projectService.

Любой модуль имеет доступ ко всем таблицам.

В папке:

utils

уже находится половина бизнес-логики.

А файл:

project.service.js

занимает несколько тысяч строк.

Чтобы изменить один статус проекта, разработчику приходится разбираться одновременно в:

платежах;
уведомлениях;
ролях;
файлах;
аналитике.

Формально приложение всё ещё является монолитом.

Но проблема не в том, что оно разворачивается одним процессом.

Проблема в том, что внутри него практически исчезли границы.

Именно эту проблему решает модульный монолит.

Он позволяет оставить:

один проект;
один deployment;
одну основную базу;
простые транзакции;
простую локальную разработку;

но при этом построить приложение из относительно независимых бизнес-модулей.

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


Начнём с главного: монолит — не значит «всё в одном файле»

Иногда слово «монолит» воспринимают буквально.

Будто архитектура обязательно должна выглядеть так:

server.js

внутри которого:

if (route === '/users') { ... }
if (route === '/projects') { ... }
if (route === '/payments') { ... }

Но монолитность говорит прежде всего о границе deployment.

Например:

Browser
   ↓
Nginx
   ↓
Application
   ↓
PostgreSQL

Приложение собирается, тестируется и разворачивается как единая система.

Внутри него при этом может существовать очень строгая архитектура:

Application
│
├── Identity
├── Customers
├── Projects
├── Billing
├── Files
└── Notifications

Это и есть основная идея модульного монолита:

физически приложение остаётся единым, логически — состоит из изолированных частей.


Почему обычного деления на controllers, services и models часто недостаточно

Один из самых популярных вариантов структуры:

src/
├── controllers/
├── services/
├── repositories/
├── models/
└── validators/

На первый взгляд всё организовано.

Но посмотрим, что происходит с одним бизнес-процессом.

Например:

Клиент оплачивает проект.

Код этой функции может быть разбросан:

controllers/payment-controller.js
services/payment-service.js
services/project-service.js
repositories/payment-repository.js
repositories/project-repository.js
models/payment.js
models/project.js
validators/payment-validator.js

Через полгода разработчик, которому нужно изменить платежный workflow, прыгает по всему проекту.

Структура разделена по техническим слоям, а не по смыслу бизнеса.


Модульная структура разворачивает проект в другую сторону

Вместо:

controllers/
services/
repositories/

на верхнем уровне появляются:

identity/
projects/
billing/
files/
notifications/

А уже внутри каждого модуля могут существовать собственные слои.

Например:

modules/
└── billing/
    ├── api/
    ├── domain/
    ├── application/
    ├── infrastructure/
    └── index.js

Для проектов попроще:

modules/
└── billing/
    ├── routes.js
    ├── service.js
    ├── repository.js
    ├── events.js
    └── public-api.js

Точная структура не так важна.

Важно другое:

всё, относящееся к одной бизнес-возможности, находится рядом.


Представим реальный SaaS или CRM

У нас есть:

Identity
Organizations
Projects
Messages
Files
Billing
Notifications

Каждый модуль должен отвечать на конкретный набор вопросов.

Например:

Identity

Кто пользователь?
Как он входит?
Какие у него credentials?
Активна ли учётная запись?

Projects

Что такое проект?
Какой у него статус?
Какие допустимы переходы?
Кто участники?

Billing

Какие существуют счета?
Какая сумма?
Оплачен ли счёт?
Есть ли возврат?

Files

Кому принадлежит файл?
Где он физически хранится?
Можно ли его скачать?
Удалён ли объект?

Это уже не технические папки.

Это части предметной области.


Первый принцип: модуль должен владеть своей бизнес-логикой

Представим Billing.

Плохая архитектура:

// projects/service.js

await db.payments.update({
  where: { id: paymentId },
  data: { status: 'PAID' }
});

Модуль Projects напрямую изменил внутренние данные Billing.

Теперь Billing фактически не контролирует собственный lifecycle.


Лучше обращаться через публичный контракт модуля

Например:

await billing.confirmPayment({
  paymentId,
  providerEventId
});

Projects не обязан знать:

какая таблица используется;
какие поля обновляются;
создаётся ли history;
нужна ли idempotency;
какие guards проверяются.

Он знает только публичную операцию:

confirmPayment()

Внутреннее устройство Billing остаётся его делом.


Получается важное правило

Для каждого модуля существуют:

public API

и:

private implementation.

Другим частям приложения разрешено зависеть только от первого.


Это очень похоже на микросервис — но без сети

Микросервис тоже скрывает внутреннее устройство за:

HTTP;
RPC;
messages.

Модульный монолит делает то же концептуально, но вызов остаётся внутри одного процесса:

Projects
   ↓
billing.getPaymentStatus()

Вместо:

Projects Service
   ↓
HTTP
   ↓
Billing Service

Мы получаем архитектурную границу без:

network timeout;
retry;
service discovery;
distributed tracing;
API authentication.

Именно в этом одно из главных преимуществ подхода.


Второй принцип: данные тоже должны иметь владельца

Можно идеально разделить код:

projects/
billing/
files/

Но если любой модуль делает:

SELECT
UPDATE
DELETE

по любым таблицам, границы быстро исчезнут.

Поэтому полезно договориться:

projects.*
→ Projects module

billing.*
→ Billing module

files.*
→ Files module

Даже если физически всё находится в одном PostgreSQL.


Это не значит, что для каждого модуля нужна отдельная БД

Совсем нет.

На старте модульный монолит может использовать:

PostgreSQL

с обычным набором таблиц:

users
organizations
projects
payments
files
messages

Но на уровне архитектуры существует правило:

Таблицу payments изменяет Billing.
Таблицу projects изменяет Projects.
Таблицу files изменяет Files.

Другой модуль не должен произвольно обходить эти правила.


Почему это важно

Сегодня Projects напрямую выполняет:

UPDATE payments
SET status = 'PAID';

Завтра Billing должен при переходе в PAID дополнительно:

записать history;
проверить сумму;
создать domain event;
проверить повторный webhook.

Projects об этом не знает.

Он продолжает менять одну колонку напрямую.

Теперь в базе появляются платежи:

status = PAID

но:

history отсутствует;
событие не создано;
проверки не выполнялись.

Бизнес-инвариант нарушен.


Хорошая граница защищает не код, а правила бизнеса

Это принципиально.

Модульность нужна не ради красивой структуры:

modules/

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

Например:

Payment

может перейти:

PENDING → SUCCEEDED

только через:

Billing.confirmPayment()

Теперь все правила находятся рядом.


Третий принцип: не делать общий shared складом всего подряд

Практически любой большой проект со временем создаёт:

shared/

или:

common/

Сначала там:

date-utils.js

Затем:

permissions.js

Потом:

project-status.js
billing-rules.js
user-helper.js
notification-helper.js

Через год половина domain живёт внутри:

shared.

Модули снова связаны.


Shared должен быть маленьким и скучным

Хорошие кандидаты:

общие primitive utilities;
базовые error types;
небольшие инфраструктурные abstractions;
общий logger interface.

Плохие:

правила оплаты;
логика статусов проектов;
проверка тарифов;
permissions конкретного domain.

Если код имеет бизнес-смысл, скорее всего у него есть настоящий владелец.


Например money.js может быть shared primitive

Тип:

Money

с операциями:

add
subtract
compare

может использоваться разными областями.

Но:

calculateInvoiceTotal()

уже относится к Billing.

Разница заключается в семантике.


Четвёртый принцип: модуль не должен знать всё о другом модуле

Представим Projects хочет показать:

Проект оплачен.

Можно сделать:

const payment = await db.payments.findFirst({
  where: { projectId }
});

Теперь Projects знает:

название таблицы;
схему Billing;
связь payment/project;
внутренние статусы.

Это сильная связанность.


Лучше запросить нужный бизнес-факт

Например:

const paymentState =
  await billing.getProjectPaymentState(projectId);

Projects получает:

PAID

или:

UNPAID

но не знает внутреннюю структуру payment subsystem.


Ещё лучше — иногда вообще не спрашивать

Например Projects интересует событие:

PAYMENT_CONFIRMED

Billing публикует его.

Projects реагирует:

WAITING_PAYMENT
        ↓
READY_TO_START

Теперь Billing не знает, что Projects сделает с событием.

А Projects не знает, как Billing получил деньги.


Внутренние domain events полезны даже без Kafka

Это важный момент.

Слово:

event

не означает автоматически:

Kafka.

В модульном монолите событие может распространяться обычным внутренним механизмом.

Например:

billing
   ↓
PAYMENT_CONFIRMED
   ↓
application event bus
   ↓
projects
notifications
analytics

Все находятся внутри одного runtime.


Зачем это нужно, если можно просто вызвать функции

Иногда прямой вызов лучше.

Например:

Projects → Identity.getUser()

понятен и синхронен.

Но событие удобно, когда Billing сообщает:

Факт произошёл.

и не должен знать, кому он интересен.

Например:

PAYMENT_CONFIRMED

может использовать:

Projects;
Notifications;
Analytics.

Billing не должен импортировать все три модуля.


Иначе зависимости начинают расходиться во все стороны

Плохой вариант:

Billing
 ├── Projects
 ├── Notifications
 ├── Analytics
 └── CRMIntegration

Добавили нового потребителя — меняем Billing.

Гораздо чище:

Billing
   ↓
PAYMENT_CONFIRMED

а подписчики уже решают, нужен ли им этот факт.


Но event-driven не нужно использовать для каждой строчки

Плохая крайность:

USER_NAME_READ_REQUESTED
PROJECT_TITLE_RESOLVED
BUTTON_LABEL_CHANGED

Система становится трудно читаемой.

Если требуется простой синхронный ответ:

getUser()

обычный метод зачастую лучше.

Events особенно полезны для свершившихся бизнес-фактов.


Пятый принцип: направление зависимостей должно быть понятно

Если построить dependency graph модулей:

Projects → Billing
Billing → Projects
Files → Projects
Projects → Files
Notifications → Projects
Projects → Notifications

мы получили почти полный граф.

Разделить такой монолит позднее будет очень трудно.


Циклические зависимости — тревожный сигнал

Например:

Projects
↓
Billing
↓
Projects

Почему Billing вообще должен знать о внутренней логике Projects?

Возможно, business event позволит убрать обратную зависимость.

Или обе функции на самом деле принадлежат одному bounded context и мы провели неправильную границу.

Цикл часто сообщает о проблеме модели.


Шестой принцип: одна функция должна иметь очевидное место

Представим разработчику нужно изменить:

Правила возврата платежа.

Он должен почти сразу понимать:

modules/billing

а не искать:

routes/admin.js
utils/payment.js
helpers/order.js
services/project.js
cron/cleanup.js

Хорошая модульная архитектура уменьшает область, которую нужно держать в голове.


Это один из главных признаков качественного проекта

Когда разработчик получает задачу:

Изменить правила статуса проекта.

В хорошо разделённой системе он открывает:

Projects

В плохо разделённой:

Нужно сначала выяснить, где вообще меняется статус.

Это напрямую влияет на стоимость последующей разработки.


Седьмой принцип: модуль должен иметь собственные тесты

Например:

modules/
└── projects/
    ├── ...
    └── tests/

Можно проверить:

какие статусы допустимы;
кто может выполнить переход;
что происходит при завершении;
какие события создаются.

Не поднимая обязательно весь пользовательский интерфейс.


Особенно полезны architecture tests

Обычный unit test проверяет:

Правильно ли работает функция?

Architecture test:

Не нарушили ли мы границы системы?

Например:

Billing
не должен импортировать
внутренние файлы Projects.

Концептуально можно запретить

modules/billing/*

импортировать:

modules/projects/internal/*

Разрешено только:

modules/projects/public-api

Это уже машинная защита архитектуры.


Одной документации:

Не импортируйте внутренности других модулей.

обычно хватает до первого срочного релиза.

Потом разработчик думает:

Сейчас один раз напрямую прочитаю таблицу — потом исправим.

Через три года таких «один раз» уже сорок.

Автоматическая проверка делает границу реальной.


В TypeScript/JavaScript можно использовать явные public entrypoints

Например:

modules/
└── billing/
    ├── internal/
    │   ├── payment-repository.js
    │   └── refund-engine.js
    │
    └── index.js

index.js экспортирует:

export {
  confirmPayment,
  createInvoice,
  refundPayment
};

Другие модули используют:

import {
  confirmPayment
} from '../billing/index.js';

А не:

import {
  updatePaymentRow
} from '../billing/internal/payment-repository.js';

Это простая техника, но эффект большой

У Billing появляется настоящий контракт.

Внутри можно менять:

таблицы;
repository;
algorithm;
структуру файлов.

Пока:

public API

остается совместимым, остальные модули не затронуты.


Восьмой принцип: HTTP layer не должен содержать весь бизнес

Плохой route:

app.post('/projects/:id/complete', async (req, res) => {
  // 150 строк
});

Внутри:

проверка роли;
запрос проекта;
смена статуса;
создание уведомления;
запись истории;
работа с оплатой.

Теперь domain logic принадлежит HTTP endpoint.


Лучше route оставить адаптером

Например:

app.post('/projects/:id/complete', async (req, res) => {
  const result =
    await projects.completeProject({
      projectId: req.params.id,
      actorId: req.user.id
    });

  res.json(result);
});

А business operation находится:

Projects.completeProject()

Теперь её можно вызвать из:

HTTP;
admin action;
background job;
integration test.

Не копируя правила.


Девятый принцип: база не должна становиться вторым публичным API

Даже если модули используют один PostgreSQL, схема БД должна рассматриваться как implementation detail модулей.

Очень опасный сценарий:

Projects читает billing tables.
Billing читает user tables.
Analytics читает всё.
Admin пишет всё.

Теперь public API модулей существует только для красоты.

Настоящей точкой интеграции стала база.


Почему это усложняет изменения

Billing хочет заменить:

payment.status

на:

payment.state

В хорошем модуле нужно изменить только Billing.

В плохой системе сначала нужно искать по всему репозиторию:

payments.status

и надеяться, что нигде не осталось raw SQL.

Это уже интеграционная база внутри монолита.


А как быть с JOIN между модулями?

Это интересный вопрос.

Модульный монолит не обязан запрещать любой JOIN как религиозное правило.

Например read-only dashboard может эффективно получить:

project
+
customer name

одним запросом.

Иногда это разумный trade-off.

Но важно отличать:

оптимизированную read model

от:

хаотичного изменения чужих данных.

Reads и writes можно рассматривать по-разному

Особенно строгими полезно делать write boundaries.

Например:

только Billing
изменяет payment.

Для сложных отчётов можно создавать:

reporting queries;
database views;
read models.

Так мы не вынуждены превращать каждый экран в десяток внутренних вызовов.


Модульность не должна уничтожать производительность

Архитектура существует ради продукта.

Если для отображения dashboard требуется:

Projects → Users
Projects → Billing
Projects → Files
Projects → Notifications

последовательно выполнить 40 функций и 40 SQL-запросов, мы создали другую проблему.

Иногда отдельная read model является лучшим решением.


Например dashboard projection

Можно подготовить:

project_summary

с данными:

projectId
title
clientName
paymentState
unreadCount
fileCount

Она обслуживает чтение.

Но источниками истины остаются соответствующие domain modules.

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


Десятый принцип: транзакции — преимущество модульного монолита, которое не стоит выбрасывать

Представим:

Создать проект после подтверждения предложения.

Нужно:

создать Project;
создать Membership;
записать History.

В одном PostgreSQL это можно выполнить:

BEGIN
  project
  membership
  history
COMMIT

Если что-то упало:

ROLLBACK.

Очень удобно.


Не нужно имитировать микросервисы там, где одна транзакция лучше

Иногда разработчики слишком буквально защищают границы:

Каждый модуль должен работать полностью асинхронно через events.

В итоге простая бизнес-операция становится:

CREATE_PROJECT_REQUESTED
↓
PROJECT_CREATED
↓
MEMBERSHIP_CREATE_REQUESTED
↓
MEMBERSHIP_CREATED

и появляется eventual consistency там, где она вообще не нужна.


Модульный монолит позволяет выбирать

Для критичного локального процесса:

одна транзакция.

Для слабосвязанного побочного действия:

event.

Например:

Project completed

можно транзакционно записать:

Project → COMPLETED
History

А:

отправить email

выполнить после commit через job/outbox.

Так мы используем сильные стороны обоих подходов.


Одиннадцатый принцип: уведомления не должны управлять бизнесом

Плохая схема:

Projects
↓
sendEmail()

И если SMTP упал:

completeProject()

тоже падает.

Получается:

Проект нельзя завершить, потому что почтовый сервер временно недоступен.

Чаще это неправильная зависимость.


Правильнее сначала зафиксировать бизнес-факт

BEGIN

Project → COMPLETED
History
Outbox → PROJECT_COMPLETED

COMMIT

После чего worker может:

send email;
Telegram;
analytics.

Если почта не работает:

Project

всё равно остаётся правильно завершённым.


Это уже очень зрелая архитектура — и всё ещё монолит

У нас могут существовать:

один codebase;
одна БД;
один release.

Но внутри:

modules;
domain events;
outbox;
worker;
clear ownership.

По уровню организации это значительно ближе к хорошо спроектированной распределённой системе, чем к «одному большому файлу».


Двенадцатый принцип: state machine должна принадлежать модулю

Например Projects имеет состояния:

NEW
ESTIMATION
OFFER_SENT
IN_PROGRESS
COMPLETED
CANCELLED

Правила переходов должны находиться:

Projects

а не быть разбросанными:

frontend;
admin controller;
billing;
notification worker.

Другие модули могут просить выполнить действие

Например Billing сообщает:

PAYMENT_CONFIRMED

Projects решает:

Разрешает ли мой текущий state такой переход?

То есть Billing не выполняет:

UPDATE projects
SET status = 'IN_PROGRESS';

напрямую.


Это защищает невозможные состояния

Например нельзя получить:

Project = IN_PROGRESS

при:

offerApproved = false.

Все пути изменения проходят через одну domain boundary.


Тринадцатый принцип: permissions тоже имеют владельца

Очень легко получить:

if (user.role === 'admin')

в сотне файлов.

Потом появляется:

manager;
client;
viewer;
accountant.

Права начинают расходиться.


Полезно разделить два уровня

Identity/Authorization может отвечать:

кто пользователь;
какие у него memberships;
базовые permissions.

А Projects:

Может ли конкретный actor изменить этот проект в его текущем состоянии?

То есть module policy знает контекст domain.


Например

projects.canComplete({
  actor,
  project
});

учитывает:

роль;
ownership;
organization;
project state.

Так authorization остаётся рядом с объектом, который защищает.


Четырнадцатый принцип: не создавать «God module»

После разделения часто появляется:

Core

который импортируют все.

А внутри Core:

users;
permissions;
config;
projects;
billing;
events;
database.

В итоге мы просто переместили монолит:

из app.js
в core/.

Настоящий core должен быть минимальным

Например:

application bootstrap;
dependency wiring;
database infrastructure;
event bus;
logger.

Но не:

вся бизнес-логика.

Business domain должен оставаться в модулях.


Пятнадцатый принцип: зависимости лучше передавать явно

Например Billing требует:

paymentProvider
repository
eventBus

Можно создать:

createBillingModule({
  repository,
  paymentProvider,
  eventBus
});

Теперь зависимость видна.

Плохой вариант — когда модуль сам импортирует:

global db;
global config;
global mailer;
global everything.

Тогда реальные зависимости скрыты.


Явные зависимости упрощают тестирование

В тесте можно передать:

fakePaymentProvider

и проверить Billing без реального внешнего API.

Не нужно менять глобальные singleton.


Шестнадцатый принцип: модульные границы должны отражать бизнес, а не URL

Например существуют маршруты:

/admin/projects
/client/projects
/api/projects

Это не три модуля.

Все они относятся к:

Projects.

Различается только presentation layer.


То же относится к frontend и backend

Модуль:

Projects

может иметь:

backend domain;
API;
admin UI;
client UI.

На уровне продукта это одна capability.

Архитектура должна отражать предметную область, а не случайное расположение экранов.


Как может выглядеть структура реального приложения

Например:

src/
│
├── app/
│   ├── bootstrap.js
│   ├── http.js
│   └── dependencies.js
│
├── modules/
│   │
│   ├── identity/
│   │   ├── domain/
│   │   ├── application/
│   │   ├── infrastructure/
│   │   └── index.js
│   │
│   ├── projects/
│   │   ├── domain/
│   │   ├── application/
│   │   ├── infrastructure/
│   │   └── index.js
│   │
│   ├── billing/
│   │   ├── domain/
│   │   ├── application/
│   │   ├── infrastructure/
│   │   └── index.js
│   │
│   ├── files/
│   └── notifications/
│
├── shared/
│   ├── errors/
│   ├── logging/
│   └── primitives/
│
└── workers/

Это только пример.

Главное не название папок.

Главное — правила между ними.


Потому что плохую архитектуру можно прекрасно разложить по папкам

Например:

modules/
├── projects/
├── billing/
└── files/

выглядит отлично.

Но внутри:

import {
  updatePayment
} from '../billing/internal/db.js';

и:

import {
  deleteProject
} from '../projects/internal/repository.js';

Границ фактически нет.

Получился папочный монолит.


Поэтому архитектуру нужно оценивать по зависимостям, а не по дереву файлов

Полезные вопросы:

Кто может изменить Payment?
Может ли Projects напрямую читать Billing repository?
Где находится правило перехода Project?
Кто публикует PROJECT_COMPLETED?
Какие модули зависят друг от друга?

Ответы говорят об архитектуре значительно больше, чем названия директорий.


Можно даже строить dependency graph

Например:

Identity
   ↑
Projects
   │
   ├────→ Files
   │
   └────→ Billing

Billing
   ↓ event
Notifications

Если со временем graph превращается в:

каждый → каждый

это сигнал потери модульности.


Семнадцатый принцип: database migrations тоже лучше организовать по модулям

Вместо одного каталога:

migrations/
001
002
003
...
488

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

Например:

projects migrations
billing migrations
identity migrations

Физический runner при этом остаётся один.


Это помогает понять влияние изменения

Migration:

add_refund_reason

явно принадлежит:

Billing.

Если она одновременно изменяет:

projects;
users;
files;
notifications;

это повод проверить, действительно ли изменение корректно спроектировано.


Восемнадцатый принцип: не делать гигантские модули только ради уменьшения их количества

Другой перекос:

BusinessModule

внутри которого:

users;
projects;
payments;
files;
messages.

Формально модулей мало.

По сути архитектура снова монолитная внутри одного модуля.


Хорошая граница должна отвечать на вопрос

Есть ли у этой части самостоятельный бизнес-смысл и собственные правила?

Например:

Billing

да.

Project Management

да.

StringUtils

нет — это технический utility.


Размер модулей может сильно различаться

Например:

Projects

15 000 строк.

Notifications

2 000.

Это нормально.

Модульность не означает:

Каждый кусок должен иметь одинаковый размер.

Граница определяется связностью поведения, а не количеством файлов.


Девятнадцатый принцип: не дробить слишком рано

Можно получить противоположную проблему:

ProjectTitleModule
ProjectStatusModule
ProjectMemberModule
ProjectDateModule

Теперь простой процесс требует четырёх внутренних API.

Это уже микросервисы в миниатюре, только внутри одного процесса.


Хороший модуль должен обладать внутренней cohesion

То есть связанные правила находятся вместе.

Например:

Project
Stage
Membership
ChangeRequest

могут естественно принадлежать одному Projects domain.

Если их разрезать слишком мелко, разработчик снова будет прыгать между модулями для одной бизнес-задачи.


Двадцатый принцип: публичный API модуля должен быть бизнесовым

Плохой:

createRow()
updateRow()
deleteRow()

Это фактически CRUD над базой.

Хороший:

createProject()
acceptOffer()
startProject()
completeProject()
cancelProject()

Теперь интерфейс отражает реальные действия продукта.


Это сильно улучшает читаемость кода

Сравните:

await projectRepository.update(
  id,
  { status: 'COMPLETED' }
);

и:

await projects.completeProject({
  projectId: id,
  actorId
});

Во втором случае сразу понятен бизнес-смысл.

И внутри метода можно централизованно выполнить:

permissions;
guards;
history;
outbox.

CRUD не исчезает полностью

Для некоторых сущностей:

справочник;
настройка;
категория.

обычный CRUD вполне достаточен.

Не нужно превращать каждое изменение строки в сложную domain command.

Уровень моделирования должен соответствовать сложности правил.


Модульный монолит не равен обязательному DDD

Можно использовать идеи:

bounded context;
aggregate;
domain event.

Но не обязательно строить:

EntityFactoryRepositorySpecificationManager

для таблицы настроек из трёх полей.

Архитектура должна уменьшать сложность.

Не демонстрировать терминологию.


Хороший модульный монолит может быть очень прагматичным

Например:

routes
service
repository
events

в каждом модуле уже могут дать достаточную изоляцию.

Если продукт сложнее — добавляются:

domain;
application;
infrastructure.

Главное — сохранять направление зависимостей.


Чем модульный монолит отличается от микросервисов

Самое заметное:

Deployment

Модульный монолит:

Release 4.2
→ всё приложение.

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

Projects 18
Billing 7
Files 11

обновляются независимо.


Сеть

Модульный:

function call.

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

HTTP/RPC/message broker.

Транзакции

Модульный монолит:

одна PostgreSQL transaction

может охватывать несколько локальных изменений.

Микросервисы чаще требуют:

eventual consistency;
saga;
compensation.

Локальная разработка

Модульный:

app
postgres
redis

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

gateway
identity
billing
projects
files
broker
databases...

Границы

И здесь есть интересный момент.

Физическая граница микросервисов сильнее:

нельзя случайно импортировать
private function
из другого процесса.

В монолите это технически очень легко.

Поэтому модульный монолит требует дисциплины и автоматических ограничений.


Именно это его главный риск

Сначала архитектура выглядит:

Projects
Billing
Files

Потом появляется срочная задача.

Разработчик говорит:

Сейчас быстренько прочитаю таблицу Billing из Projects.

Через месяц:

И здесь тоже.

Через год модульные границы существуют только в README.


Поэтому правила должны быть проверяемыми

Например:

lint/import rules;
architecture tests;
database permissions;
code review;
module public APIs.

Чем больше команда, тем важнее сделать правила техническими, а не устными.


Что можно проверять автоматически

Например:

Projects
не импортирует
Billing/internal.
Domain
не импортирует
HTTP layer.
Files
не делает прямой UPDATE
projects table.
shared
не зависит от modules.

Такие проверки защищают архитектуру постоянно.


Код-ревью тогда становится проще

Вместо обсуждения:

Мне кажется, этот импорт не очень красивый.

есть правило:

Модулю запрещена эта зависимость.

Архитектура становится частью engineering contract команды.


Как быть с frontend

Тот же принцип работает и там.

Например:

features/
├── projects/
├── billing/
├── files/
└── account/

Не обязательно создавать один гигантский:

components/

с тысячей компонентов.

Функциональные области могут владеть:

UI;
state;
API calls;
validation.

Но frontend boundaries не обязаны совпадать с backend один в один

Например экран:

Project Workspace

может показывать:

Project;
Messages;
Files;
Billing.

Это presentation composition.

Backend domain при этом остаётся разделённым.

Не нужно заставлять пользователя видеть архитектурные границы разработчиков.


API тоже может объединять данные

Например:

GET /projects/:id/workspace

возвращает агрегированный view.

Это не обязательно означает:

Projects владеет всеми этими данными.

Можно создать application/query layer, который формирует read model из нескольких модулей.


Модульность не означает «никогда не объединять данные»

Она означает:

объединять их осознанно, не разрушая ownership и бизнес-инварианты.


Когда модульный монолит особенно хорош

Например новый продукт:

CRM;
SaaS;
B2B-портал;
личный кабинет;
система управления проектами;
интернет-магазин.

Есть:

одна команда;
одна основная БД;
несколько тысяч или десятков тысяч пользователей;

и нет измеримой необходимости независимо разворачивать каждый domain.

Здесь модульный монолит часто даёт очень хороший баланс.


Он особенно удобен, когда предметная область ещё развивается

В первый год может выясниться:

Billing

на самом деле тесно связан с:

Subscriptions.

Или наоборот.

Внутри монолита границу можно изменить относительно дёшево.

Если уже существуют:

два сервиса;
две БД;
API;
events;
CI/CD,

реорганизация намного дороже.


Это позволяет ошибаться дёшево

А на ранней стадии продукта такая способность очень ценна.

Мы не обязаны знать идеальную финальную архитектуру.

Нужно лишь избегать решений, которые делают будущее изменение слишком дорогим.


Когда модульный монолит перестаёт быть достаточным

Сам по себе рост количества строк не является причиной.

Но могут появиться более сильные сигналы.

Например:

Billing

имеет:

собственную команду;
собственный release cadence;
отдельный workload;
понятные данные;
стабильный публичный contract.

Тогда модуль уже почти естественно готов стать сервисом.


Или тяжёлый processing начинает мешать основному приложению

Например:

Video processing

потребляет:

CPU 100%
RAM 8 GB.

Core API:

CPU 15%.

Масштабировать весь монолит ради обработки видео неэффективно.

Этот модуль — хороший кандидат на физическое выделение.


Правильно спроектированный модульный монолит облегчает extraction

До:

Application
├── Projects
├── Billing
└── Files

Billing уже имеет:

public API;
data ownership;
events;
tests.

Следующий этап:

Application
├── Projects
└── Files

Billing Service

Теперь вместо:

local Billing API

появляется:

remote Billing API.

Самая сложная часть обычно не перенос кода, а данные

Если Projects годами напрямую читал и изменял:

payments

выделить Billing трудно.

Если ownership соблюдался:

Billing
→ единственный writer

переезд становится значительно безопаснее.

Это ещё одна причина заниматься модульностью задолго до появления микросервисов.


Можно подготовить сервис, не создавая его

Это, пожалуй, один из самых полезных принципов.

Сегодня:

один process.

Но:

Billing

уже имеет:

контракт;
ownership;
domain events;
tests.

Если отдельный deployment никогда не понадобится — ничего страшного.

Мы всё равно получили хорошо организованный код.

Если понадобится — стоимость extraction ниже.


В этом преимущество перед «микросервисы заранее»

Мы сохраняем опцию, не оплачивая её полностью.

Сегодня не нужны:

service discovery;
network retries;
distributed tracing;
separate deployment;
separate DB;

Но архитектурная граница уже существует.


Что мы бы не делали

Не создавали бы:

30 модулей

в MVP из пяти экранов только ради красивой схемы.

Не вводили бы:

Kafka

для внутреннего события, которое можно обработать в одном процессе.

Не создавали бы:

database per module

без реальной причины.

Не строили бы:

repository + factory + specification

для каждой таблицы.

Не пытались бы заранее угадать все будущие сервисы.


А что делали бы

Начали бы с бизнес-карты продукта.

Например:

Identity
Customers
Projects
Billing
Files
Notifications
Editorial

Для каждого задали бы вопросы:

За какие правила отвечает модуль?
Какие данные ему принадлежат?
Что можно делать через его public API?
Какие события он создаёт?
От каких модулей зависит?
Кто имеет право изменять его таблицы?

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


Практический пример: завершение проекта

Плохой вариант:

project.status = 'COMPLETED';

await db.projects.save(project);

await db.notifications.insert(...);

await sendEmail(...);

await db.analytics.insert(...);

await db.billing.update(...);

Projects знает слишком много обо всём приложении.


Модульный вариант

Projects.completeProject()

внутри:

check permission
↓
check state transition
↓
Project → COMPLETED
↓
History
↓
PROJECT_COMPLETED

После commit:

PROJECT_COMPLETED
   │
   ├── Notifications
   ├── Analytics
   └── Billing

Каждый модуль решает сам, нужно ли ему это событие.


Добавили новую интеграцию

Например:

Telegram.

В плохой системе:

Projects.completeProject()

приходится редактировать снова.

В хорошей:

TelegramIntegration

подписывается на:

PROJECT_COMPLETED.

Projects вообще не меняется.


Это один из лучших тестов модульности

Спросите:

Когда появляется новая реакция на уже существующий бизнес-факт, приходится ли менять источник этого факта?

Если постоянно:

да,

границы, возможно, слишком связаны.


Другой пример: удалить пользователя

На первый взгляд:

DELETE users

Но пользователь может владеть:

projects;
files;
messages;
payments.

Теперь операция затрагивает много domains.

Модульный монолит заставляет явно решить:

Identity

что означает удаление аккаунта.

Например:

USER_DEACTIVATED

Другие модули реагируют:

Projects:
сохранить historical ownership

Files:
ограничить новые upload

Notifications:
остановить отправку

Мы перестаём воспринимать БД как набор случайных связанных строк.


Модульность делает сложные бизнес-операции видимыми

И это, возможно, её главная ценность.

Проблема большого проекта не в том, что файлов много.

А в том, что непонятно, где заканчивается одна ответственность и начинается другая.


Как понять, что модульная архитектура действительно работает

Есть несколько практических признаков.

Разработчик знает, куда идти с новой задачей.

Изменение Billing редко требует редактировать Projects.

Внутренние таблицы не используются как публичный API.

Domain rules находятся рядом с domain.

Циклических зависимостей мало или нет.

Можно протестировать модуль отдельно.

Public API модулей относительно небольшой.

Добавление нового потребителя события не требует переписывать источник.


А признаки деградации выглядят иначе

shared

растёт быстрее modules.

Почти каждый сервис импортирует:

db

напрямую.

Любой модуль может обновить любую таблицу.

Появились:

projectService2;
commonService;
globalHelper.

Для одной задачи нужно менять десять модулей.

Dependency graph превратился в паутину.

Это уже путь к Big Ball of Mud.


Самое важное — замечать это до того, как проект станет огромным

Разделить приложение из:

20 000 строк

намного проще, чем из:

500 000.

Модульность дешевле закладывать постепенно с самого начала.

Не обязательно идеально.

Но направление должно быть задано.


Модульный монолит не является временной архитектурой

Есть распространённое ощущение:

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

Нет.

Продукт может годами оставаться:

Modular Monolith
+
PostgreSQL
+
Workers
+
Redis
+
S3

и прекрасно решать бизнес-задачу.

Если архитектура:

понятна;
масштабируется;
поддерживается;

нет причины физически её дробить.


Микросервис — не повышение уровня

Переход:

module
→
service

не является архитектурным повышением ранга.

Это обмен.

Мы получаем:

independent deployment;
independent scaling;
fault isolation.

Но платим:

network;
retries;
distributed consistency;
monitoring;
DevOps complexity.

Переход оправдан только когда первая группа действительно нужна.


Поэтому модульный монолит можно считать архитектурой с отложенным решением

Сегодня мы точно знаем:

Projects

и:

Billing

имеют разные ответственности.

Но пока не знаем:

Нужны ли им разные процессы?

Мы фиксируем то, что знаем:

логическую границу.

И откладываем то, чего пока не знаем:

физическое разделение.

Это очень сильный архитектурный приём.


Как мы бы начинали новый продукт

Допустим нужно разработать CRM.

Мы сначала выделили бы основные capabilities:

Identity
Organizations
Customers
Projects
Communication
Files
Billing
Notifications

Затем определили бы ownership.

Например:

Customers
→ contacts/companies

Projects
→ project/stage/history

Billing
→ invoices/payments/refunds

Files
→ metadata/storage lifecycle

Затем определили бы public operations

Например:

Projects:
createProject()
acceptOffer()
startProject()
completeProject()
cancelProject()
Billing:
createInvoice()
confirmPayment()
refundPayment()
Files:
createUpload()
activateFile()
deleteFile()
createDownloadUrl()

Затем события

PROJECT_CREATED
PROJECT_COMPLETED
PAYMENT_CONFIRMED
FILE_UPLOADED
USER_DEACTIVATED

Не десятки событий на каждую строчку.

Только действительно важные факты.


После этого можно спокойно строить обычный production

Например:

Nginx
   ↓
Application × 2
   ↓
PostgreSQL
   ↓
Worker

Object Storage
Redis

Никакой обязательной Kubernetes-инфраструктуры.

Никаких двадцати сервисов.

Но codebase уже имеет структуру, способную расти.


Практический checklist модульного монолита

Перед тем как назвать приложение модульным, мы бы проверили:

  • верхнеуровневые модули отражают бизнес-возможности, а не только технические слои;
  • у каждого модуля есть понятная ответственность;
  • модуль имеет небольшой публичный API;
  • внутренние implementation-файлы не используются другими модулями напрямую;
  • у domain-данных есть явный владелец;
  • другой модуль не выполняет произвольный UPDATE чужих таблиц;
  • критичные изменения выполняются через бизнес-команды, а не generic CRUD;
  • state machine и бизнес-инварианты находятся внутри соответствующего модуля;
  • события используются для значимых бизнес-фактов, а не для каждого внутреннего вызова;
  • синхронные операции не превращены без необходимости в искусственную eventual consistency;
  • shared содержит инфраструктурные primitives, а не склад бизнес-логики;
  • нет бесконтрольных циклических зависимостей;
  • HTTP-контроллеры не являются местом основной business logic;
  • зависимости модулей видимы и по возможности проверяются автоматически;
  • тесты могут проверять отдельные module rules;
  • тяжёлые side effects вынесены из критичной транзакции;
  • отчёты и read models не разрушают ownership write-модели;
  • database migrations имеют понятного domain-владельца;
  • новая бизнес-функция имеет очевидное место в codebase;
  • существует понятный путь выделения модуля в отдельный сервис, если такая необходимость действительно появится.

Если большинство этих свойств выполняется, перед нами уже не просто «монолит, разложенный по папкам».

Это архитектура с реальными границами.


Самый простой тест

Представим, что завтра нужно полностью переписать внутреннее устройство Billing.

Например:

другие таблицы;
другой payment provider;
новая state machine.

Сколько других модулей придётся изменить?

Если ответ:

почти все,

Billing не является настоящим модулем.

Если:

при сохранении публичного контракта
большая часть приложения
вообще не заметит изменений,

граница работает.


Второй тест — удаление модуля

Допустим продукт полностью отказался от:

Notifications.

Можно ли относительно понятно удалить:

modules/notifications

и несколько subscriptions/configuration?

Или notification logic находится:

в Projects;
Billing;
Users;
Files;
Admin.

Если второе — модуль существует только номинально.


Третий тест — новый разработчик

Новый инженер получает задачу:

Изменить правила возврата оплаты.

Сможет ли он через несколько минут понять:

modules/billing

и область работы?

Или ему нужно провести глобальный поиск по:

refund
payment
money
invoice

по всему repository?

Хорошая архитектура значительно сокращает стоимость понимания системы.


И именно это влияет на бизнес

Архитектура иногда выглядит абстрактной технической темой.

Но через два-три года её качество выражается вполне конкретно.

Изменение:

одного процесса

занимает:

день

или:

две недели,

потому что каждое изменение затрагивает всё приложение.

Новый разработчик входит в проект:

за несколько дней

или:

месяцами боится менять код.

Release содержит:

локальное изменение

или:

непредсказуемый риск для пяти соседних функций.

Это и есть реальная стоимость архитектуры.


Вместо вывода

Главная проблема монолита не в том, что он:

один.

Главная проблема начинается тогда, когда внутри него становится невозможно ответить:

Какая часть системы за что отвечает?

Модульный монолит сохраняет сильные стороны простого приложения:

один deployment;
простую локальную разработку;
обычные вызовы функций;
единые транзакции;
понятную эксплуатацию.

Но добавляет то, чего часто не хватает растущему монолиту:

business boundaries;
ownership данных;
контролируемые зависимости;
публичные module API;
domain events;
локализованные правила.

В результате архитектура может выглядеть:

                Application
                    │
      ┌─────────────┼─────────────┐
      │             │             │
   Projects       Billing        Files
      │             │             │
      └─────────────┼─────────────┘
                    │
              PostgreSQL

Физически это всё ещё один продукт.

Но изменение Billing больше не требует понимать весь Projects.

Files не управляет статусами проекта.

Notifications не определяет, завершилась ли бизнес-операция.

А таблица payments перестаёт быть общей собственностью всего repository.

Именно эта граница отличает обычный растущий монолит от модульного монолита.

Поэтому при создании нового CRM, SaaS или B2B-сервиса мы бы не начинали с вопроса:

«Сколько сервисов нужно?»

Сначала намного полезнее спросить:

«Какие части бизнеса должны иметь собственные правила, данные и ответственность?»

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

А если когда-нибудь отдельному модулю действительно понадобятся свой deployment, масштабирование или команда, его можно будет вынести уже по существующей границе.

Не потому, что:

пришло время микросервисов.

А потому, что продукт сам дорос до момента, когда физическое разделение начало решать реальную проблему.

Есть похожая задача?

Опишите продукт, интеграции и ограничения. До разработки зафиксируем объём, риски и критерии приёмки.

Обсудить проект →