Domain-Driven Design Ограниченные контексты и карта контекстов
0%

Ограниченные контексты и карта контекстов

Ограниченные контексты и карта контекстов

В прошлой статье мы разобрали домен, поддомены и единый язык. Осталась неудобная деталь: единый язык не бывает единым на всю компанию. Стоит собрать всех — продажи, склад, бухгалтерию, поддержку — и попросить дать одно определение слова «заказ», как переговоры затянутся на месяцы и закончатся определением, которое не нужно никому.

Ограниченный контекст (bounded context) — ответ Эванса на эту проблему, и ответ дерзкий: перестаньте договариваться о едином определении; договоритесь о границе, внутри которой определение одно, и о правилах перевода на границе. Это самая практически ценная идея во всём DDD: тактические блоки можно не применять и жить нормально, а проигнорировать границы контекстов — значит гарантированно получить систему, где изменение цены в каталоге ломает отчёт по НДС.


1. Интуиция: почему одна модель на всех не работает

Мысленный эксперимент со словом «клиент»

Спросите три отдела, что такое клиент.

  • Продажи: «тот, кому мы продаём» — сегмент, потенциал сделки, закреплённый менеджер, стадия воронки. Клиент существует ещё до того, как что-то купил.
  • Биллинг: «тот, кто платит» — реквизиты, ИНН, кредитный лимит, юрисдикция, баланс. Клиент без платёжных данных — бессмысленная запись.
  • Поддержка: «тот, кто обратился» — уровень SLA, каналы связи, история тикетов, язык общения. Здесь клиентом может быть сотрудник клиента, не подписывавший договор.

Ни одно из определений не «правильнее» других: все три верны в своём контексте и несовместимы вне его.

Одно слово «Клиент» — три разные модели в трёх контекстах

Что происходит, если попытаться объединить

Появляется класс Customer на 80 полей, где 60 всегда null. Дальше по нарастающей:

# Симптом отсутствия границ: один класс обслуживает три реальности
class Customer:
    id: int
    sales_segment: str | None          # продажи
    account_manager_id: int | None     # продажи
    vat_number: str | None             # биллинг
    credit_limit: Decimal | None       # биллинг
    sla_tier: str | None               # поддержка
    preferred_language: str | None     # поддержка

    def is_valid(self) -> bool:
        # А что здесь писать? Валидность зависит от того, КТО спрашивает.
        # Для биллинга обязателен vat_number, для поддержки — нет.
        # Любой ответ будет неверным для двух из трёх потребителей.
        ...

Метод is_valid() невозможно написать корректно — и это не проблема кода, а индикатор того, что в одном классе живут разные инварианты. Формально: если k контекстов делят одну модель с n правилами каждый, поверхность регрессии при изменении — O(k · n), а число согласований на одно изменение — O(k) команд. Разрезав модель на k независимых, получаем O(n) на изменение внутри контекста и O(1) согласований — ценой явной трансляции на границах. Вся стратегия ниже про то, как сделать цену трансляции меньше цены согласований.


2. Строгое определение

Ограниченный контекст — это явная граница (кодовая, командная и лингвистическая), внутри которой конкретная модель домена определена, непротиворечива и применима; за её пределами модель не имеет силы, а термины требуют перевода.

Разберём определение по частям — каждое слово нагружено.

Часть определения Что означает практически Как проверить
явная Граница видна в коде: отдельный модуль/пакет/сервис, отдельная схема БД Можно показать пальцем на каталог и сказать «вот граница»
модель определена Каждый термин имеет ровно одно значение Спросите двух разработчиков, что такое «отгрузка» — ответы совпадут
непротиворечива Инварианты внутри не конфликтуют Нет полей, обязательных для одного сценария и запрещённых для другого
не имеет силы вне Классы контекста не импортируются другими контекстами Статический анализ импортов зелёный
термины требуют перевода Есть явный слой трансляции на границе Есть код, который называется маппером/ACL, а не «просто DTO скопировали»

Контекст ≠ поддомен ≠ микросервис ≠ модуль

Три самых частых путаницы в DDD, и стоят они дорого.

