Олег Марков
Изоляция модулей module-isolation - ключевые принципы и практическая реализация
Введение
Изоляция модулей (module-isolation) — это подход к построению архитектуры, при котором каждый модуль системы проектируется так, чтобы быть максимально независимым от остальных. Цель — сделать кодовую базу предсказуемой, тестируемой и устойчивой к изменениям.
Когда вы внедряете изоляцию модулей, вы по сути отвечаете на несколько важных вопросов:
- Где начинается и заканчивается ответственность каждого модуля?
- Какие зависимости модуль может иметь и как они контролируются?
- Как модули взаимодействуют между собой без прямого «залезания» во внутренности друг друга?
- Как ограничить «разрастание» общего кода и не допустить появления хрупких связей?
Смотрите, я покажу вам, как можно подойти к module-isolation последовательно: от теории и принципов к конкретным практикам, соглашениям по структуре проекта и небольшим примерам кода. Примеры будут концептуальными (на псевдокоде, близком к Go/TypeScript), чтобы вы могли легко адаптировать подход к своему стеку.
Ключевые цели и принципы изоляции модулей
Зачем нужна изоляция модулей
Когда в проекте десятки или сотни модулей, без явной изоляции почти всегда возникнет:
- сильная связанность (tight coupling) между модулями;
- сложность локальных изменений — изменение в одном месте ломает три других;
- дублирование логики из‑за страха «притронуться» к существующему коду;
- усложнение онбординга — новым разработчикам трудно понять границы ответственности.
Изоляция модулей решает эти проблемы за счет:
- явных границ между модулями;
- минимального и формализованного публичного API каждого модуля;
- запрета или жесткого ограничения на «сквозные» зависимости;
- выноса межмодульного взаимодействия в контролируемый слой (например, слой интеграции).
В результате вы получаете:
- возможность дорабатывать модуль, почти не трогая остальные;
- прогнозируемое влияние изменений;
- более быстрые и надежные ревью и тестирование.
Базовые принципы module-isolation
Давайте сформулируем несколько базовых принципов, вокруг которых строится module-isolation:
Принцип явной границы
Каждый модуль имеет четко определенную границу и контракт. Все, что внутри границы — его внутренняя реализация, которая не должна использоваться напрямую снаружи.Принцип минимального API
Публичный интерфейс модуля должен быть минимальным набором функций/типов, необходимых другим модулям. Все остальное — приватные детали.Принцип однонаправленных зависимостей
Зависимости между модулями должны быть однонаправленными и предсказуемыми. Например,coreможет зависеть только отshared, аfeature— отcore, но не наоборот.Принцип запрета «проброса» внутренних деталей
Внутренние структуры данных модуля не должны становиться частью публичного контракта другого модуля. Для обмена данными используются DTO, события или явно определенные интерфейсы.Принцип модульного тестирования
Каждый модуль тестируется изолированно, при необходимости через заглушки (stubs/mocks) внешних зависимостей.
Типовые архитектурные уровни и место module-isolation
Модули vs слои
Важно не путать слои архитектуры и модули. Слои (например, presentation, application, domain, infrastructure) обычно описывают вертикальное разделение по типу ответственности, а модули — горизонтальное, по предметным областям или сценариям.
Очень часто module-isolation реализуют так:
- каждый модуль имеет все необходимые слои внутри себя;
- либо слои общие, но каждый модуль располагает в них только свои компоненты.
Давайте разберемся на примере.
Представим систему интернет‑магазина. Возможная декомпозиция:
catalog— управление товарами и категориями;orders— оформление и управление заказами;payments— платежи;users— учетные записи клиентов.
Каждый модуль может включать в себя:
domain— бизнес‑логика и сущности;application— сценарии использования, команды/обработчики;infra— репозитории, интеграции с внешними сервисами.
При module-isolation вы не делаете один большой слой domain для всей системы. Вы делаете catalog.domain, orders.domain, payments.domain и так далее. Это и есть изоляция.
Правила границ модуля
Что входит в модуль
Как правило, в модуль имеет смысл включить:
- доменные сущности и объекты (например, Order, Product, Payment);
- бизнес‑правила (валидация, политики, агрегация данных);
- интерфейсы для внешнего мира (например, порты или API‑интерфейсы);
- адаптеры для работы с инфраструктурой (репозитории, клиенты внешних API);
- публичный фасад модуля — точка входа, через которую с ним взаимодействуют.
Главный критерий — логическая целостность: модуль должен описывать завершенный фрагмент предметной области или сценарий.
Что не должно «утекать» из модуля
При module-isolation важно не допустить «утечки» деталей. Не стоит:
- выставлять наружу внутренние структуры хранения (ORM‑модели, SQL‑структуры);
- раскрывать внутренние реализации сервисов;
- позволять другим модулям обходить ваш публичный API и обращаться к «внутренностям» напрямую.
Чтобы этого избежать, часто используют:
- соглашения по структуре каталогов;
- ограничения импортов (например, через линтеры или правила сборки);
- экспорт только фасадных типов/функций.
Структурирование проекта с учетом module-isolation
Пример структуры каталогов
Смотрите, я покажу вам пример структуры на условном языке:
// Каталог modules - здесь лежат независимые модули
/modules
/catalog
/domain
product.go // доменная сущность товара
category.go // доменная сущность категории
service.go // бизнес-логика работы с товарами
/application
list_products.go // сценарии использования (use cases)
create_product.go
/infra
product_repo_pg.go // репозиторий для PostgreSQL
api.go // публичный фасад модуля catalog
/orders
/domain
order.go
order_service.go
/application
create_order.go
/infra
order_repo_pg.go
api.go // публичный фасад модуля orders
/payments
...
Комментарии в коде помогают вам понять, какую роль играют файлы:
domain— чистая бизнес‑логика;application— сценарии, orchestrators;infra— конкретные реализации инфраструктуры;api.go— единственная точка, через которую другие модули могут работать с этим модулем.
Публичный фасад модуля
Теперь вы увидите, как это выглядит в коде. Пусть модуль catalog предоставляет только две операции: получить товар и создать новый.
// Файл modules/catalog/api.go
package catalog
// ProductDTO - структура, которую мы показываем наружу
// Внутренние доменные сущности могут отличаться по полям
type ProductDTO struct {
ID string
Name string
Description string
PriceCents int64
}
// Service - интерфейс публичного сервиса модуля catalog
// Другие модули зависят только от этого интерфейса
type Service interface {
GetProductByID(id string) (ProductDTO, error)
CreateProduct(name string, description string, priceCents int64) (ProductDTO, error)
}
// NewService - фабричный метод для создания реализации сервиса
// Здесь мы скрываем внутреннюю структуру и зависимости
func NewService(repo ProductRepository) Service {
// Здесь мы возвращаем внутреннюю реализацию,
// но снаружи виден только интерфейс Service
return &serviceImpl{
repo: repo,
}
}
Важно, что:
- Другие модули не импортируют
modules/catalog/domain. - Они работают только с
modules/catalogи типами/интерфейсами изapi.go.
Ограничение импортов и зависимостей
Само соглашение по структуре — уже шаг к изоляции. Но его легко нарушить. Поэтому практикуют:
- проверку импортов линтером (например, custom‑правила в ESLint, golangci-lint);
- явную конфигурацию зависимостей на уровне сборки (например, запрет на импорты из
infraмежду модулями); - ревью‑правило: кросс‑модульные связи — только через публичные фасады.
Пример правила:
«Любой модуль может импортировать другой модуль только через его корневой пакет/namespace, но не через внутренние каталоги».
Взаимодействие модулей
Прямые зависимости через публичные фасады
Самый простой способ взаимодействия — один модуль напрямую вызывает другой через его публичный интерфейс.
Пример: orders обращается к catalog, чтобы получить информацию о товаре.
// Файл modules/orders/domain/order_service.go
package orders
import "example.com/project/modules/catalog"
// OrderService - доменный сервис модуля orders
type OrderService struct {
catalogSvc catalog.Service // зависимость от публичного интерфейса catalog
repo OrderRepository
}
// CreateOrder - создание заказа на основе списка товаров
func (s *OrderService) CreateOrder(productIDs []string) (Order, error) {
var items []OrderItem
for _, id := range productIDs {
// Здесь мы вызываем модуль catalog через его интерфейс
product, err := s.catalogSvc.GetProductByID(id)
if err != nil {
return Order{}, err
}
// Формируем позицию заказа на основе данных из catalog
items = append(items, OrderItem{
ProductID: id,
Price: product.PriceCents,
})
}
// Далее идет создание и сохранение заказа
order := NewOrder(items) // доменная логика создания заказа
if err := s.repo.Save(order); err != nil {
return Order{}, err
}
return order, nil
}
Обратите внимание, как этот фрагмент кода решает задачу:
ordersне знает, как устроенcatalogвнутри;- связь выражена через интерфейс
catalog.Service; ordersиспользует только DTO и методы, которые модульcatalogрешил сделать публичными.
Событийное взаимодействие между модулями
При прямых синхронных вызовах можно легко прийти к «паутине» зависимостей. Чтобы снизить связанность, используют событийный подход:
- модуль публикует события о произошедших изменениях;
- другие модули подписываются и реагируют.
Например:
- модуль
ordersпубликует событиеOrderCreated; - модуль
paymentsподписан на это событие и создает платеж.
Покажу вам, как это реализовано на практике в упрощенном виде.
// Общий интерфейс для событий
type Event interface {
Name() string
}
// OrderCreatedEvent - событие модуля orders
type OrderCreatedEvent struct {
OrderID string
Amount int64
}
func (e OrderCreatedEvent) Name() string {
return "orders.OrderCreated"
}
// EventBus - простой шиной событий
type EventBus interface {
Publish(event Event) error
Subscribe(eventName string, handler func(Event)) error
}
Модуль orders:
// Внутри модуля orders
type OrderService struct {
repo OrderRepository
eventBus EventBus // зависимость от шины событий
}
func (s *OrderService) CreateOrder(...) (Order, error) {
// ... создание заказа
// Публикуем событие о создании заказа
evt := OrderCreatedEvent{
OrderID: order.ID,
Amount: order.TotalAmount,
}
// Сообщаем системе об успешном создании заказа
if err := s.eventBus.Publish(evt); err != nil {
return Order{}, err
}
return order, nil
}
Модуль payments:
// Внутри модуля payments
func RegisterOrderCreatedHandler(bus EventBus, paymentsSvc *PaymentsService) {
// Подписываем хендлер на событие OrderCreated
bus.Subscribe("orders.OrderCreated", func(e Event) {
evt, ok := e.(OrderCreatedEvent)
if !ok {
// Здесь можно логировать ошибку преобразования типа
return
}
// Реагируем на событие - создаем платеж
// Все параметры берем из события
_ = paymentsSvc.CreatePayment(evt.OrderID, evt.Amount)
})
}
Как видите, этот код выполняет:
- связь
orders→paymentsчерез события, а не прямые вызовы; - каждый модуль остается изолированным;
- их объединяет только инфраструктура событий (шина).
Работа с зависимостями внутри модуля
Явное объявление зависимостей
Чтобы не нарушать изоляцию, зависимости модуля должны быть:
- явными;
- инъецироваться снаружи (через конструкторы);
- по возможности выражаться в виде интерфейсов, а не конкретных реализаций.
Давайте посмотрим, что происходит в следующем примере:
// Внутри модуля catalog
// ProductRepository - интерфейс хранилища товаров
// Реализация может быть любой - БД, кэш, внешний сервис
type ProductRepository interface {
GetByID(id string) (Product, error)
Save(p Product) error
}
// serviceImpl - внутренняя реализация Service
type serviceImpl struct {
repo ProductRepository
}
// GetProductByID - реализация публичного метода
func (s *serviceImpl) GetProductByID(id string) (ProductDTO, error) {
// Достаем доменную сущность из репозитория
product, err := s.repo.GetByID(id)
if err != nil {
return ProductDTO{}, err
}
// Преобразуем доменную сущность к DTO
return ProductDTO{
ID: product.ID,
Name: product.Name,
Description: product.Description,
PriceCents: product.PriceCents,
}, nil
}
Смотрите, здесь важные моменты:
serviceImplне создаетProductRepositoryсам, он получает его извне;- это позволяет легко подменять реализацию (например, на in‑memory для тестов);
- модуль
catalogможет работать в изоляции, ему не нужно знать детали инфраструктуры.
Диаграмма зависимостей модуля
Полезно держать в голове идею «внутрь — к домену». Зависимости должны направляться так:
infraзависит отdomain, но не наоборот;applicationзависит отdomain, может отinfra(через абстракции);- публичный фасад зависит от
applicationиdomain.
Вы можете оформить это как правило:
- не допускаем импортов из
infraвdomain; - доменные сущности не зависят от инфраструктурных типов.
Тестирование в условиях module-isolation
Локальные модульные тесты
Каждый модуль должен тестироваться независимо от других. Для этого:
- внешние зависимости модуля подменяются на заглушки;
- тесты покрывают публичный интерфейс модуля и внутреннюю доменную логику;
- кросс‑модульное взаимодействие тестируется на уровне интеграции, а не unit‑тестами.
Пример unit‑теста для модуля catalog:
// Файл modules/catalog/service_test.go
// fakeProductRepo - фейковая реализация ProductRepository
// Используется только в тестах для изоляции от БД
type fakeProductRepo struct {
products map[string]Product
}
func (r *fakeProductRepo) GetByID(id string) (Product, error) {
p, ok := r.products[id]
if !ok {
return Product{}, errors.New("not found")
}
return p, nil
}
func (r *fakeProductRepo) Save(p Product) error {
r.products[p.ID] = p
return nil
}
func TestService_GetProductByID(t *testing.T) {
repo := &fakeProductRepo{
products: map[string]Product{
"p1": {ID: "p1", Name: "Test product", PriceCents: 1000},
},
}
// Создаем сервис через публичный конструктор
svc := NewService(repo)
dto, err := svc.GetProductByID("p1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dto.Name != "Test product" {
t.Fatalf("expected name Test product, got %s", dto.Name)
}
}
Комментариями здесь пояснено:
- мы подменили реальный репозиторий на
fakeProductRepo; - протестировали модуль в полном отрыве от остальной системы.
Контрактные тесты между модулями
Если модуль предоставляет важный контракт, вы можете использовать контрактные тесты:
- описывать ожидаемое поведение модуля как набор тестовых сценариев;
- запускать эти тесты и для реальной реализации, и для «фейковой» при необходимости;
- гарантировать, что изменения внутри модуля не нарушат договоренности с другими.
Границы ответственности и размер модуля
Как понять, что модуль «слишком большой»
Признаки:
- модуль знает «слишком много» о других частях системы;
- в каталоге модуля появляется огромное количество несвязанных сценариев;
- сложно кратко сформулировать, чем занимается модуль;
- изменяя одну бизнес‑функцию, вы постоянно затрагиваете множество мест в модуле.
В этом случае имеет смысл:
- разделить модуль по подсущностям (например,
billing→invoicingиcharging); - выделить отдельный модуль для кросс‑сценарной логики;
- пересмотреть связи: возможно, модуль содержит чужую ответственность.
Как понять, что модуль «слишком маленький»
Признаки:
- модуль решает только одну‑две тривиальные задачи;
- без этого модуля остальная система почти не теряет смысла;
- для понимания простого сценария нужно пролистать множество мини‑модулей.
Чрезмерная детализация приводит к:
- усложнению навигации;
- росту накладных расходов на поддержку;
- усложнению сборки и конфигурации.
Полезное правило: модуль должен описывать законченный фрагмент предметной области, который можно объяснить одной‑двумя фразами без «и еще вот это».
Анти‑паттерны и типичные ошибки
«Общий» модуль, который знают все
Очень частая ошибка — создать модуль common или shared, который:
- начинает содержать половину кода проекта;
- используется всеми модулями без ограничений;
- становится транзитивной зависимостью для всего.
В итоге:
- любое изменение в
commonможет повлиять на все модули; - изоляция модулей фактически размывается.
Как подойти правильнее:
- четко разделите технические утилиты (например, логирование, сериализацию) и доменные вещи;
- технические утилиты можно оформить как отдельную библиотеку, но строго без доменной логики;
- доменные вещи не выносите в «общий» модуль, если только это не действительно общий язык (например, типы денег, идентификаторы), и то — с осторожностью.
Импорт внутренних деталей вместо публичного API
Еще один анти‑паттерн:
- разработчик импортирует не корневой пакет модуля, а его
infraилиdomain; - использует внутренние типы напрямую;
- со временем появляется непрозрачная сеть связей.
Чтобы этого избежать:
- договоритесь, что импорты должны идти только в корневой пакет модуля;
- контролируйте это линтерами и ревью;
- не экспортируйте из внутренних пакетов типы, которые не должны быть доступны.
Использование модулей как «просто каталогов»
Иногда проект формально делят на модули, но:
- модули свободно обращаются к любым слоям друг друга;
- нет публичных фасадов;
- нет жестких правил импорта.
Формально структура есть, но изоляции — нет. В таком случае полезно:
- ввести API‑фасады;
- ограничить взаимодействие только фасадами;
- постепенно вычистить «сквозные» зависимости.
Постепенное внедрение module-isolation в существующий проект
С чего начать
Если проект уже существует и сильно связан:
Выделите логические домены
Опишите основные области: пользователи, платежи, отчеты, каталог и т. д.Сделайте пробный модуль
Возьмите один домен, выделите его в модуль с собственным фасадом, слоями и тестами.Определите правила импортов
Зафиксируйте в документации, какие связи допускаются, а какие нет.Начните ограничивать новые связи
Новые фичи реализуйте уже по правилам module-isolation.Постепенно рефакторьте старый код
По мере необходимости переносите функциональность в модули и убирайте «сквозные» зависимости.
Стратегия «страховочных» тестов
Переход к module-isolation часто сопровождается рефакторингом. Чтобы не сломать систему:
- добавьте интеграционные тесты, которые покрывают важные сценарии;
- после каждого шага рефакторинга прогоняйте эти тесты;
- по мере изоляции модулей часть интеграционных тестов можно заменить модульными и контрактными.
Заключение
Изоляция модулей — это не про «красивую» структуру каталогов, а про управляемые зависимости и четкие границы ответственности. Если вы:
- определяете для каждого модуля понятный контракт;
- ограничиваете доступ к внутренностям;
- делаете зависимости предсказуемыми и однонаправленными;
- тестируете модули отдельно, а интеграцию — через четко определенные точки;
то ваш проект становится значительно устойчивее к изменениям и проще для развития.
Подход module-isolation хорошо сочетается и с микросервисами, и с модульной монолитной архитектурой. В обоих случаях он помогает не превращать систему в «комок связей», где любое изменение опасно.
Частозадаваемые технические вопросы по теме и ответы
1. Как запретить импорт внутренних пакетов модуля на уровне инструмента?
Используйте линтеры или статический анализ:
- в Go можно добавить кастомное правило для golangci-lint (например, плагин, который проверяет, что импорты разрешены только в корневой пакет модуля);
- в TypeScript/JavaScript можно использовать ESLint с правилом no-restricted-imports, где перечислить запрещенные пути, вроде
modules/**/infra/**иmodules/**/domain/**.
Запустите линтер в CI, чтобы пул‑реквесты с нарушением не проходили.
2. Как организовать миграции базы данных при module-isolation?
Часто делают каталог миграций на модуль:
migrations/catalog,migrations/ordersи т. д.;- каждый модуль отвечает только за свою схему;
- инструмент миграций (например, Flyway, Liquibase, goose) запускается по всем каталогам в фиксированном порядке.
Важно, чтобы один модуль не менял таблицы, которые логически принадлежат другому модулю.
3. Как быть с транзакциями, которые затрагивают несколько модулей?
Обычно применяют:
- либо саги/паттерн «оркестратор» на уровне приложения — отдельный компонент управляет последовательностью шагов в разных модулях и компенсирующими операциями;
- либо вынос сложной кросс‑модульной транзакции в единый модуль «workflow» или «process», который знает о нескольких доменах, но взаимодействует с ними только через публичные фасады.
Прямые распределенные транзакции между модулями стараются избегать.
4. Можно ли делиться доменными сущностями между модулями?
Желательно нет. Если вам нужно передавать доменные данные:
- создайте отдельные DTO/контракты для общения модулей (структуры, события);
- модули могут маппить свои сущности к этим DTO и обратно;
- общий доменный тип допустим только как часть «общего языка» (например, Money, CountryCode) и в отдельном, очень небольшом модуле.
5. Как постепенно выносить «общий» модуль common без массовых поломок?
Действуйте по шагам:
- Разделите содержимое
commonна технические утилиты и доменные вещи. - Технические утилиты оформите как отдельную библиотеку util, которая не знает ничего о домене.
- Доменные части разнесите по конкретным модулям, где они логически живут.
- Заменяйте импорты из
commonна импорты из новых модулей по одному или группами, контролируя изменения тестами.