Цели модуля

После модуля студент сможет:

  • Определять границы сервисов по доменным областям
  • Осознанно выбирать между modular monolith и microservices
  • Применять тактические паттерны Domain-Driven Design
  • Использовать Dependency Inversion для гибкой архитектуры
  • Проектировать поддерживаемые и расширяемые системы
  • Понимать основы multi-tenancy

1.1 Роль архитектора

Что делает архитектор

Архитектор — не просто senior разработчик. Архитектор:

  • Принимает решения о структуре системы
  • Оценивает trade-offs (компромиссы)
  • Обеспечивает эволюционируемость системы
  • Коммуницирует решения команде
  • Документирует решения (ADR)
  • Защищает технические решения перед бизнесом

Уровни архитектурных решений

УровеньПримерыКто принимает
СтратегическийМонолит vs микросервисы, выбор облакаАрхитектор + CTO
ТактическийГраницы сервисов, выбор БД, паттерныАрхитектор + Tech Lead
ОперационныйСтруктура пакетов, именованиеTech Lead + команда

Trade-off мышление

Нет "лучшей" архитектуры. Есть архитектура подходящая для:

  • Размера команды
  • Сложности домена
  • Требований к масштабированию
  • Операционных возможностей
  • Бюджета

Задача архитектора — найти баланс, а не идеал.

Архитектурные принципы

  1. Простота: не усложняй без необходимости
  2. Эволюционируемость: система должна меняться
  3. Тестируемость: легко проверять поведение
  4. Наблюдаемость: понятно что происходит внутри
  5. Безопасность: защита данных и доступов
  6. Масштабируемость: рост без полной перестройки

1.2 Service boundaries

Что такое границы сервиса

Граница сервиса определяет:

  • Какие данные принадлежат сервису
  • Какие операции сервис выполняет
  • Какой API сервис предоставляет
  • За что сервис отвечает

Определение границ по домену

Границы сервисов должны совпадать с доменными областями:

E-commerce платформа:
├── Catalog Service      # товары, категории
├── Cart Service         # корзина
├── Order Service        # заказы
├── Payment Service      # платежи
├── Inventory Service    # остатки
└── Notification Service # уведомления

Каждый сервис владеет своей доменной областью.

Признаки плохих границ

  1. Distributed monolith: сервисы сильно связаны, нужно деплоить вместе
  2. Chatty services: много сетевых вызовов между сервисами
  3. Shared database: несколько сервисов пишут в одну таблицу
  4. Business logic across services: правила размазаны по сервисам

Правила границ

  1. Один сервис — одна доменная область
  2. Сервис владеет своими данными (своя БД или схема)
  3. Взаимодействие через API или события
  4. Изменение одного сервиса не требует изменения других
  5. Сервис можно задеплоить независимо

Эволюция границ

Границы могут меняться со временем:

Шаг 1: Модульный монолит (все модули в одном процессе)
Шаг 2: Вынос горячего модуля в отдельный сервис
Шаг 3: Разделение при росте команды
Шаг 4: Объединение мелких сервисов при избыточной декомпозиции

Не стремитесь к идеальным границам сразу. Начинайте с модульного монолита и эволюционируйте при необходимости.


1.3 Modular Monolith vs Microservices (углублённо)

Modular Monolith

Одно приложение, один деплой, но код разделён на независимые модули.

Структура:

myapp/
├── cmd/app/main.go
├── modules/
│   ├── catalog/
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── repository.go
│   │   ├── model.go
│   │   └── dto.go
│   ├── orders/
│   │   └── ...
│   └── payments/
│       └── ...
├── shared/
│   ├── database/
│   ├── middleware/
│   └── config/
└── go.mod

Правила modular monolith:

  1. Модули не импортируют внутренние пакеты друг друга
  2. Взаимодействие через публичные интерфейсы модуля
  3. Каждый модуль имеет свою таблицу в БД
  4. Shared-код минимален и стабилен

Microservices

Каждый сервис — отдельное приложение со своей БД.

catalog-service/
├── cmd/app/main.go
├── internal/...
└── go.mod

orders-service/
├── cmd/app/main.go
├── internal/...
└── go.mod

Взаимодействие через сеть: HTTP, gRPC, очередь сообщений.

Сравнение

КритерийModular MonolithMicroservices
ДеплойОдинНезависимый
МасштабированиеВертикальноеГоризонтальное per-service
Сетевые вызовыНетДа
ТранзакцииЛокальныеРаспределённые (Saga)
ОтказоустойчивостьОдна точка отказаИзоляция сбоев
Операционная сложностьНизкаяВысокая
КомандаДо 10 человекНесколько команд