Ключевые тезисы, видные из схемы:

  • Поддомен — из пространства задачи, контекст — из пространства решения. Поддомен дан бизнесом, контекст вы проектируете. Отображение 1:1 — хорошая цель, но 1:N (поддомен разрезан на два контекста из-за разных темпов изменений) встречается постоянно.
  • Контекст ≠ единица деплоя. Два контекста могут жить в одном процессе как модули («модульный монолит»), и это часто правильнее двух сервисов. Обратное — один контекст, размазанный по трём сервисам, — почти всегда ошибка: транзакционная граница агрегата окажется распределённой.
  • Контекст — единица владения. Одна команда может владеть несколькими контекстами; контекст с двумя владельцами — источник вечного конфликта (либо сознательное Partnership).

Правило: микросервис — способ деплоя контекста, а не сам контекст. Разрезав систему на сервисы, не определив контексты, вы получаете распределённый монолит — сетевые задержки микросервисов при связности монолита (эту тему подробно разбирает трек «Архитектурные паттерны»).


3. Как найти границы: практические эвристики

Границы не выводятся из схемы БД. Они выводятся из наблюдений за языком и за людьми. Вот работающий чек-лист, отсортированный по силе сигнала.

Расшифровка сигналов:

  1. Лингвистическая двусмысленность (сильнейший сигнал). Как только вы слышите «ну это смотря какой заказ» или видите в коде OrderType, is_internal, mode='B2B' — там граница. Атрибут-переключатель, меняющий смысл половины полей, — это два контекста, склеенных скотчем.
  2. Разные владельцы решений. Правила скидок утверждает коммерческий директор, правила налогов — главбух, и они никогда не встречаются: это два контекста. Закон Конвея работает в обе стороны — границы кода тяготеют к границам коммуникации (Conway, 1968; «Inverse Conway Maneuver» у Skelton & Pais).
  3. Разный темп изменений. Промо-акции переписывают каждый спринт, бухучёт — раз в несколько лет. Склеив их, вы обрекаете бухгалтерский код на еженедельную регрессию.
  4. Разные нефункциональные требования. Корзина должна быть доступна всегда и может быть слегка неконсистентной; проводка по счёту — наоборот. Разные точки на CAP-компромиссе плохо уживаются в одной модели.
  5. Отдельная транзакционная граница. Атомарная операция целиком лежит внутри одного контекста — это ограничение снизу: контекст не может быть меньше самой крупной нужной ему транзакции.

Обратный тест: когда границу проводить НЕ надо

  • Контекст из одного CRUD без собственных правил — это не контекст, а таблица; оставьте модулем.
  • Если между «контекстами» нужны десятки синхронных вызовов на один сценарий, граница проведена поперёк естественного шва — сшивайте обратно.
  • Если команда из трёх человек владеет семью контекстами и семью базами, вы платите за границы больше, чем получаете. Правило Ньюмана из «Building Microservices»: начинайте с модульного монолита и режьте только по доказанным швам.

4. Карта контекстов: девять паттернов отношений

Карта контекстов (context map) — это не диаграмма развёртывания и не схема API. Это карта политических и организационных отношений между командами, выраженная через код. Главный вопрос каждой связи: кто вынужден подстраиваться, когда другая сторона меняется?

Базовая нотация: U (upstream) — тот, кто влияет; D (downstream) — тот, кто подстраивается. Стрелка идёт от U к D.

Карта контекстов с нотацией U/D, ACL, OHS/PL

Разберём каждый — с критерием применимости и ценой.

4.1 Partnership (партнёрство)

Две команды взлетают и падают вместе: провал интеграции означает провал обеих. Координация релизов, общие интеграционные тесты, совместное планирование. Когда: два ядровых контекста, которые физически нельзя развивать независимо (заказы и доставка в логистике). Цена: связность процессов — каждая крупная фича требует согласования двух бэклогов. Симптом деградации: партнёрство «на бумаге», когда одна команда фактически диктует — переименуйте в Customer–Supplier и живите честно.

4.2 Shared Kernel (общее ядро)

Явно выделенное подмножество модели (типы, схема, код), разделяемое двумя и более контекстами; менять можно только по согласию всех владельцев, с прогоном тестов всех потребителей. Что кладут: Money, Currency, TimeRange, идентификаторы, базовые ошибки — обобщённые примитивы без бизнес-правил конкретного контекста. Чего класть нельзя: сущности с поведением, статусы бизнес-процессов, DTO API. Цена: SK — точка максимальной связности на карте. Если он растёт каждый спринт, вы вернулись к общей модели.

