
Модульный монолит на NestJS: практическое руководство
- NestJS
- modular monolith
- bounded modules
- domain events
- transactions
- architecture testing
- microservices

Модульный монолит на NestJS: практическое руководство
Модульный монолит — это один deployable application, бизнес-возможности которого разделены enforceable boundaries. Он сохраняет local calls, простое развёртывание и database transactions, одновременно предотвращая неконтролируемый coupling, превращающий обычный монолит в проблему. Для многих развивающихся продуктов это лучший default, чем старт с distributed system.
NestJS предоставляет modules, providers и dependency injection, но decorators сами не создают domain boundaries. Команда должна определить ownership, public contracts, data access и разрешённые dependencies. GARNO.TECH применяет эти правила в рамках разработки на NestJS, сохраняя скорость delivery и создавая реальный путь к будущему выделению сервисов.
Начинайте с бизнес-границ, а не с папок
Стройте modules вокруг бизнес-возможностей Orders, Billing, Inventory и Identity, а не вокруг технических layers, общих для всего application. Bounded module владеет своими use cases, domain rules, persistence mappings и migrations. Controllers являются delivery adapters, а repositories — implementation details. Generic shared folder не должен становиться скрытым domain без ownership.
Определите язык и invariants каждой boundary. Orders может хранить customer и product identifiers, но не должен обращаться к repository другого module, чтобы изменять его tables. Иногда малый stable value object лучше продублировать, чем связать release decisions. Действительно generic infrastructure — logging, configuration, clock и tracing — держите в узком core без business policy.
Предоставьте каждому модулю малый public API
Другие modules должны импортировать явный facade, command/query port или event contract, но не internal services и repositories. NestJS providers инкапсулированы, если их не export, поэтому считайте exports array архитектурной public surface. Экспортируйте лишь несколько stable capabilities, а controllers, ORM entities, repository implementations и internal orchestration оставляйте private.
Facade принимает purpose-specific input и возвращает contract, которым владеет boundary. Не выставляйте query builders или persistence entities: тогда callers зависят от storage details. Если для business decision нужен synchronous call, используйте public port. Если consumer лишь реагирует на факт, публикуйте event, название которого описывает завершённое business occurrence.
// orders/public/orders.facade.ts
export abstract class OrdersFacade {
abstract place(command: PlaceOrder): Promise<PlacedOrder>;
abstract find(id: string): Promise<OrderView | null>;
}
// orders/orders.module.ts
@Module({
imports: [OrdersPersistenceModule],
controllers: [OrdersController],
providers: [
PlaceOrderHandler,
FindOrderHandler,
{ provide: OrdersFacade, useClass: NestOrdersFacade }
],
exports: [OrdersFacade] // the complete public module API
})
export class OrdersModule {}
// Billing imports OrdersFacade; it never imports OrdersRepository,
// OrderEntity or handlers from an orders/internal path.Оставляйте transactions локальными и надёжно публикуйте events
Сохраняйте invariant и его writes в одном module и одной local transaction. Transaction, охватывающая tables нескольких owners, скрывает проблему boundary и станет сложной после extraction. Когда operation должна запросить работу в другом месте, сначала commit-те owner state, затем координируйте workflow с явными failure и compensation rules.
In-process events помогают decoupling, но publish только после commit создаёт crash window, а до commit может описать data, которая затем rollback. Для важных integration events запишите outbox row в одной transaction с domain state. Worker публикует её с idempotency key, записывает delivery attempts и выдерживает duplicates. Consumers должны быть idempotent, даже пока всё работает в одном process.
await dataSource.transaction(async manager => {
const order = Order.place(command);
await manager.getRepository(OrderEntity).save(mapOrder(order));
await manager.getRepository(OutboxMessage).insert({
id: randomUUID(), // consumer idempotency key
type: "orders.order-placed.v1",
aggregateId: order.id,
payload: JSON.stringify({
orderId: order.id,
customerId: order.customerId,
totalMinor: order.totalMinor
}),
occurredAt: new Date(),
publishedAt: null
});
});
// A separate worker claims unpublished rows, publishes them,
// and marks them delivered. Retries may create duplicates,
// so every consumer records processed message IDs.Превратите dependency rules в исполняемые тесты
Architecture documentation устаревает, если CI её не enforce. Добавьте rules, запрещающие imports из internal directory другого module, не позволяющие domain code зависеть от NestJS или ORM packages и отклоняющие cycles. Разрешайте cross-module imports только через public entry point. Объединяйте static rules с module-level integration tests и real persistence там, где важна transaction behavior.
Тестируйте boundary через public API, а не private providers. Contract tests должны охватывать commands, queries и event schemas. Малый end-to-end suite проверяет wiring и HTTP behavior, но не заменяет быстрых domain tests. Считайте forbidden-import violation ошибкой build, потому что игнорируемый warning не является architectural constraint.
Выделяйте сервис только при наличии доказательств
Готовьтесь к extraction, не делая вид, что network уже существует. Дайте candidate module чёткий ownership, serializable contracts, module-owned data access, observable workflows и idempotent event handling. Не добавляйте HTTP между modules, distributed tracing и eventual consistency только ради сходства с microservices: эти затраты должны появиться после реальной потребности.
Выделяйте service, когда stable capability требует independent deployment, существенно иного scaling, усиленной security/failure isolation или autonomous team ownership. Сначала измерьте constraint и определите SLO. Затем спрячьте public contract за transport adapter, перенесите owned data, запустите compatibility tests, постепенно переключайте traffic и сохраните rollback. Безопасной миграцию делают boundaries, а не speculative infrastructure.
Чек-лист реализации модульного монолита
- Определите business capabilities, invariants, language и data ownership до создания NestJS modules.
- Откройте малый facade, port или event contract; repositories, entities и handlers оставьте private.
- Держите transactions внутри одного owner; для надёжных integration events используйте transactional outbox.
- Enforce разрешённые imports, layer direction и отсутствие cycles в CI; тестируйте modules через public APIs.
- Extract только при измеренной потребности в deployment, scaling, isolation или ownership, с telemetry и rollback.
Является ли NestJS module автоматически bounded context?
Могут ли modules использовать одну database?
Достаточно ли in-process events для надёжных workflows?
Когда module должен стать microservice?
Наши исследования
Исследование и разработка решений на основе искусственного интеллекта для оптимизации бизнес-процессов и повышения эффективности принятия решений.
Анализ моделей машинного обучения для прогнозной аналитики в сфере финансов, электронной коммерции и SaaS-платформ.
Исследование технологий обработки естественного языка и компьютерного зрения для усиления автоматизации, персонализации и поддержки клиентов.


