Как делать11 октября 2026 г.10 мин чтения
Архитектура ПОШаг 6 из 9

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

Коротко

Для большинства новых продуктов разумный старт — модульный монолит: одно приложение и одно развёртывание, но модули с публичным 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 г.

Содержание
  1. 01Три формы одной системы
  2. 02Критерии: команда, домен, развёртывание, данные, наблюдаемость
  3. 03Стоимость распределённости
  4. 04Данные: граница, которую труднее всего провести
  5. 05Модульный монолит в коде: публичный API и запрет обходных путей
  6. 06Путь эволюции: сначала монолит, потом strangler fig
  7. 07Таблица выбора

Три формы одной системы

Монолит — не ругательство. На microservices.io монолит определён как архитектура, в которой приложение — один развёртываемый компонент с одной базой данных, и все операции внутри него локальны. Фаулер отмечает, что в мире микросервисов слово «монолит» обычно означает только способ сборки — приложение как единое целое, — а не плохую структуру кода.

Плохую структуру называют «большим комом грязи» (Big Ball of Mud), и она возможна при любой форме. Фаулер пишет, что теоретически ничто не мешает монолиту иметь чёткие границы модулей, но на практике их слишком легко нарушить. Модульный монолит — это монолит, где границы не только нарисованы, но и защищены: у каждого модуля есть публичный API, свои данные и проверка в CI, которая не даёт обойти API.

Единица развёртывания

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

Вызов другой части системы

Монолит без границ
Любая функция, любой импорт
Модульный монолит
Вызов функции через публичный API модуля
Микросервисы
Сетевой запрос или сообщение

Данные

Монолит без границ
Общие таблицы, кто угодно читает что угодно
Модульный монолит
Одна база, у модуля свои таблицы или схема
Микросервисы
Данные сервиса доступны только через его API

Транзакция через несколько частей

Монолит без границ
Обычная ACID-транзакция
Модульный монолит
Возможна, но связывает модули
Микросервисы
Только итоговая согласованность: саги, события

Чем защищены границы

Монолит без границ
Ничем
Модульный монолит
Линтер зависимостей, права в базе
Микросервисы
Сеть и отдельные репозитории

Критерии: команда, домен, развёртывание, данные, наблюдаемость

Команда

Модульный монолит
Одна или несколько команд в одной кодовой базе, нужна координация релизов
Микросервисы
Небольшие независимые команды, каждая владеет своими сервисами
Вопрос себе
Сколько команд реально мешают друг другу при релизе?

Домен

Модульный монолит
Границы можно двигать рефакторингом за день
Микросервисы
Перенос ответственности между сервисами — согласованная миграция API и данных
Вопрос себе
Насколько устоялись границы предметной области?

Развёртывание

Модульный монолит
Один артефакт, один конвейер; можно запустить несколько копий за балансировщиком
Микросервисы
Независимые релизы, но нужны автоматизация, версионирование API, совместимость
Вопрос себе
Есть ли автоматический деплой и откат для каждого компонента?

Данные

Модульный монолит
ACID-транзакции между модулями, JOIN в отчётах
Микросервисы
База на сервис: межсервисные операции — саги, запросы — API-композиция или отдельные модели чтения
Вопрос себе
Есть ли операции, которые должны атомарно менять данные разных частей?

Наблюдаемость

Модульный монолит
Один лог, один стек вызовов, отладка в одном процессе
Микросервисы
Распределённая трассировка, корреляция логов, метрики на каждый сервис
Вопрос себе
Сможете ли вы найти, какой из сервисов замедлил запрос?

Масштаб и особые требования

Модульный монолит
Масштабируется всё приложение целиком
Микросервисы
Часть с особой нагрузкой, доступностью или безопасностью выносится отдельно
Вопрос себе
Есть ли часть, которой нужны другие ресурсы или другой уровень защиты?

Последний критерий microservices.io формулирует как «разделять по характеристикам»: требованиям к ресурсам, доступности и безопасности. Это один из немногих технических доводов за отдельный сервис; большинство остальных — организационные.

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

Стоимость распределённости

Фаулер в заметке MicroservicePremium (2015) называет цену микросервисов премией: сложность, которую сам стиль добавляет к проекту, — автоматизированное развёртывание, мониторинг, обработка отказов, итоговая согласованность. Его вывод: не рассматривать микросервисы, пока система не стала слишком сложной для монолита, а большинство систем строить как один монолит, уделяя внимание модульности внутри него.

  1. Удалённые вызовы медленные

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

  2. Удалённые вызовы падают

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

  3. Итоговая согласованность

    У каждого сервиса свои данные, поэтому изменение, затрагивающее несколько сервисов, становится видимым не сразу. Пользователь обновил данные, обновил страницу — а изменения ещё нет.

  4. Эксплуатационная сложность

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

  • Новое окружение поднимается за часы, а не за недели
  • Мониторинг ловит и технические сбои (ошибки, доступность), и бизнес-сбои (например, падение числа заказов)
  • Каждый компонент деплоится и откатывается автоматически
  • Логи разных компонентов связываются одним идентификатором запроса
  • Разработчики участвуют в эксплуатации своих сервисов

Чек-лист выше — пересказ того, что Фаулер в заметке MicroservicePrerequisites называет минимумом до первого сервиса, плюс корреляция логов, без которой распределённую систему трудно отлаживать. Если половина пунктов не выполнена, премия будет выше выгоды. О наблюдаемости подробнее — в уроке о мониторинге и логировании.