4.3 Customer–Supplier (заказчик–поставщик)

Upstream признаёт потребности downstream и берёт их в свой бэклог: есть переговоры, приоритеты, сроки; downstream участвует в приёмочном тестировании upstream. Когда: обе команды внутри одной организации и есть кто-то, способный разрешить конфликт приоритетов. Цена: upstream тратит часть ёмкости на чужие требования.

4.4 Conformist (конформист)

Downstream принимает модель upstream как свою, без трансляции: отказ от собственной модели в обмен на нулевую цену интеграции. Когда: модель upstream приемлема, домен для вас неядровой, ресурсов на ACL нет (интеграция с платёжным провайдером в маленьком сервисе). Цена: любое изменение upstream протекает прямо в вашу модель. Никогда не применяйте Conformist в ядровом поддомене — это отдаёт наружу главный актив компании.

4.5 Anticorruption Layer (ACL, предохранительный слой)

Downstream строит слой перевода: чужая модель конвертируется в термины собственного языка, и внутрь контекста чужие типы не попадают вообще. Когда: чужая модель плоха, легаси, нестабильна или просто чужая — а ваш контекст ядровой. Цена: код маппинга и лишний слой при отладке. Выгода: изменение upstream локализуется в одном файле. ACL — самый частый и самый недооценённый паттерн; практически всё постепенное вытеснение легаси строится на нём (Fowler, «StranglerFigApplication»).

4.6 Open Host Service + 4.7 Published Language

OHS: upstream публикует общий версионированный протокол для всех потребителей вместо частного интерфейса на каждого. Применяют, когда потребителей больше 2–3; цена — обязательство держать обратную совместимость и депрекейт-политику.

PL: хорошо описанный формат обмена — JSON Schema, Avro/Protobuf, OpenAPI или отраслевой стандарт (ISO 20022 в финтехе, HL7/FHIR в медицине). Обычно идёт в паре с OHS. Ключевое: PL — не внутренняя модель, выставленная наружу, а отдельный, намеренно более бедный и стабильный контракт. Если @Entity сериализуется прямо в ответ API — у вас не Published Language, а утечка модели.

4.8 Separate Ways + 4.9 Big Ball of Mud

Separate Ways: интеграции нет, каждый контекст решает задачу сам, даже ценой дублирования. Когда интеграция дороже копии: поддержке нужны только имя и email — проще хранить копию, чем звать сервис клиентов на каждый тикет. Цена — расхождение данных; осознанное дублирование не ошибка, а плата за автономию.

Big Ball of Mud: отсутствие границ. Отметьте эту зону на карте честно — как область, куда нельзя пускать новую модель (Foote & Yoder). Стратегия: обнести ACL и постепенно вытеснять.

Сводная таблица выбора

Ситуация Паттерн Почему
Мы ядровой контекст, upstream — легаси ACL Защитить главный актив
Мы неядровой, upstream хорош и стабилен Conformist Не платить за трансляцию
У нас 5+ потребителей OHS + Published Language Один контракт вместо пяти
Обе команды в одной компании, есть переговоры Customer–Supplier Приоритеты решаемы
Два ядровых контекста, судьба общая Partnership Иначе рассинхрон релизов
Нужны общие примитивы (Money, Id) Shared Kernel (маленький) Дешевле копипасты
Пересечение мизерное Separate Ways Дублирование дешевле связи

5. Код: две модели и ACL между ними

Покажем полный вертикальный срез. Контекст Заказы (ядровой, downstream) интегрируется с контекстом Каталог (upstream, OHS + Published Language).

5.1 Опубликованный язык upstream-контекста

# catalog/published_language.py — КОНТРАКТ, а не внутренняя модель каталога.
# Меняется только с увеличением версии; внутри каталога модель богаче и другая.
from dataclasses import dataclass

@dataclass(frozen=True)
class ProductSnapshotV1:
    """Минимально необходимая проекция товара для внешних потребителей."""
    sku: str
    display_name: str
    price_minor_units: int      # цена в минорных единицах — без float
    currency_code: str          # ISO 4217
    is_orderable: bool          # каталог сам решает, что значит «можно заказать»
    tax_category: str           # 'standard' | 'reduced' | 'zero'
    schema_version: str = "catalog.product.v1"

