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 можно выделить четыре основных контекста:

КонтекстКлючевые понятияЗа что отвечает
LearningCourse, Lesson, Progress, QuizПрохождение уроков, оценка знаний
IdentityUser, Session, OAuthProviderРегистрация, аутентификация
ContentTrack, Markdown, Slug, TagХранение и валидация контента
AnalyticsEvent, PageView, MetricСбор статистики использования

В контексте Learning слово User - это "ученик с прогрессом". В контексте Identity тот же User - "учётная запись с email и OAuth-токенами". Разные модели, разные правила, разные пакеты.

Карта контекстов BackendStart: Learning, Identity, Content, Analytics. ACL между Learning и Identity, события связывают Content и Analytics

Структура пакетов в 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/
Если `learning/domain/` импортирует `billing/domain/`, граница нарушена. Контексты общаются только через явные интеграционные механизмы: события, API или ACL-адаптеры.

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 переводит внешнюю модель во внутреннюю:

ACL переводит RawLesson из контекста Content в LessonMaterial контекста Learning - чужая модель не протекает внутрь

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
Если в shared-пакете больше 5-10 типов - вероятно, контексты недостаточно разделены. Shared Kernel - это компромисс, а не архитектурная цель.

Для BackendStart монолит с пакетным разделением - оптимальный выбор. Один сервер, четыре контекста в разных пакетах, Shared Kernel с UserID и Timestamp. Микросервисы здесь не нужны - команда маленькая, трафик умеренный.

Мини-задание

  • Выдели 3-4 Bounded Context в своём проекте и запиши ключевые сущности каждого
  • Найди слово, которое означает разное в двух контекстах (аналог "Course")
  • Определи тип отношений между контекстами (Customer-Supplier, Conformist и т.д.)
  • Напиши интерфейс Anti-Corruption Layer для одной интеграции между контекстами

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.