Bounded Context: границы предметных областей
Bounded Context: границы предметных областей
Представь: в команде фронтендер говорит "курс" и имеет в виду страницу с уроками. Маркетолог говорит "курс" и подразумевает лендинг с ценой и отзывами. Бухгалтер слышит "курс" и думает о валютном курсе. Одно слово - три разных значения.
Когда все три смысла попадают в одну структуру Course, получается монстр с полями Price, ExchangeRate, Lessons, LandingURL, VATPercent. Любое изменение в биллинге ломает обучение. Любой рефакторинг обучения задевает маркетинг.
Bounded Context решает эту проблему: каждый контекст - это лингвистическая граница, внутри которой слова имеют ровно один смысл.
Что такое Bounded Context
Bounded Context (ограниченный контекст) - это явная граница, внутри которой доменная модель непротиворечива. Один и тот же термин в разных контекстах означает разные вещи и представлен разными структурами.
// Контекст Learning - курс как набор уроков
package learning
type Course struct {
ID CourseID
Title string
Slug CourseSlug
Lessons []Lesson
Description string
}
// Контекст Billing - курс как продукт с ценой
package billing
type Course struct {
ID CourseID
Name string
PriceRub int
IsActive bool
TrialDays int
}
<?php
declare(strict_types=1);
// Контекст Learning - курс как набор уроков
namespace App\Learning\Domain;
final class Course
{
/** @param Lesson[] $lessons */
public function __construct(
public readonly CourseId $id,
public readonly string $title,
public readonly CourseSlug $slug,
public readonly array $lessons,
public readonly string $description,
) {}
}
// Контекст Billing - курс как продукт с ценой
namespace App\Billing\Domain;
final class Course
{
public function __construct(
public readonly CourseId $id,
public readonly string $name,
public readonly int $priceRub,
public readonly bool $isActive,
public readonly int $trialDays,
) {}
}
Обрати внимание: оба контекста используют слово "Course", но внутри у них совершенно разные поля и поведение. Это не дублирование - это намеренное разделение ответственности.
Контексты BackendStart
На платформе BackendStart можно выделить четыре основных контекста:
| Контекст | Ключевые понятия | За что отвечает |
|---|---|---|
| Learning | Course, Lesson, Progress, Quiz | Прохождение уроков, оценка знаний |
| Identity | User, Session, OAuthProvider | Регистрация, аутентификация |
| Content | Track, Markdown, Slug, Tag | Хранение и валидация контента |
| Analytics | Event, PageView, Metric | Сбор статистики использования |
В контексте Learning слово User - это "ученик с прогрессом". В контексте Identity тот же User - "учётная запись с email и OAuth-токенами". Разные модели, разные правила, разные пакеты.
Структура пакетов в Go
Каждый Bounded Context живёт в своём пакете. Внутри пакета - слои по гексагональной архитектуре:
internal/
learning/
domain/ # Entity, VO, Aggregate, Repository interface
app/ # Use-cases (Application Services)
infra/ # GORM-репозиторий, HTTP-адаптеры
identity/
domain/
app/
infra/
content/
domain/
app/
infra/
analytics/
domain/
app/
infra/
Context Map: отношения между контекстами
Контексты не существуют изолированно. Между ними есть отношения, и тип отношений определяет, как они интегрируются.
Customer-Supplier (Заказчик-Поставщик)
Один контекст зависит от другого, и поставщик учитывает потребности заказчика. Пример: Content (поставщик) предоставляет уроки для Learning (заказчик). Команда Content планирует API с учётом того, что нужно Learning.
Conformist (Конформист)
Зависимый контекст принимает модель поставщика "как есть", без влияния на неё. Пример: интеграция с внешним OAuth-провайдером - GitHub диктует формат токенов, и Identity подстраивается.
Separate Ways (Каждый сам по себе)
Контексты не интегрируются вовсе. Пример: Analytics не зависит от Identity напрямую - аналитика работает с анонимными идентификаторами.
Anti-Corruption Layer (Антикоррупционный слой)
Защитный адаптер, который переводит внешнюю модель в термины своего контекста. Это самый важный паттерн для защиты границ.
Anti-Corruption Layer на практике
Когда контекст Learning получает данные из Content, он не должен работать с чужими структурами напрямую. ACL переводит внешнюю модель во внутреннюю:
package learning
// Внутренняя модель контекста Learning
type LessonMaterial struct {
LessonID LessonID
Title string
ContentHTML string
HasQuiz bool
}
// Порт: что нужно контексту Learning от внешнего мира
type ContentProvider interface {
GetLessonMaterial(ctx context.Context, slug string) (LessonMaterial, error)
}
<?php
declare(strict_types=1);
namespace App\Learning\Domain;
// Внутренняя модель контекста Learning
final readonly class LessonMaterial
{
public function __construct(
public LessonId $lessonId,
public string $title,
public string $contentHtml,
public bool $hasQuiz,
) {}
}
// Порт: что нужно контексту Learning от внешнего мира.
// final НЕ ставится - это интерфейс.
interface ContentProvider
{
public function getLessonMaterial(string $slug): LessonMaterial;
}
package infra
// ACL-адаптер: переводит модель Content → модель Learning
type ContentACLAdapter struct {
contentAPI contentclient.Client
}
func (a *ContentACLAdapter) GetLessonMaterial(
ctx context.Context, slug string,
) (learning.LessonMaterial, error) {
// Получаем данные в формате контекста Content
raw, err := a.contentAPI.FetchLesson(ctx, slug)
if err != nil {
return learning.LessonMaterial{}, fmt.Errorf("content api: %w", err)
}
// Переводим в термины контекста Learning
return learning.LessonMaterial{
LessonID: learning.LessonID(raw.ID),
Title: raw.Title,
ContentHTML: raw.RenderedBody,
HasQuiz: raw.QuizFileExists,
}, nil
}
<?php
declare(strict_types=1);
namespace App\Learning\Infrastructure;
use App\Content\Api\ContentClient;
use App\Learning\Domain\ContentProvider;
use App\Learning\Domain\LessonId;
use App\Learning\Domain\LessonMaterial;
// ACL-адаптер: переводит модель Content -> модель Learning
final class ContentAclAdapter implements ContentProvider
{
public function __construct(
private readonly ContentClient $contentApi,
) {}
public function getLessonMaterial(string $slug): LessonMaterial
{
// Получаем данные в формате контекста Content
$raw = $this->contentApi->fetchLesson($slug);
// Переводим в термины контекста Learning
return new LessonMaterial(
lessonId: new LessonId($raw->id),
title: $raw->title,
contentHtml: $raw->renderedBody,
hasQuiz: $raw->quizFileExists,
);
}
}
Если формат данных в Content изменится, правки затронут только ACL-адаптер. Домен Learning останется нетронутым.
Интеграция между контекстами
Два основных способа связать контексты, не нарушая границ:
Через доменные события
Предпочтительный способ для асинхронных сценариев - через event-driven архитектуру. Learning публикует событие - Analytics его обрабатывает:
// Learning публикует факт
event := LessonCompleted{
UserID: userID,
LessonID: lessonID,
At: time.Now(),
}
publisher.Publish(ctx, event)
// Analytics слушает и обрабатывает в своих терминах
func (h *AnalyticsHandler) OnLessonCompleted(e learning.LessonCompleted) {
h.metrics.RecordProgress(e.UserID, e.LessonID, e.At)
}
// Learning публикует факт
$event = new LessonCompleted(
userId: $userId,
lessonId: $lessonId,
at: new \DateTimeImmutable(),
);
$publisher->publish($event);
// Analytics слушает и обрабатывает в своих терминах
final class AnalyticsHandler
{
public function __construct(
private readonly MetricsRecorder $metrics,
) {}
public function onLessonCompleted(LessonCompleted $e): void
{
$this->metrics->recordProgress($e->userId, $e->lessonId, $e->at);
}
}
Через синхронный API
Когда нужен немедленный ответ. Learning запрашивает данные у Identity через интерфейс:
// Интерфейс в контексте Learning
type UserInfoProvider interface {
GetDisplayName(ctx context.Context, userID int64) (string, error)
}
// Реализация вызывает Identity-контекст
type IdentityAdapter struct {
identitySvc identity.UserService
}
func (a *IdentityAdapter) GetDisplayName(
ctx context.Context, userID int64,
) (string, error) {
user, err := a.identitySvc.FindByID(ctx, userID)
if err != nil {
return "", err
}
return user.DisplayName, nil
}
<?php
declare(strict_types=1);
// Интерфейс в контексте Learning
namespace App\Learning\Domain;
interface UserInfoProvider
{
public function getDisplayName(int $userId): string;
}
// Реализация вызывает Identity-контекст
namespace App\Learning\Infrastructure;
use App\Identity\Domain\UserService;
use App\Learning\Domain\UserInfoProvider;
final class IdentityAdapter implements UserInfoProvider
{
public function __construct(
private readonly UserService $identitySvc,
) {}
public function getDisplayName(int $userId): string
{
$user = $this->identitySvc->findById($userId);
return $user->displayName;
}
}
Монолит и Shared Kernel
Не каждый проект требует микросервисов. В монолите контексты живут в одном бинарнике, но в разных пакетах. Это даёт разделение на уровне компиляции без сетевых издержек.
Shared Kernel - небольшой набор общих типов, которые используют несколько контекстов. Обычно это базовые Value Objects:
package shared
type UserID int64
type Timestamp time.Time
Для BackendStart монолит с пакетным разделением - оптимальный выбор. Один сервер, четыре контекста в разных пакетах, Shared Kernel с UserID и Timestamp. Микросервисы здесь не нужны - команда маленькая, трафик умеренный.
Мини-задание
- Выдели 3-4 Bounded Context в своём проекте и запиши ключевые сущности каждого
- Найди слово, которое означает разное в двух контекстах (аналог "Course")
- Определи тип отношений между контекстами (Customer-Supplier, Conformist и т.д.)
- Напиши интерфейс Anti-Corruption Layer для одной интеграции между контекстами