Обратите внимание: is_orderable — это решение каталога, выраженное одним булевым полем. Каталог не выставляет наружу stock_status, is_archived, visibility_rules, из которых это решение складывается. Иначе каждый потребитель начнёт вычислять «заказуемость» сам, и правило размножится по системе — ровно та болезнь, которую лечит DDD.

5.2 Модель downstream-контекста

# orders/domain/model.py — собственный язык контекста «Заказы».
# Здесь нет ни слова «product», ни «sku» из чужой терминологии — только наши термины.
from dataclasses import dataclass
from decimal import Decimal

@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str


@dataclass(frozen=True)
class OrderableItem:
    """То, что заказы знают о товаре. Ровно столько, сколько нужно для их правил."""
    item_code: str          # наш термин вместо чужого 'sku'
    title: str
    unit_price: Money
    vat_rate: Decimal       # ставка, а не чужая строковая 'tax_category'


class Order:
    def __init__(self, order_id: str, currency: str) -> None:
        self._id, self._currency = order_id, currency
        self._lines: list[tuple[OrderableItem, int]] = []

    def add_line(self, item: OrderableItem, quantity: int) -> None:
        # Инвариант принадлежит контексту «Заказы» и проверяется здесь, а не в каталоге.
        if quantity <= 0:
            raise ValueError("Количество должно быть положительным")
        if item.unit_price.currency != self._currency:
            raise ValueError("Валюта позиции не совпадает с валютой заказа")
        self._lines.append((item, quantity))

5.3 Собственно ACL

# orders/infrastructure/catalog_acl.py
# Единственное место во всём контексте «Заказы», которое знает про формат каталога.
from decimal import Decimal
from typing import Protocol

from catalog.published_language import ProductSnapshotV1
from orders.domain.model import Money, OrderableItem


class ItemNotOrderable(Exception):
    """Доменная ошибка НАШЕГО контекста, а не чужой HTTP-код."""


class CatalogPort(Protocol):
    """Порт описан в терминах контекста «Заказы» — инверсия зависимости."""
    def fetch_orderable_item(self, item_code: str) -> OrderableItem: ...


# Таблица трансляции — знание, которое иначе расползлось бы по коду
_VAT_BY_CATEGORY = {"standard": Decimal("0.20"),
                    "reduced": Decimal("0.10"),
                    "zero": Decimal("0.00")}


class HttpCatalogAdapter:
    """Реализация порта: HTTP + трансляция чужой модели в нашу."""

    def __init__(self, client) -> None:
        self._client = client

    def fetch_orderable_item(self, item_code: str) -> OrderableItem:
        raw = self._client.get(f"/api/v1/products/{item_code}")
        return self._translate(ProductSnapshotV1(**raw))

    def _translate(self, s: ProductSnapshotV1) -> OrderableItem:
        # 1. Неизвестная версия схемы — авария интеграции, а не «вдруг распарсится».
        if s.schema_version != "catalog.product.v1":
            raise RuntimeError(f"Неподдерживаемая версия схемы: {s.schema_version}")
        # 2. Чужое решение переводим в НАШУ доменную ошибку.
        if not s.is_orderable:
            raise ItemNotOrderable(f"Товар {s.sku} недоступен для заказа")
        # 3. Чужая строковая категория → наша ставка. Незнакомая — падаем громко,
        #    а не подставляем 0: тихий ноль даст финансовую ошибку через квартал.
        try:
            vat = _VAT_BY_CATEGORY[s.tax_category]
        except KeyError as exc:
            raise RuntimeError(f"Неизвестная налоговая категория: {s.tax_category}") from exc
        # 4. Минорные единицы → Money, чужие имена полей → наши.
        return OrderableItem(
            item_code=s.sku,
            title=s.display_name,
            unit_price=Money(Decimal(s.price_minor_units) / 100, s.currency_code),
            vat_rate=vat,
        )

Что даёт конструкция: контекст «Заказы» тестируется без каталога (подставляем фейковый CatalogPort); переход каталога на v2 меняет один файл, домен не трогается; незнакомые значения приводят к громкому отказу, а не к тихой порче данных.

5.4 Поток вызова и деградация