Когда выбирать что

Modular Monolith когда:

  • Одна команда (до 10 человек)
  • Один продукт
  • Не нужно независимое масштабирование
  • Простота деплоя критична
  • Стартап, MVP

Microservices когда:

  • Несколько автономных команд
  • Нужно независимое масштабирование компонентов
  • Разные технологические требования
  • Организационная необходимость
  • Зрелая инфраструктура (Kubernetes, observability)

Правило

Не начинайте с микросервисов. Начните с modular monolith. Выносите в микросервисы когда есть реальная необходимость.

"Microservices are the destination, not the starting point."


1.4 Domain-Driven Design (tactical patterns)

Что такое DDD

Domain-Driven Design — подход к проектированию ПО через доменную область. Стратегический DDD определяет границы контекстов. Тактический DDD — паттерны внутри контекста.

Тактические паттерны

Entity

Сущность с идентификатором и жизненным циклом.

go
type Order struct {
    ID         string
    CustomerID string
    Status     OrderStatus
    Items      []OrderItem
    CreatedAt  time.Time
    UpdatedAt  time.Time
}

func (o *Order) Cancel() error {
    if o.Status != OrderStatusPending {
        return ErrCannotCancel
    }
    o.Status = OrderStatusCancelled
    o.UpdatedAt = time.Now()
    return nil
}

Value Object

Объект без идентификатора, определяется значениями полей.

go
type Money struct {
    Amount   int64  // в копейках/центах
    Currency string
}

func (m Money) Add(other Money) (Money, error) {
    if m.Currency != other.Currency {
        return Money{}, ErrCurrencyMismatch
    }
    return Money{Amount: m.Amount + other.Amount, Currency: m.Currency}, nil
}

Value objects immutable. Для изменения создаём новый объект.

Aggregate

Группа сущностей и value objects с инвариантами. Aggregate root — точка доступа к агрегату.

go
type Order struct {
    ID     string
    Items  []OrderItem  // часть агрегата
    Total  Money        // value object
    Status OrderStatus
}

// Order — aggregate root
// OrderItem — часть агрегата, не доступна напрямую

func (o *Order) AddItem(product Product, quantity int) error {
    if quantity <= 0 {
        return ErrInvalidQuantity
    }

    item := OrderItem{
        ProductID: product.ID,
        Name:      product.Name,
        Price:     product.Price,
        Quantity:  quantity,
    }

   o.Items = append(o.Items, item)
   o.recalculateTotal()
   return nil
}

Domain Service

Операция не принадлежащая ни одной сущности.

go
type PricingService struct {
    discounts DiscountRepository
}

func (s *PricingService) CalculateTotal(order *Order) (Money, error) {
    base := order.Subtotal()

    discounts, err := s.discounts.GetForCustomer(order.CustomerID)
    if err != nil {
        return Money{}, err
    }

    return applyDiscounts(base, discounts)
}

Repository

Абстракция доступа к агрегатам.

go
type OrderRepository interface {
    Get(ctx context.Context, id string) (*Order, error)
    Save(ctx context.Context, order *Order) error
    Delete(ctx context.Context, id string) error
}

Repository возвращает и сохраняет агрегаты целиком, не отдельные поля.

Domain Event

Факт произошедший в домене.

go
type OrderPlaced struct {
    OrderID    string
    CustomerID string
    Total      Money
    OccurredAt time.Time
}

Domain events используются для:

  • Декомпозиции сервисов
  • Аудита
  • Интеграции
  • Асинхронной обработки

Применение DDD в Go

Go не имеет классов и наследования, но DDD паттерны применимы:

Паттерн DDDРеализация в Go
Entitystruct с методами
Value Objectstruct, immutable через конструктор
Aggregatestruct с вложенными объектами
Domain Servicestruct с методами, зависит от репозиториев
Repositoryinterface + реализация
Domain Eventstruct, публикуется через outbox

Когда применять DDD

Применяйте DDD когда:

  • Сложная бизнес-логика
  • Много правил и инвариантов
  • Домен требует точного моделирования
  • Команда работает с доменными экспертами

Не применяйте для:

  • Простого CRUD
  • Утилит и инструментов
  • Прототипов

1.5 Dependency Inversion в Go (углублённо)

Принцип

Модули верхнего уровня не зависят от модулей нижнего уровня. Оба зависят от абстракций (интерфейсов).