Данные: граница, которую труднее всего провести

Паттерн «база на сервис» (Database per service) на microservices.io требует, чтобы данные сервиса были приватными и доступными только через его API, а транзакции сервиса затрагивали только его базу. Отдельный сервер на каждый сервис для этого не нужен: варианты — приватные таблицы, отдельная схема на сервис или отдельный сервер, и первые два самые дешёвые. Автор паттерна советует ставить барьеры, например отдельного пользователя базы на сервис с правами только на свои объекты, — без барьера разработчики будут обходить API.

Тот же приём работает в модульном монолите: одна база PostgreSQL, схема на модуль и роль на модуль. Внешних ключей между модулями нет — запись хранит идентификатор услуги, но проверяет его через API каталога. Это сознательная потеря ссылочной целостности ради независимости модулей; в монолите её можно оставить, но тогда выделить модуль в сервис будет труднее.

schema.sql
-- 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;
Схема на модуль и роль модуля с доступом только к своей схеме
check.sql
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;
Проверка: роль модуля записи пишет в свою схему и пытается прочитать каталог
terminal
$ 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;
                          ^
Реальный вывод на PostgreSQL 17: своя схема доступна, чужая — нет

Цена разделения данных проявляется в операциях, которые затрагивают несколько модулей. Пример с 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

Что внутри
Шина событий внутри процесса
Кто может импортировать
Все, но сама не знает модулей

Каталог отдаёт наружу только то, что нужно для записи: название и длительность. Цена и флаг архива остаются внутри — их можно менять, не трогая другие модули.

src/modules/catalog/index.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) } };
}
Публичный API каталога: узкий интерфейс вместо доступа к хранилищу

Модуль записи зависит от интерфейса CatalogApi, а не от реализации каталога, и сообщает о новой записи событием. В одном процессе событие доставляет простая шина; если модуль уведомлений станет сервисом, на её место встанет брокер сообщений, а надёжную публикацию обеспечит паттерн transactional outbox — он разобран в уроке о событиях и очередях.

src/modules/booking/index.ts
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 };
    },
  };
}
Модуль записи: правило «мастер не может быть занят дважды», вызов каталога через API и событие
src/shared/events.ts
// 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);
    }
  }
}
Шина событий внутри процесса: типизированная подписка по полю type
src/modules/notifications/index.ts
import 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] };
}
Модуль уведомлений знает контракт события, но не таблицы модуля записи
src/app.ts
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 };
}
Точка сборки: один процесс, одно развёртывание, модули связываются здесь
test/app.test.ts
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);
  });
});
Тесты идут только через публичные API модулей

Компилятор не запрещает импорт ../catalog/internal/… из модуля записи. Это делает dependency-cruiser: правило с группой $1 разрешает модулю импортировать свои файлы и чужие index.ts, но не чужие внутренности, а no-circular требует, чтобы зависимости между модулями образовывали граф без циклов.

.dependency-cruiser.cjs
/** @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" },
  },
};
Границы модулей как правила: только публичный API, shared не знает модулей, без циклов
terminal
$ 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)
Реальный вывод (сокращён): типы, тесты и границы в порядке

Теперь два типичных нарушения: модуль записи берёт тип цены из внутренностей каталога, а каталог ради статистики популярности начинает импортировать модуль записи.

diff
--- 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 };
Нарушения, которые пропускают компилятор и тесты
terminal
$ 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.
Реальный вывод (вывод vitest сокращён): тесты зелёные, проверка границ — нет

Правильные исправления: если записи нужна цена, каталог добавляет её в публичный BookableService; если каталогу нужна статистика, он подписывается на событие booking.created в точке сборки, а не импортирует модуль записи. В Java ту же проверку даёт Spring Modulith: вызов ApplicationModules.of(Application.class).verify() отклоняет циклы между модулями и обращения к их внутренним пакетам.

Путь эволюции: сначала монолит, потом strangler fig

В заметке MonolithFirst (2015) Фаулер пишет, что почти все успешные истории микросервисов начинались с монолита, который стал слишком большим и был разделён, а почти все системы, сразу построенные как микросервисы, попадали в серьёзные проблемы. Два довода: на старте неизвестно, нужен ли продукт пользователям, и скорость важнее масштабируемости; а правильные границы между сервисами трудно угадать даже опытным архитекторам в знакомой предметной области — в монолите их дешевле найти и сдвинуть. Фаулер приводит и контраргумент: построить достаточно модульный монолит требует дисциплины, а раннее разделение приучает команды работать независимо.

Когда выделение назрело, Фаулер советует не переписывать систему целиком, а заменять её постепенно — подход Strangler Fig. Название — от фигового дерева-душителя, которое прорастает на дереве-хозяине и со временем заменяет его. Новые функции и выносимые части строятся рядом со старой системой, поведение переносится по кусочку, а временный код, который позволяет старому и новому сосуществовать (transitional architecture), удаляется в конце. Фаулер выделяет четыре направления работы: понять желаемые результаты, решить, как разбить задачу на части, успешно поставлять части и менять организацию, чтобы это происходило постоянно.

Модули с публичным APIДанные по схемамСвязь через API и событияМодуль — отдельный сервисСтарый код удалён
внутри монолитаstrangler fig
Путь эволюции: сначала границы внутри одного развёртывания, потом перенос по одному модулю

Модульный монолит делает этот путь короче: публичный 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

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

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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