Изоляция модулей module-isolation - ключевые принципы и практическая реализация

19 февраля 2026
Автор

Олег Марков

Введение

Изоляция модулей (module-isolation) — это подход к построению архитектуры, при котором каждый модуль системы проектируется так, чтобы быть максимально независимым от остальных. Цель — сделать кодовую базу предсказуемой, тестируемой и устойчивой к изменениям.

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

  • Где начинается и заканчивается ответственность каждого модуля?
  • Какие зависимости модуль может иметь и как они контролируются?
  • Как модули взаимодействуют между собой без прямого «залезания» во внутренности друг друга?
  • Как ограничить «разрастание» общего кода и не допустить появления хрупких связей?

Смотрите, я покажу вам, как можно подойти к module-isolation последовательно: от теории и принципов к конкретным практикам, соглашениям по структуре проекта и небольшим примерам кода. Примеры будут концептуальными (на псевдокоде, близком к Go/TypeScript), чтобы вы могли легко адаптировать подход к своему стеку.


Ключевые цели и принципы изоляции модулей

Зачем нужна изоляция модулей

Когда в проекте десятки или сотни модулей, без явной изоляции почти всегда возникнет:

  • сильная связанность (tight coupling) между модулями;
  • сложность локальных изменений — изменение в одном месте ломает три других;
  • дублирование логики из‑за страха «притронуться» к существующему коду;
  • усложнение онбординга — новым разработчикам трудно понять границы ответственности.

Изоляция модулей решает эти проблемы за счет:

  • явных границ между модулями;
  • минимального и формализованного публичного API каждого модуля;
  • запрета или жесткого ограничения на «сквозные» зависимости;
  • выноса межмодульного взаимодействия в контролируемый слой (например, слой интеграции).

В результате вы получаете:

  • возможность дорабатывать модуль, почти не трогая остальные;
  • прогнозируемое влияние изменений;
  • более быстрые и надежные ревью и тестирование.

Базовые принципы module-isolation

Давайте сформулируем несколько базовых принципов, вокруг которых строится module-isolation:

  1. Принцип явной границы
    Каждый модуль имеет четко определенную границу и контракт. Все, что внутри границы — его внутренняя реализация, которая не должна использоваться напрямую снаружи.

  2. Принцип минимального API
    Публичный интерфейс модуля должен быть минимальным набором функций/типов, необходимых другим модулям. Все остальное — приватные детали.

  3. Принцип однонаправленных зависимостей
    Зависимости между модулями должны быть однонаправленными и предсказуемыми. Например, core может зависеть только от shared, а feature — от core, но не наоборот.

  4. Принцип запрета «проброса» внутренних деталей
    Внутренние структуры данных модуля не должны становиться частью публичного контракта другого модуля. Для обмена данными используются DTO, события или явно определенные интерфейсы.

  5. Принцип модульного тестирования
    Каждый модуль тестируется изолированно, при необходимости через заглушки (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)
    })
}

Как видите, этот код выполняет:

  • связь orderspayments через события, а не прямые вызовы;
  • каждый модуль остается изолированным;
  • их объединяет только инфраструктура событий (шина).

Работа с зависимостями внутри модуля

Явное объявление зависимостей

Чтобы не нарушать изоляцию, зависимости модуля должны быть:

  • явными;
  • инъецироваться снаружи (через конструкторы);
  • по возможности выражаться в виде интерфейсов, а не конкретных реализаций.

Давайте посмотрим, что происходит в следующем примере:

// Внутри модуля 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;
  • протестировали модуль в полном отрыве от остальной системы.

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

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

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

Границы ответственности и размер модуля

Как понять, что модуль «слишком большой»

Признаки:

  • модуль знает «слишком много» о других частях системы;
  • в каталоге модуля появляется огромное количество несвязанных сценариев;
  • сложно кратко сформулировать, чем занимается модуль;
  • изменяя одну бизнес‑функцию, вы постоянно затрагиваете множество мест в модуле.

В этом случае имеет смысл:

  • разделить модуль по подсущностям (например, billinginvoicing и charging);
  • выделить отдельный модуль для кросс‑сценарной логики;
  • пересмотреть связи: возможно, модуль содержит чужую ответственность.

Как понять, что модуль «слишком маленький»

Признаки:

  • модуль решает только одну‑две тривиальные задачи;
  • без этого модуля остальная система почти не теряет смысла;
  • для понимания простого сценария нужно пролистать множество мини‑модулей.

Чрезмерная детализация приводит к:

  • усложнению навигации;
  • росту накладных расходов на поддержку;
  • усложнению сборки и конфигурации.

Полезное правило: модуль должен описывать законченный фрагмент предметной области, который можно объяснить одной‑двумя фразами без «и еще вот это».


Анти‑паттерны и типичные ошибки

«Общий» модуль, который знают все

Очень частая ошибка — создать модуль common или shared, который:

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

В итоге:

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

Как подойти правильнее:

  • четко разделите технические утилиты (например, логирование, сериализацию) и доменные вещи;
  • технические утилиты можно оформить как отдельную библиотеку, но строго без доменной логики;
  • доменные вещи не выносите в «общий» модуль, если только это не действительно общий язык (например, типы денег, идентификаторы), и то — с осторожностью.

Импорт внутренних деталей вместо публичного API

Еще один анти‑паттерн:

  • разработчик импортирует не корневой пакет модуля, а его infra или domain;
  • использует внутренние типы напрямую;
  • со временем появляется непрозрачная сеть связей.

Чтобы этого избежать:

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

Использование модулей как «просто каталогов»

Иногда проект формально делят на модули, но:

  • модули свободно обращаются к любым слоям друг друга;
  • нет публичных фасадов;
  • нет жестких правил импорта.

Формально структура есть, но изоляции — нет. В таком случае полезно:

  • ввести API‑фасады;
  • ограничить взаимодействие только фасадами;
  • постепенно вычистить «сквозные» зависимости.

Постепенное внедрение module-isolation в существующий проект

С чего начать

Если проект уже существует и сильно связан:

  1. Выделите логические домены
    Опишите основные области: пользователи, платежи, отчеты, каталог и т. д.

  2. Сделайте пробный модуль
    Возьмите один домен, выделите его в модуль с собственным фасадом, слоями и тестами.

  3. Определите правила импортов
    Зафиксируйте в документации, какие связи допускаются, а какие нет.

  4. Начните ограничивать новые связи
    Новые фичи реализуйте уже по правилам module-isolation.

  5. Постепенно рефакторьте старый код
    По мере необходимости переносите функциональность в модули и убирайте «сквозные» зависимости.

Стратегия «страховочных» тестов

Переход к 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 без массовых поломок?

Действуйте по шагам:

  1. Разделите содержимое common на технические утилиты и доменные вещи.
  2. Технические утилиты оформите как отдельную библиотеку util, которая не знает ничего о домене.
  3. Доменные части разнесите по конкретным модулям, где они логически живут.
  4. Заменяйте импорты из common на импорты из новых модулей по одному или группами, контролируя изменения тестами.
Стрелочка влевоСтруктура проекта в Go GolangПринципы Feature Sliced Design - полное практическое руководствоСтрелочка вправо

Все гайды по Feature-sliced_design

Открыть базу знаний

Отправить комментарий