Интерфейсы в Go: duck typing

Go интерфейсы удовлетворяются неявно. Тип реализует интерфейс если имеет все его методы. Не нужно implements.

go
// Интерфейс в пакете потребителя
package repository

type RepoRepository interface {
    Create(ctx context.Context, repo *models.Repo) error
    GetByID(ctx context.Context, id string) (*models.Repo, error)
}

// Реализация в пакете адаптера
package postgres

type RepoRepository struct {
    pool *pgxpool.Pool
}

// PostgresRepoRepository автоматически удовлетворяет
// repository.RepoRepository без явного объявления

Интерфейсы определяет потребитель

Важный принцип: интерфейс определяется там, где используется, не там, где реализуется.

go
// ПЛОХО: интерфейс в пакете реализации
package postgres
type RepoRepository interface { ... }

// ПРАВИЛЬНО: интерфейс в пакете потребителя
package repository  // или package service
type RepoRepository interface { ... }

Это позволяет:

  • Потребителю определять только нужные методы
  • Иметь несколько реализаций одного интерфейса
  • Тестировать с mock без реальной реализации

Interface Segregation

Не создавайте большие интерфейсы. Разделяйте на маленькие.

go
// ПЛОХО: большой интерфейс
type Storage interface {
    Read(path string) ([]byte, error)
    Write(path string, data []byte) error
    Delete(path string) error
    List(prefix string) ([]string, error)
    GetMetadata(path string) (Metadata, error)
    SetMetadata(path string, meta Metadata) error
    Copy(src, dst string) error
    Move(src, dst string) error
}

// ПРАВИЛЬНО: маленькие интерфейсы
type Reader interface {
    Read(path string) ([]byte, error)
}

type Writer interface {
    Write(path string, data []byte) error
}

type Deleter interface {
    Delete(path string) error
}

// Композиция при необходимости
type ReadWriter interface {
    Reader
    Writer
}

Dependency Injection в Go

В Go идиоматично использовать ручной DI через конструкторы:

go
func NewRepoService(repo repository.RepoRepository) *RepoService {
    return &RepoService{repo: repo}
}

func NewCreateRepoUseCase(repoSvc *RepoService, outboxSvc *OutboxService) *CreateRepoUseCase {
    return &CreateRepoUseCase{repo: repoSvc, outbox: outboxSvc}
}

Фреймворки (wire, fx, dig) нужны только для очень больших проектов.

Тестирование через DI

go
func TestRepoService_GetRepo(t *testing.T) {
    // Подставляем mock
    mockRepo := new(MockRepoRepository)
    mockRepo.On("GetByID", mock.Anything, "repo-123").
        Return(&models.Repo{ID: "repo-123"}, nil)

    svc := NewRepoService(mockRepo)

    repo, err := svc.GetRepo(context.Background(), "repo-123")

    assert.NoError(t, err)
    assert.Equal(t, "repo-123", repo.ID)
}

1.6 Maintainability и extensibility

Что такое maintainability

Лёгкость поддержки системы:

  • Понятность кода
  • Лёгкость изменений
  • Безопасность рефакторинга
  • Скорость онбординга новых разработчиков

Принципы maintainability

  1. Читаемость важнее cleverness
go
// ПЛОХО: clever but unclear
result := process(filter(map(items, f), p))

// ПРАВИЛЬНО: понятно что происходит
mapped := make([]Result, 0, len(items))
for _, item := range items {
    mapped = append(mapped, f(item))
}

filtered := make([]Result, 0)
for _, m := range mapped {
    if p(m) {
        filtered = append(filtered, m)
    }
}
  1. Явность лучше неявности
go
// ПЛОХО: магические значения
if status == 3 { ... }

// ПРАВИЛЬНО: именованные константы
const StatusCompleted = 3
if status == StatusCompleted { ... }
  1. Маленькие функции и файлы
  • Функция 20-50 строк. Файл 200-400 строк.
  • Если больше — подумайте о разделении.
  1. Консистентность

Одинаковые паттерны во всём проекте:

  • Именование: GetRepo, CreateRepo, UpdateRepo
  • Структура: handler -> usecase -> service -> repository
  • Обработка ошибок: оборачивание с контекстом

Что такое extensibility

Лёгкость добавления новой функциональности без изменения существующей.

Open/Closed Principle

Код открыт для расширения, закрыт для изменения.

go
// ПЛОХО: изменение при добавлении типа
func Process(item Item) {
    switch item.Type {
    case "email":
        sendEmail(item)
    case "sms":
        sendSMS(item)
    // Добавление нового типа требует изменения функции
    }
}