Про fallback: кэш: выбор между «упасть» и «взять устаревшую копию» — доменное решение, а не техническое. Для цены устаревание на 5 минут недопустимо (продадим по старой цене), для названия — нормально. Поэтому решение принимается в ACL потребителя, знающего цену ошибки, а не в общем HTTP-клиенте.


6. Как контексты живут и мигрируют

Границы не проводятся один раз. Полезно смотреть на контекст как на объект с жизненным циклом.

Три наблюдения по этому графу:

  • Стрелка Модуль → Слияние обязана существовать. Гипотеза о границе бывает неверной, и откат — нормальный исход. Откатить модуль дёшево, сервис — дорого. Отсюда правило: сначала модуль, потом сервис, никогда наоборот.
  • Переход ОтдельнаяСхема — главный водораздел. Пока два контекста делают JOIN по таблицам друг друга, границы нет, что бы ни было написано в архитектурном документе. Общая база — это Shared Kernel размером со всю модель, только необъявленный.
  • Стабильный → Разделение случается, когда внутри появляется прилагательное: «внутренний заказ», «подписочный заказ». Прилагательное перед ключевым существительным — ранний признак того, что зреет новый контекст.

Порядок разрезания монолита

Рецепт, проверенный на многих миграциях (см. Newman, «Monolith to Microservices»):

  1. Логическая граница: пакет orders/, публичный orders/api.py, остальное приватно.
  2. Запрет импортов в CI — без автоматики граница разрушится за пару спринтов.
  3. Разделение данных: убрать JOIN через границу, заменив вызовом API или репликой. Самый трудоёмкий шаг — обычно 70% усилий миграции.
  4. Разделение транзакций: вместо одной транзакции — сага или eventual consistency (см. доменные события и интеграцию).
  5. Только теперь — отдельный процесс, если он даёт конкретное: независимый релизный цикл, отдельное масштабирование, изоляцию отказов.

Если шаги 1–4 сделаны, шаг 5 занимает дни. Если начать с шага 5, шаги 1–4 не будут сделаны никогда — их придётся делать по сети и под нагрузкой.


7. Как границы удерживают в проде

Граница, которую не проверяет автоматика, не существует. Ниже — минимальный набор.

7.1 Статическая проверка импортов

Для Python — import-linter:

# setup.cfg (или .importlinter)
[importlinter]
root_packages = orders catalog billing

[importlinter:contract:1]
name = Контексты не импортируют внутренности друг друга
type = forbidden
source_modules = orders.domain
                 orders.application
forbidden_modules = catalog
                    billing
# исключение ровно одно: ACL имеет право знать про опубликованный язык
ignore_imports = orders.infrastructure.catalog_acl -> catalog.published_language

[importlinter:contract:2]
name = Домен не зависит от инфраструктуры
type = forbidden
source_modules = orders.domain
forbidden_modules = orders.infrastructure
                    sqlalchemy
                    requests

