Цели модуля
После модуля студент сможет:
- Определять границы сервисов по доменным областям
- Осознанно выбирать между 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 Service boundaries
Что такое границы сервиса
Граница сервиса определяет:
- Какие данные принадлежат сервису
- Какие операции сервис выполняет
- Какой API сервис предоставляет
- За что сервис отвечает
Определение границ по домену
Границы сервисов должны совпадать с доменными областями:
E-commerce платформа:
├── Catalog Service # товары, категории
├── Cart Service # корзина
├── Order Service # заказы
├── Payment Service # платежи
├── Inventory Service # остатки
└── Notification Service # уведомления
Каждый сервис владеет своей доменной областью.
Признаки плохих границ
- Distributed monolith: сервисы сильно связаны, нужно деплоить вместе
- Chatty services: много сетевых вызовов между сервисами
- Shared database: несколько сервисов пишут в одну таблицу
- Business logic across services: правила размазаны по сервисам
Правила границ
- Один сервис — одна доменная область
- Сервис владеет своими данными (своя БД или схема)
- Взаимодействие через API или события
- Изменение одного сервиса не требует изменения других
- Сервис можно задеплоить независимо
Эволюция границ
Границы могут меняться со временем:
Шаг 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:
- Модули не импортируют внутренние пакеты друг друга
- Взаимодействие через публичные интерфейсы модуля
- Каждый модуль имеет свою таблицу в БД
- Shared-код минимален и стабилен
Microservices
Каждый сервис — отдельное приложение со своей БД.
catalog-service/
├── cmd/app/main.go
├── internal/...
└── go.mod
orders-service/
├── cmd/app/main.go
├── internal/...
└── go.mod
Взаимодействие через сеть: HTTP, gRPC, очередь сообщений.
Сравнение
| Критерий | Modular Monolith | Microservices |
|---|---|---|
| Деплой | Один | Независимый |
| Масштабирование | Вертикальное | Горизонтальное 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
Сущность с идентификатором и жизненным циклом.
gotype 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
Объект без идентификатора, определяется значениями полей.
gotype 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 — точка доступа к агрегату.
gotype 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
Операция не принадлежащая ни одной сущности.
gotype 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
Абстракция доступа к агрегатам.
gotype 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
Факт произошедший в домене.
gotype OrderPlaced struct { OrderID string CustomerID string Total Money OccurredAt time.Time }
Domain events используются для:
- Декомпозиции сервисов
- Аудита
- Интеграции
- Асинхронной обработки
Применение DDD в Go
Go не имеет классов и наследования, но DDD паттерны применимы:
| Паттерн DDD | Реализация в Go |
|---|---|
| Entity | struct с методами |
| Value Object | struct, immutable через конструктор |
| Aggregate | struct с вложенными объектами |
| Domain Service | struct с методами, зависит от репозиториев |
| Repository | interface + реализация |
| Domain Event | struct, публикуется через 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 через конструкторы:
gofunc 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
gofunc 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
- Читаемость важнее 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) } }
- Явность лучше неявности
go// ПЛОХО: магические значения if status == 3 { ... } // ПРАВИЛЬНО: именованные константы const StatusCompleted = 3 if status == StatusCompleted { ... }
- Маленькие функции и файлы
- Функция 20-50 строк. Файл 200-400 строк.
- Если больше — подумайте о разделении.
- Консистентность
Одинаковые паттерны во всём проекте:
- Именование: 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 без изменения существующего кода
Стратегии расширяемости
- Интерфейсы для вариативного поведения
gotype Notifier interface { Notify(ctx context.Context, event Event) error }
- Plugin architecture
gotype Handler func(ctx context.Context, req Request) (Response, error) func RegisterHandler(operation string, h Handler) { ... }
- 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:
sqlCREATE 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:
gotype 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
gofunc (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
- Всегда фильтруйте по tenant_id в запросах
- Проверяйте tenant_id при создании записей
- Не логируйте данные других tenants
- Изолируйте кэш по tenant_id
- Применяйте rate limiting per tenant
- Аудит действий с 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
Задание: проанализируйте шаблон
- Определите доменные области
- Найдите aggregate roots
- Определите границы модулей
- Проверьте Dependency Rule
- Оцените 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
Спроектируйте расширяемую систему уведомлений:
gotype 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 "Проверь себя"
type Order struct {
ID string
Items []OrderItem
Total Money
Status OrderStatus
}// 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 шаблона.