// ПРАВИЛЬНО: расширение без изменения
type Processor interface {
    Process(item Item) error
}

type EmailProcessor struct{}
func (p *EmailProcessor) Process(item Item) error { ... }

type SMSProcessor struct{}
func (p *SMSProcessor) Process(item Item) error { ... }

// Новый тип: добавляем новый Processor без изменения существующего кода

Стратегии расширяемости

  1. Интерфейсы для вариативного поведения
go
type Notifier interface {
    Notify(ctx context.Context, event Event) error
}
  1. Plugin architecture
go
type Handler func(ctx context.Context, req Request) (Response, error)
func RegisterHandler(operation string, h Handler) { ... }
  1. Event-driven расширение
go
// Подписчики реагируют на события без изменения издателя
eventBus.Subscribe("order.placed", sendConfirmationEmail)
eventBus.Subscribe("order.placed", updateInventory)

Технический долг

Осознанные компромиссы ради скорости. Фиксируйте:

  • Что взяли в долг
  • Почему
  • Когда вернём
go
// TODO(tech-debt): упростить после миграции на v2 API
// Причина: дедлайн релиза 2026-03-01

1.7 Multi-tenancy

Что такое multi-tenancy

Одно приложение обслуживает нескольких клиентов (tenants). Каждый tenant имеет изолированные данные.

Модели изоляции

МодельИзоляцияСтоимостьКогда
Separate databaseПолнаяВысокаяEnterprise, строгие требования
Shared DB, separate schemaСредняяСредняяСредние клиенты
Shared DB, shared schema + tenant_idМинимальнаяНизкаяSaaS, много клиентов

Shared schema с tenant_id

Наиболее распространённая модель для SaaS:

sql
CREATE TABLE repos (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    name VARCHAR(255) NOT NULL,
    owner_id UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_repos_tenant ON repos(tenant_id);

Tenant context

Tenant ID передаётся через context:

go
type contextKey string
const TenantIDKey contextKey = "tenant_id"

func TenantMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tenantID := extractTenantID(r)  // из токена или заголовка
        ctx := context.WithValue(r.Context(), TenantIDKey, tenantID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func GetTenantID(ctx context.Context) string {
    if id, ok := ctx.Value(TenantIDKey).(string); ok {
        return id
    }
    return ""
}

Репозиторий с tenant isolation

go
func (r *RepoRepository) List(ctx context.Context, filter RepoFilter) ([]*models.Repo, error) {
    tenantID := GetTenantID(ctx)
    if tenantID == "" {
        return nil, ErrTenantRequired
    }


    query := "SELECT ... FROM repos WHERE tenant_id = $1 AND owner_id = $2"
    rows, err := r.pool.Query(ctx, query, tenantID, filter.OwnerID)
    // ...
}

Правила multi-tenancy

  1. Всегда фильтруйте по tenant_id в запросах
  2. Проверяйте tenant_id при создании записей
  3. Не логируйте данные других tenants
  4. Изолируйте кэш по tenant_id
  5. Применяйте rate limiting per tenant
  6. Аудит действий с tenant_id

Привязка к шаблону проекта

Архитектура шаблона

Шаблон проекта использует Clean Architecture с элементами DDD:

internal/
├── models/          # Entities и Value Objects
├── repository/      # Repository интерфейсы
├── service/         # Domain Services
├── usecase/         # Application Services (Use Cases)
├── adapters/        # Infrastructure implementations
├── handlers/        # Transport layer
└── app/             # Composition root (DI)

Границы в шаблоне

Шаблон — модульный монолит с чёткими границами:

  • Repo domain: models/repo.go, service/repo_service.go
  • User domain: models/user.go, service/user_service.go
  • PullRequest domain: models/pull_request.go, service/pull_request_service.go
  • Outbox: models/outbox_event.go, service/outbox_service.go

Задание: проанализируйте шаблон

  1. Определите доменные области
  2. Найдите aggregate roots
  3. Определите границы модулей
  4. Проверьте Dependency Rule
  5. Оцените maintainability

💱 Компромиссы

  • Modular monolith vs microservices: один деплой и локальные транзакции, но вертикальное масштабирование и общий риск; микросервисы дают изоляцию сбоев и независимый деплой, однако платят сетевыми вызовами, Saga и высокой операционной сложностью.
  • Тактический DDD vs простой CRUD: Entity, Aggregate и Repository точно моделируют сложный домен с инвариантами, но платят слоями и маппингом; для прототипов и утилит DDD избыточен — анемичная модель дешевле.
  • Ручной DI vs wire/fx: конструкторы NewRepoService(...) явные и компилируются, но composition root растёт с проектом; фреймворки собирают граф зависимостей сами, однако добавляют «магию» и зависимости.
  • Event-driven расширение vs прямые вызовы: подписчики на order.placed добавляются без правки издателя (Open/Closed), но поток выполнения неочевиден и труднее отлаживать; явные вызовы наглядны, однако каждое расширение требует менять существующий код.
  • Separate database vs shared schema с tenant_id: своя БД на tenant даёт полную изоляцию для enterprise, но дорога в эксплуатации; shared schema с tenant_id дешевле для SaaS, однако каждый запрос обязан фильтровать по tenant_id — одна ошибка смешает данные клиентов.

Практическое задание

Задание 1: Определение границ

Для e-commerce платформы определите:

  • Доменные области
  • Границы сервисов
  • Владельцев данных
  • API между сервисами

Задание 2: Modular Monolith

Спроектируйте структуру modular monolith для e-commerce:

myapp/
├── modules/
│   ├── catalog/
│   ├── cart/
│   ├── orders/
│   └── payments/
└── shared/

Определите публичные интерфейсы каждого модуля.

Задание 3: DDD patterns

Реализуйте тактические паттерны для Order domain:

  • Entity: Order с методами
  • Value Object: Money
  • Aggregate: Order + OrderItems
  • Domain Service: PricingService
  • Repository: OrderRepository interface
  • Domain Event: OrderPlaced

Задание 4: Dependency Inversion

Рефакторите код с жёсткими зависимостями:

go
// Было: сервис знает о конкретной БД
type OrderService struct {
    db *sql.DB
}

// Стало: сервис зависит от интерфейса
type OrderService struct {
    repo OrderRepository
}

Добавьте DI через конструкторы. Напишите тесты с mock.

Задание 5: Extensibility

Спроектируйте расширяемую систему уведомлений:

go
type Notifier interface {
    Notify(ctx context.Context, event Event) error
}

Реализуйте:

  • EmailNotifier
  • SMSNotifier
  • PushNotifier

Добавьте новый тип уведомлений без изменения существующего кода.

Задание 6: Multi-tenancy

Добавьте tenant isolation в существующий сервис:

  • TenantMiddleware для извлечения tenant_id
  • Tenant-aware репозитории
  • Tenant-aware кэш
  • Rate limiting per tenant

Задание 7: ADR

Напишите ADR для выбора архитектуры вашего проекта:

# ADR-001: Выбор Modular Monolith

## Статус
Accepted

## Контекст
- Размер команды: 5 человек
- Домен: e-commerce
- Требования: быстрый time-to-market

## Решение
Используем Modular Monolith потому что...

## Альтернативы
- Microservices: избыточно для 5 человек
- Flat structure: не масштабируется

## Последствия
- Простота деплоя
- Локальные транзакции
- Можно эволюционировать в микросервисы

Quiz "Проверь себя"

1. Что означает Open/Closed Principle?
2. Что такое bounded context в DDD?
3. Почему не стоит начинать проект с микросервисов?
4. Чем Value Object отличается от Entity?
5. Когда переходить от modular monolith к микросервисам?
6. Какой объект здесь aggregate root?
go
type Order struct {
    ID     string
    Items  []OrderItem
    Total  Money
    Status OrderStatus
}
7. Как обеспечить эволюционируемость архитектуры? — выберите все подходящие
8. Какие модели изоляции существуют в multi-tenancy? — выберите все подходящие
9. Почему интерфейс должен определять потребитель?
go
// package repository (потребитель)
type RepoRepository interface {
    GetByID(ctx context.Context, id string) (*models.Repo, error)
}

✅ Итоги модуля

Студент должен:

  • Определять границы сервисов по доменным областям
  • Осознанно выбирать между modular monolith и microservices
  • Применять тактические паттерны DDD (Entity, VO, Aggregate, Repository)
  • Использовать Dependency Inversion через интерфейсы и DI
  • Проектировать расширяемые системы (Open/Closed Principle)
  • Понимать модели multi-tenancy
  • Писать ADR для архитектурных решений
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 2

Следующий модуль: Модуль 2. Advanced API Design - contract-first development, OpenAPI-driven development, backward compatibility, pagination/filtering/sorting, rate limiting and quotas, API lifecycle management. Привязка к api/openapi.yaml шаблона.