Аналоги в других экосистемах: ArchUnit (Java/Kotlin), NetArchTest и .editorconfig-правила (C#), dependency-cruiser (TypeScript), internal/-пакеты и go vet в Go — про них подробнее в треке Go.

7.2 Контрактные тесты вместо сквозных

Сквозной тест «поднимем каталог, заказы и биллинг и прокликаем сценарий» долгий, хрупкий и не сообщает, кто сломал контракт. Контрактный тест проверяет только границу:

# tests/contract/test_catalog_acl.py
import pytest
from decimal import Decimal
from orders.infrastructure.catalog_acl import HttpCatalogAdapter, ItemNotOrderable


class FakeClient:
    """Отдаёт ровно тот JSON, который зафиксирован в контракте каталога."""
    def __init__(self, payload): self._payload = payload
    def get(self, _url): return self._payload


BASE = {"sku": "SKU-42", "display_name": "Кофе 1 кг", "price_minor_units": 129900,
        "currency_code": "RUB", "is_orderable": True, "tax_category": "reduced",
        "schema_version": "catalog.product.v1"}


def test_перевод_в_модель_заказов():
    item = HttpCatalogAdapter(FakeClient(BASE)).fetch_orderable_item("SKU-42")
    assert item.unit_price.amount == Decimal("1299.00")   # минорные единицы переведены
    assert item.vat_rate == Decimal("0.10")               # категория → ставка


def test_недоступный_товар_даёт_нашу_доменную_ошибку():
    with pytest.raises(ItemNotOrderable):
        client = FakeClient(BASE | {"is_orderable": False})
        HttpCatalogAdapter(client).fetch_orderable_item("SKU-42")


@pytest.mark.parametrize("broken", [
    {"schema_version": "catalog.product.v2"},   # upstream сменил версию
    {"tax_category": "luxury"},                 # upstream добавил категорию
])
def test_изменение_контракта_ломает_сборку_а_не_прод(broken):
    with pytest.raises(RuntimeError):
        HttpCatalogAdapter(FakeClient(BASE | broken)).fetch_orderable_item("SKU-42")

В проде это дополняется consumer-driven contract testing (Pact, Fowler о CDC): потребитель публикует свои ожидания, а CI поставщика падает, если новая версия их нарушает. Это превращает связь «U → D» с карты контекстов в исполняемую проверку.

7.3 Карта контекстов как живой артефакт

Держите карту в репозитории рядом с кодом (mermaid в docs/context-map.md), а не в презентации трёхлетней давности. На каждой связи подписывайте паттерн и владельца: Каталог --[OHS/PL, v1, owner: team-catalog]--> Заказы. Ревизия — раз в квартал и обязательно перед крупным разрезанием монолита. Если нравится формальность, есть Context Mapper — DSL для контекст-мапов с генерацией диаграмм.


8. Типичные ошибки

Ошибка Как выглядит Чем лечится
Контекст = микросервис Режут по сущностям: user-service, order-service, address-service Резать по языку и правилам, а не по таблицам
Общая БД у «разных» контекстов JOIN orders o, billing_invoices i Схема на контекст; кросс-границы только через API/события
Каноническая модель на всю компанию Проект «единый справочник клиента» длиной в два года Published Language вместо канонической модели: контракт обмена, а не общая правда
ACL, который ничего не переводит Маппер копирует поля один в один Если перевода нет, вы Conformist — назовите честно и не платите за слой
Shared Kernel как свалка В общей библиотеке common лежат статусы заказа В SK только примитивы без бизнес-правил
Conformist в ядре Ядровая модель повторяет схему стороннего SaaS ACL: ядро обязано говорить своим языком
Границы без автоматики В документе граница есть, в коде импортируют напрямую import-linter/ArchUnit в CI, падающая сборка
Слишком много контекстов 3 разработчика, 9 репозиториев, 9 баз Слить обратно; граница — это модуль до тех пор, пока не доказана
Синхронная болтливость через границу 40 HTTP-вызовов на один сценарий Граница проведена не по шву: либо сшить, либо перейти на события/реплику данных
Дублирование считают ошибкой Требуют «единственный источник правды» для каждого поля Осознанная копия read-only данных — законная плата за автономию

Про то, как эти ошибки складываются в общий провал внедрения, — отдельная статья трека: типичные ошибки внедрения DDD.


9. Мини-итог

  • Ограниченный контекст — граница, внутри которой термин означает ровно одно: граница лингвистическая, ставшая границей кода и владения.
  • Один термин в разных контекстах обязан означать разное; общее у моделей — только идентификатор и явный контракт обмена.
  • Поддомен — из пространства задачи, контекст — из пространства решения, микросервис — способ деплоя контекста. Путать их дорого.
  • Границы ищут по языку, владельцам решений, темпу изменений, требованиям к данным, транзакциям — именно в этом порядке силы сигнала.
  • Карта контекстов отвечает на вопрос, кто кому диктует форму модели. Защита ядра — ACL; забота о потребителях — OHS + Published Language; отказ от интеграции (Separate Ways) — тоже валидное решение.
  • Порядок миграции: модуль → запрет импортов → своя схема → свои транзакции → свой сервис. Обратный порядок даёт распределённый монолит.
  • Граница без автоматической проверки исчезает за 2–3 спринта.

Источники


Что дальше

Границы проведены, отношения между контекстами описаны. Дальше — заглянуть внутрь одного контекста и посмотреть, из чего собирается модель: какие объекты имеют идентичность, какие определяются только значением, где проходят транзакционные границы и куда девать логику, которая не принадлежит ни одному объекту.

Тактические блоки: сущности, объекты-значения, агрегаты, сервисы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов