Для большинства новых продуктов разумный старт — модульный монолит: одно приложение и одно развёртывание, но модули с публичным API, собственными данными и автоматической проверкой границ. Микросервисы дают независимое развёртывание и автономию команд, но добавляют сетевые вызовы, итоговую согласованность данных и эксплуатационную сложность — Мартин Фаулер называет это «премией за микросервисы». Выделять сервис стоит тогда, когда граница модуля устоялась, а у его части системы появились отдельная команда или особые требования к масштабу, доступности или безопасности.
После материала вы сможете
- сравнить модульный монолит и микросервисы по команде, домену, развёртыванию, данным и наблюдаемости и обосновать выбор для своего продукта
- перечислить составляющие стоимости распределённости и обязательные условия до выделения первого сервиса
- оформить модули с публичным API, разнести их данные по схемам PostgreSQL и проверять границы правилом
dependency-cruiserв CI - спланировать постепенный переход по схеме strangler fig и выбрать модули-кандидаты в сервисы
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)TypeScript 5.9.3 (tsc --strict), tsc 7.0.2Vitest 5.0.3dependency-cruiser 18.6.0PostgreSQL 17.11 (Docker, postgres:17-alpine)
Сверено с документацией: 11 октября 2026 г.
Содержание
Три формы одной системы
Монолит — не ругательство. На microservices.io монолит определён как архитектура, в которой приложение — один развёртываемый компонент с одной базой данных, и все операции внутри него локальны. Фаулер отмечает, что в мире микросервисов слово «монолит» обычно означает только способ сборки — приложение как единое целое, — а не плохую структуру кода.
Плохую структуру называют «большим комом грязи» (Big Ball of Mud), и она возможна при любой форме. Фаулер пишет, что теоретически ничто не мешает монолиту иметь чёткие границы модулей, но на практике их слишком легко нарушить. Модульный монолит — это монолит, где границы не только нарисованы, но и защищены: у каждого модуля есть публичный API, свои данные и проверка в CI, которая не даёт обойти API.
Единица развёртывания
- Монолит без границ
- Одна
- Модульный монолит
- Одна
- Микросервисы
- Много, каждая — со своим конвейером
Вызов другой части системы
- Монолит без границ
- Любая функция, любой импорт
- Модульный монолит
- Вызов функции через публичный API модуля
- Микросервисы
- Сетевой запрос или сообщение
Данные
- Монолит без границ
- Общие таблицы, кто угодно читает что угодно
- Модульный монолит
- Одна база, у модуля свои таблицы или схема
- Микросервисы
- Данные сервиса доступны только через его API
Транзакция через несколько частей
- Монолит без границ
- Обычная ACID-транзакция
- Модульный монолит
- Возможна, но связывает модули
- Микросервисы
- Только итоговая согласованность: саги, события
Чем защищены границы
- Монолит без границ
- Ничем
- Модульный монолит
- Линтер зависимостей, права в базе
- Микросервисы
- Сеть и отдельные репозитории
Критерии: команда, домен, развёртывание, данные, наблюдаемость
Команда
- Модульный монолит
- Одна или несколько команд в одной кодовой базе, нужна координация релизов
- Микросервисы
- Небольшие независимые команды, каждая владеет своими сервисами
- Вопрос себе
- Сколько команд реально мешают друг другу при релизе?
Домен
- Модульный монолит
- Границы можно двигать рефакторингом за день
- Микросервисы
- Перенос ответственности между сервисами — согласованная миграция API и данных
- Вопрос себе
- Насколько устоялись границы предметной области?
Развёртывание
- Модульный монолит
- Один артефакт, один конвейер; можно запустить несколько копий за балансировщиком
- Микросервисы
- Независимые релизы, но нужны автоматизация, версионирование API, совместимость
- Вопрос себе
- Есть ли автоматический деплой и откат для каждого компонента?
Данные
- Модульный монолит
- ACID-транзакции между модулями, JOIN в отчётах
- Микросервисы
- База на сервис: межсервисные операции — саги, запросы — API-композиция или отдельные модели чтения
- Вопрос себе
- Есть ли операции, которые должны атомарно менять данные разных частей?
Наблюдаемость
- Модульный монолит
- Один лог, один стек вызовов, отладка в одном процессе
- Микросервисы
- Распределённая трассировка, корреляция логов, метрики на каждый сервис
- Вопрос себе
- Сможете ли вы найти, какой из сервисов замедлил запрос?
Масштаб и особые требования
- Модульный монолит
- Масштабируется всё приложение целиком
- Микросервисы
- Часть с особой нагрузкой, доступностью или безопасностью выносится отдельно
- Вопрос себе
- Есть ли часть, которой нужны другие ресурсы или другой уровень защиты?
Последний критерий microservices.io формулирует как «разделять по характеристикам»: требованиям к ресурсам, доступности и безопасности. Это один из немногих технических доводов за отдельный сервис; большинство остальных — организационные.
Главный организационный довод — закон Конвея: организация, проектирующая систему, получает дизайн, копирующий её структуру коммуникаций. Фаулер пересказывает его так: если компилятор пишет одна команда, он будет однопроходным, если две — двухпроходным. Микросервисы позволяют каждой команде вести относительно независимую часть системы с редким и формальным общением между командами. Если команда одна, этот довод не работает: границы между сервисами одной команды — лишь дополнительные сетевые вызовы.
Стоимость распределённости
Фаулер в заметке MicroservicePremium (2015) называет цену микросервисов премией: сложность, которую сам стиль добавляет к проекту, — автоматизированное развёртывание, мониторинг, обработка отказов, итоговая согласованность. Его вывод: не рассматривать микросервисы, пока система не стала слишком сложной для монолита, а большинство систем строить как один монолит, уделяя внимание модульности внутри него.
Удалённые вызовы медленные
Вызов функции в процессе почти никогда не бывает узким местом, а сеть — бывает. Если сервис вызывает шесть других, а каждый из них — ещё шесть, задержки складываются. Смягчают более крупными вызовами и асинхронностью, но асинхронный код сложнее писать и отлаживать.
Удалённые вызовы падают
Вызов функции в процессе ожидаемо выполняется, сетевой запрос может не пройти в любой момент. Для каждого вызова нужно решить, что делать при отказе: тайм-аут, повтор, деградация, компенсация.
Итоговая согласованность
У каждого сервиса свои данные, поэтому изменение, затрагивающее несколько сервисов, становится видимым не сразу. Пользователь обновил данные, обновил страницу — а изменения ещё нет.
Эксплуатационная сложность
Несколько приложений превращаются в десятки сервисов со своими релизами. Нужны быстрое выделение инфраструктуры, мониторинг и автоматический деплой — Фаулер называет их обязательными условиями до перехода.
- Новое окружение поднимается за часы, а не за недели
- Мониторинг ловит и технические сбои (ошибки, доступность), и бизнес-сбои (например, падение числа заказов)
- Каждый компонент деплоится и откатывается автоматически
- Логи разных компонентов связываются одним идентификатором запроса
- Разработчики участвуют в эксплуатации своих сервисов
Чек-лист выше — пересказ того, что Фаулер в заметке MicroservicePrerequisites называет минимумом до первого сервиса, плюс корреляция логов, без которой распределённую систему трудно отлаживать. Если половина пунктов не выполнена, премия будет выше выгоды. О наблюдаемости подробнее — в уроке о мониторинге и логировании.
Данные: граница, которую труднее всего провести
Паттерн «база на сервис» (Database per service) на microservices.io требует, чтобы данные сервиса были приватными и доступными только через его API, а транзакции сервиса затрагивали только его базу. Отдельный сервер на каждый сервис для этого не нужен: варианты — приватные таблицы, отдельная схема на сервис или отдельный сервер, и первые два самые дешёвые. Автор паттерна советует ставить барьеры, например отдельного пользователя базы на сервис с правами только на свои объекты, — без барьера разработчики будут обходить API.
Тот же приём работает в модульном монолите: одна база PostgreSQL, схема на модуль и роль на модуль. Внешних ключей между модулями нет — запись хранит идентификатор услуги, но проверяет его через API каталога. Это сознательная потеря ссылочной целостности ради независимости модулей; в монолите её можно оставить, но тогда выделить модуль в сервис будет труднее.
-- One database, one schema per module
CREATE SCHEMA catalog;
CREATE SCHEMA booking;
CREATE TABLE catalog.services (
id text PRIMARY KEY,
title text NOT NULL,
duration_min integer NOT NULL CHECK (duration_min > 0),
price_kzt integer NOT NULL,
archived boolean NOT NULL DEFAULT false
);
-- booking stores the service id, but catalog owns the row:
-- no cross-module foreign key, the id is checked through the catalog API
CREATE TABLE booking.bookings (
id uuid PRIMARY KEY,
service_id text NOT NULL,
master_id text NOT NULL,
client_phone text NOT NULL,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL
);
-- each module's code connects with its own role and sees only its schema
CREATE ROLE booking_module NOLOGIN;
GRANT USAGE ON SCHEMA booking TO booking_module;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA booking TO booking_module;SET ROLE booking_module;
INSERT INTO booking.bookings VALUES
(gen_random_uuid(), 'haircut', 'm1', '+77011234567', '2026-10-12 10:00+00', '2026-10-12 11:00+00');
SELECT service_id, master_id FROM booking.bookings;
SELECT title FROM catalog.services;$ docker exec -i kb-pg-modulnyj-monolit psql -U postgres < check.sql
SET
INSERT 0 1
service_id | master_id
------------+-----------
haircut | m1
(1 row)
ERROR: permission denied for schema catalog
LINE 1: SELECT title FROM catalog.services;
^Цена разделения данных проявляется в операциях, которые затрагивают несколько модулей. Пример с microservices.io: оформление заказа должно проверить кредитный лимит клиента, а данные клиента и заказов принадлежат разным сервисам. В монолите это одна транзакция; между сервисами — сага с компенсирующими действиями. Запрос «клиенты региона и их последние заказы» в монолите — один JOIN, между сервисами — API-композиция или отдельная модель чтения (CQRS). Чем больше таких операций, тем дороже разделение.
Модульный монолит в коде: публичный API и запрет обходных путей
Небольшой проверенный пример — сервис записи к мастеру из трёх модулей: каталог услуг, запись и уведомления. Каждый модуль — папка в src/modules, его публичный API — файл index.ts, всё остальное лежит в internal и снаружи недоступно.
src/modules/catalog/index.ts
- Что внутри
CatalogApi,BookableService,createCatalogModule- Кто может импортировать
- Любой модуль
src/modules/catalog/internal/services-store.ts
- Что внутри
- Хранилище услуг с ценой и флагом архива
- Кто может импортировать
- Только каталог
src/modules/booking/index.ts
- Что внутри
book,BookingError, событиеBookingCreated- Кто может импортировать
- Любой модуль
src/modules/booking/internal/schedule.ts
- Что внутри
- Пересечение интервалов, хранилище записей
- Кто может импортировать
- Только модуль записи
src/modules/notifications/index.ts
- Что внутри
- Подписка на
booking.created - Кто может импортировать
- Точка сборки
src/shared/events.ts
- Что внутри
- Шина событий внутри процесса
- Кто может импортировать
- Все, но сама не знает модулей
Каталог отдаёт наружу только то, что нужно для записи: название и длительность. Цена и флаг архива остаются внутри — их можно менять, не трогая другие модули.
import { type ServiceRow, ServicesStore } from "./internal/services-store.js";
// Public API of the catalog module: the only file other modules may import
export type BookableService = { id: string; title: string; durationMin: number };
export interface CatalogApi {
findBookableService(id: string): Promise<BookableService | null>;
}
export function createCatalogModule(seed: ServiceRow[] = []) {
const store = new ServicesStore();
seed.forEach((row) => store.add(row));
const api: CatalogApi = {
async findBookableService(id) {
const row = store.find(id);
if (!row || row.archived) return null;
// price and archive flag stay inside the module
return { id: row.id, title: row.title, durationMin: row.durationMin };
},
};
return { api, admin: { addService: (row: ServiceRow) => store.add(row) } };
}Модуль записи зависит от интерфейса CatalogApi, а не от реализации каталога, и сообщает о новой записи событием. В одном процессе событие доставляет простая шина; если модуль уведомлений станет сервисом, на её место встанет брокер сообщений, а надёжную публикацию обеспечит паттерн transactional outbox — он разобран в уроке о событиях и очередях.
import { randomUUID } from "node:crypto";
import type { InProcessEvents } from "../../shared/events.js";
import type { CatalogApi } from "../catalog/index.js";
import { BookingsStore, overlaps } from "./internal/schedule.js";
// Public API of the booking module: its input, its errors and its events
export type BookInput = { serviceId: string; masterId: string; clientPhone: string; startsAt: Date };
export type BookingCreated = {
type: "booking.created";
bookingId: string;
masterId: string;
clientPhone: string;
serviceTitle: string;
startsAt: string;
};
export class BookingError extends Error {
constructor(readonly code: "UNKNOWN_SERVICE" | "SLOT_TAKEN") {
super(code);
}
}
export function createBookingModule(deps: {
catalog: CatalogApi;
events: InProcessEvents<BookingCreated>;
}) {
const store = new BookingsStore();
return {
async book(input: BookInput): Promise<{ bookingId: string; endsAt: Date }> {
// Another module is reached only through its public API
const service = await deps.catalog.findBookableService(input.serviceId);
if (!service) throw new BookingError("UNKNOWN_SERVICE");
const endsAt = new Date(input.startsAt.getTime() + service.durationMin * 60_000);
const slot = { startsAt: input.startsAt, endsAt };
if (store.forMaster(input.masterId).some((b) => overlaps(b, slot))) {
throw new BookingError("SLOT_TAKEN");
}
const booking = { id: randomUUID(), ...input, endsAt };
store.add(booking);
await deps.events.publish({
type: "booking.created",
bookingId: booking.id,
masterId: booking.masterId,
clientPhone: booking.clientPhone,
serviceTitle: service.title,
startsAt: booking.startsAt.toISOString(),
});
return { bookingId: booking.id, endsAt };
},
};
}// In-process event bus: modules talk without importing each other's internals.
// If a module becomes a service, a message broker takes this place.
type Handler<E> = (event: E) => void | Promise<void>;
export class InProcessEvents<E extends { type: string }> {
private readonly handlers = new Map<string, Handler<E>[]>();
subscribe<T extends E["type"]>(type: T, handler: Handler<Extract<E, { type: T }>>): void {
const list = this.handlers.get(type) ?? [];
list.push(handler as Handler<E>);
this.handlers.set(type, list);
}
async publish(event: E): Promise<void> {
for (const handler of this.handlers.get(event.type) ?? []) {
await handler(event);
}
}
}typeimport type { InProcessEvents } from "../../shared/events.js";
import type { BookingCreated } from "../booking/index.js";
// Notifications know the booking event contract, not the booking tables
export function createNotificationsModule(deps: { events: InProcessEvents<BookingCreated> }) {
const outbox: string[] = [];
deps.events.subscribe("booking.created", (event) => {
outbox.push(`${event.clientPhone}: ${event.serviceTitle}, ${event.startsAt}`);
});
return { sent: () => [...outbox] };
}import { createBookingModule, type BookingCreated } from "./modules/booking/index.js";
import { createCatalogModule } from "./modules/catalog/index.js";
import { createNotificationsModule } from "./modules/notifications/index.js";
import { InProcessEvents } from "./shared/events.js";
// One process, one deployment: modules are wired together here
export function createApp() {
const events = new InProcessEvents<BookingCreated>();
const catalog = createCatalogModule([
{ id: "haircut", title: "Haircut", durationMin: 60, priceKzt: 8000, archived: false },
{ id: "beard", title: "Beard trim", durationMin: 30, priceKzt: 4000, archived: true },
]);
const booking = createBookingModule({ catalog: catalog.api, events });
const notifications = createNotificationsModule({ events });
return { catalog, booking, notifications };
}import { describe, expect, it } from "vitest";
import { createApp } from "../src/app.js";
import { BookingError } from "../src/modules/booking/index.js";
const at = (iso: string) => new Date(iso);
describe("modular monolith: booking through public APIs", () => {
it("books a slot and notifies through an in-process event", async () => {
const app = createApp();
const { endsAt } = await app.booking.book({
serviceId: "haircut",
masterId: "m1",
clientPhone: "+77011234567",
startsAt: at("2026-10-12T10:00:00Z"),
});
expect(endsAt).toEqual(at("2026-10-12T11:00:00Z"));
expect(app.notifications.sent()).toEqual([
"+77011234567: Haircut, 2026-10-12T10:00:00.000Z",
]);
});
it("rejects an overlapping slot for the same master", async () => {
const app = createApp();
const base = { serviceId: "haircut", masterId: "m1", clientPhone: "+77011234567" };
await app.booking.book({ ...base, startsAt: at("2026-10-12T10:00:00Z") });
await expect(
app.booking.book({ ...base, startsAt: at("2026-10-12T10:30:00Z") }),
).rejects.toEqual(new BookingError("SLOT_TAKEN"));
// back-to-back is fine: intervals are half-open
await app.booking.book({ ...base, startsAt: at("2026-10-12T11:00:00Z") });
expect(app.notifications.sent()).toHaveLength(2);
});
it("does not book archived or unknown services", async () => {
const app = createApp();
const input = { masterId: "m1", clientPhone: "+77011234567", startsAt: at("2026-10-12T10:00:00Z") };
await expect(app.booking.book({ ...input, serviceId: "beard" })).rejects.toMatchObject({
code: "UNKNOWN_SERVICE",
});
await expect(app.booking.book({ ...input, serviceId: "nope" })).rejects.toBeInstanceOf(BookingError);
});
});Компилятор не запрещает импорт ../catalog/internal/… из модуля записи. Это делает dependency-cruiser: правило с группой $1 разрешает модулю импортировать свои файлы и чужие index.ts, но не чужие внутренности, а no-circular требует, чтобы зависимости между модулями образовывали граф без циклов.
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: "modules-only-via-public-api",
comment: "Another module is reachable only through its index.ts",
severity: "error",
from: { path: "^src/modules/([^/]+)/" },
to: {
path: "^src/modules/",
pathNot: ["^src/modules/$1/", "^src/modules/[^/]+/index\\.ts$"],
},
},
{
name: "shared-knows-no-modules",
comment: "shared is a toolbox, not a back door between modules",
severity: "error",
from: { path: "^src/shared/" },
to: { path: "^src/modules/" },
},
{
name: "no-circular",
comment: "Module dependencies must form a directed acyclic graph",
severity: "error",
from: {},
to: { circular: true },
},
{
name: "not-to-unresolvable",
severity: "error",
from: {},
to: { couldNotResolve: true },
},
],
options: {
doNotFollow: { path: "node_modules" },
tsPreCompilationDeps: true,
tsConfig: { fileName: "tsconfig.json" },
},
};shared не знает модулей, без циклов$ npx tsc --noEmit && npx vitest run --reporter=verbose
✓ test/app.test.ts > modular monolith: booking through public APIs > books a slot and notifies through an in-process event 3ms
✓ test/app.test.ts > modular monolith: booking through public APIs > rejects an overlapping slot for the same master 1ms
✓ test/app.test.ts > modular monolith: booking through public APIs > does not book archived or unknown services 1ms
Test Files 1 passed (1)
Tests 3 passed (3)
$ npx depcruise src --config .dependency-cruiser.cjs
✔ no dependency violations found (8 modules, 11 dependencies cruised)Теперь два типичных нарушения: модуль записи берёт тип цены из внутренностей каталога, а каталог ради статистики популярности начинает импортировать модуль записи.
--- a/src/modules/booking/index.ts
+++ b/src/modules/booking/index.ts
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import type { InProcessEvents } from "../../shared/events.js";
import type { CatalogApi } from "../catalog/index.js";
+import type { ServiceRow } from "../catalog/internal/services-store.js";
import { BookingsStore, overlaps } from "./internal/schedule.js";
// Public API of the booking module: its input, its errors and its events
@@ -12,6 +13,7 @@ export type BookingCreated = {
clientPhone: string;
serviceTitle: string;
startsAt: string;
+ priceKzt?: ServiceRow["priceKzt"];
};
export class BookingError extends Error {
--- a/src/modules/catalog/index.ts
+++ b/src/modules/catalog/index.ts
@@ -1,5 +1,9 @@
+import type { BookingCreated } from "../booking/index.js";
import { type ServiceRow, ServicesStore } from "./internal/services-store.js";
+// catalog wants to count popular services from booking events
+export type PopularityInput = Pick<BookingCreated, "serviceTitle">;
+
// Public API of the catalog module: the only file other modules may import
export type BookableService = { id: string; title: string; durationMin: number };$ npx tsc --noEmit && npx vitest run
Tests 3 passed (3)
$ npx depcruise src --config .dependency-cruiser.cjs
error no-circular: src/modules/booking/index.ts →
src/modules/catalog/index.ts →
src/modules/booking/index.ts
error modules-only-via-public-api: src/modules/booking/index.ts → src/modules/catalog/internal/services-store.ts
x 2 dependency violations (2 errors, 0 warnings). 8 modules, 13 dependencies cruised.Правильные исправления: если записи нужна цена, каталог добавляет её в публичный BookableService; если каталогу нужна статистика, он подписывается на событие booking.created в точке сборки, а не импортирует модуль записи. В Java ту же проверку даёт Spring Modulith: вызов ApplicationModules.of(Application.class).verify() отклоняет циклы между модулями и обращения к их внутренним пакетам.
Путь эволюции: сначала монолит, потом strangler fig
В заметке MonolithFirst (2015) Фаулер пишет, что почти все успешные истории микросервисов начинались с монолита, который стал слишком большим и был разделён, а почти все системы, сразу построенные как микросервисы, попадали в серьёзные проблемы. Два довода: на старте неизвестно, нужен ли продукт пользователям, и скорость важнее масштабируемости; а правильные границы между сервисами трудно угадать даже опытным архитекторам в знакомой предметной области — в монолите их дешевле найти и сдвинуть. Фаулер приводит и контраргумент: построить достаточно модульный монолит требует дисциплины, а раннее разделение приучает команды работать независимо.
Когда выделение назрело, Фаулер советует не переписывать систему целиком, а заменять её постепенно — подход Strangler Fig. Название — от фигового дерева-душителя, которое прорастает на дереве-хозяине и со временем заменяет его. Новые функции и выносимые части строятся рядом со старой системой, поведение переносится по кусочку, а временный код, который позволяет старому и новому сосуществовать (transitional architecture), удаляется в конце. Фаулер выделяет четыре направления работы: понять желаемые результаты, решить, как разбить задачу на части, успешно поставлять части и менять организацию, чтобы это происходило постоянно.
Модульный монолит делает этот путь короче: публичный API модуля уже стал контрактом, данные уже разнесены по схемам, общение уже идёт событиями. Выделение превращается в замену вызова функции сетевым клиентом и шины в процессе — брокером, а не в раскопки общего кода. Фаулер упоминает и промежуточный вариант — начать с нескольких крупных сервисов и дробить их, когда границы устоятся.
Каталог услуг
- Владеет данными
- Услуги, цены, длительность
- Кандидат в сервис?
- Нет: мало правил, читается всеми, меняется вместе с записью
Запись
- Владеет данными
- Записи, расписание мастеров
- Кандидат в сервис?
- Нет, пока одна команда: это ядро, ему нужны транзакции
Уведомления
- Владеет данными
- Шаблоны, журнал отправок
- Кандидат в сервис?
- Первый кандидат: асинхронный, внешние провайдеры, свой профиль нагрузки
Оплата
- Владеет данными
- Платежи, возвраты
- Кандидат в сервис?
- Возможно: другие требования к безопасности и аудиту
Отчёты
- Владеет данными
- Модель чтения
- Кандидат в сервис?
- Возможно: тяжёлые запросы не должны мешать записи
Таблица — учебная карта модулей, а не универсальный ответ: тот же модуль уведомлений может годами жить в монолите, если отправок мало, а команда одна.
Таблица выбора
Новый продукт, гипотеза не проверена
- Что выбрать
- Модульный монолит
- Почему
- Скорость изменений важнее масштаба; границы ещё сдвинутся
Одна команда до 8–10 человек
- Что выбрать
- Модульный монолит
- Почему
- Довод Конвея не работает, премия за распределённость не окупается
Несколько команд мешают друг другу при релизах
- Что выбрать
- Модульный монолит с жёсткими границами, затем выделение по одному сервису
- Почему
- Сначала проверить, что мешает именно развёртывание, а не связность кода
Часть системы требует другой нагрузки, доступности или защиты
- Что выбрать
- Выделить эту часть в сервис
- Почему
- Разделение по характеристикам — технический довод за сервис
Легаси-монолит без границ, нужен переезд
- Что выбрать
- Strangler fig: новые функции рядом, перенос по частям
- Почему
- Полная замена долго не приносит пользы и редко успевает за бизнесом
Нет автодеплоя, мониторинга и трассировки
- Что выбрать
- Не начинать микросервисы
- Почему
- Не выполнены обязательные условия, премия будет выше выгоды
Порог «8–10 человек» — наш ориентир по опыту, а не правило из источников: важнее не число людей, а то, сколько команд независимо выпускают изменения в одну кодовую базу.
Дальше: урок о чистой архитектуре показывает, как устроить слои внутри одного модуля, урок о событиях и очередях — как надёжно доставлять события между модулями и сервисами, а разбор проектирования системы — как применить эти решения к конкретной задаче целиком.
Как проверено4 · пройденосверено 11 октября 2026 г.
- запущено, работает
src/modules/{catalog,booking,notifications}/index.ts, internal/*, src/shared/events.ts, src/app.ts
временный проект scratchpad/kb/verify/modulnyj-monolit-ili-mikroservisy: tsc --noEmit (strict, nodenext) на TypeScript 5.9.3 и 7.0.2; Node.js 24.18
- запущено, работает
test/app.test.ts
vitest 5.0.3: 3/3 passed; вывод в статье реальный (сокращён)
- запущено, работает
.dependency-cruiser.cjs + diff с нарушениями
dependency-cruiser 18.6.0: чистый код — 0 нарушений (8 modules, 11 dependencies); с патчем (import type из catalog/internal, импорт booking из catalog) — modules-only-via-public-api и no-circular при зелёных tsc и тестах; diff — git diff --no-index реальных файлов
- запущено, работает
schema.sql + check.sql
PostgreSQL 17.11 (docker postgres:17-alpine, kb-pg-modulnyj-monolit, остановлен): psql ON_ERROR_STOP — схема создана; SET ROLE booking_module: INSERT/SELECT в booking OK, SELECT из catalog — permission denied for schema catalog
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.