Domain-Driven Design Типичные ошибки внедрения DDD
0%

Типичные ошибки внедрения DDD

Типичные ошибки внедрения DDD

Восемь статей назад мы начали с вопроса «где в коде живёт знание о бизнесе». К этому моменту у вас есть весь инструментарий: стратегия, контексты, тактические блоки, персистентность, события, Event Storming и работающий код.

Проблема в том, что инструментарий — не самая частая причина провала. Внедрения DDD ломаются предсказуемо и почти всегда одинаково, и почти никогда — из-за того, что кто-то неправильно написал Money.__eq__. Ломаются они потому, что:

DDD оптимизирует стоимость изменения через N лет, а оценивают его по стоимости первой недели.

Отсюда две симметричные катастрофы. Первая: команда платит стартовую наценку и не получает ничего, потому что скопировала форму (папки, слова, слои), а не содержание (границы, инварианты, язык). Вторая: команда платит наценку там, где её вообще не надо было платить — домен оказался CRUD-справочником.

Эта статья — каталог таких провалов. Для каждого: признак (как понять, что это про вас, не советуясь с архитектором), механика (почему это дорого именно в деньгах и инцидентах), выход (что делать в понедельник, не останавливая продукт). В конце — таблица «симптом → диагноз», метрики здоровья модели и приоритизация: что чинить первым.


1. Карта ловушек

Ошибки живут на четырёх уровнях, и это не декоративная классификация: лечатся они принципиально по-разному. Ошибку уровня кода правит одна команда за спринт. Ошибку уровня границ — квартал и переговоры. Ошибку уровня организации нельзя починить рефакторингом вообще.

Дальше — по уровням снизу вверх по частоте: самые массовые ошибки идут первыми.


2. Уровень решения: DDD там, где домена нет

2.1. Признак

Возьмите три последних тикета и выпишите бизнес-правила, которые они меняли. Если в них нет ни одного слова «нельзя», «только если», «кроме случая», а есть «добавить поле», «показать колонку», «выгрузить в Excel» — у вас не домен, а редактор данных.

Второй тест, ещё честнее: сколько инвариантов в вашей самой сложной сущности? Если ответ «уникальность email и обязательность имени» — это валидация формы, а не доменная логика. Инвариант домена — это правило, которое связывает несколько полей или сущностей и которое нельзя проверить в момент ввода данных.

2.2. Механика

Стоимость DDD — не абстрактная «сложность». Она измерима: на каждый сценарий вы платите объектом-значением, агрегатом, портом, репозиторием, мапперами domain↔ORM и domain↔DTO. В примере из седьмой статьи это примерно в 2,5–3 раза больше строк, чем прямолинейный CRUD, и — что важнее — три места вместо одного, куда надо зайти, чтобы добавить поле.

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

Кривая стоимости изменения: CRUD против доменной модели

2.3. Выход

Разделите систему по типам поддоменов и применяйте разные стили в разных местах — это нормально и правильно:

Поддомен Стиль Почему
Ядро (core) Полный DDD: агрегаты, VO, события Здесь конкурентное преимущество и максимум изменений
Поддерживающий (supporting) Транзакционный скрипт + аккуратные сервисы Правил мало, меняются редко
Обобщённый (generic) Купить / взять готовое Уведомления, биллинг-провайдер, auth

Главная ошибка тут — не «применили DDD», а «применили его равномерно». Единый архитектурный стандарт на весь монорепозиторий выглядит красиво в презентации и стоит компании квартал разработки в год.


3. Уровень модели: папки вместо модели

Самый массовый провал. Команда прочитала книгу, создала domain/, application/, infrastructure/, переименовала OrderService в OrderApplicationService — и на этом остановилась. Внутри всё то же самое.

Заявленная слоистая структура против реального графа зависимостей

3.1. Как это выглядит в коде

# domain/order.py — «доменная модель»
from sqlalchemy.orm import Mapped, mapped_column          # ← протечка №1
from infrastructure.db import Base                        # ← протечка №2

class Order(Base):                                        # ← наследование от ORM
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    status: Mapped[str]
    total: Mapped[float]                                  # ← деньги во float
    customer_id: Mapped[int]

    def set_status(self, status: str) -> None:            # ← «метод» без инварианта
        self.status = status
# application/order_service.py — здесь живут ВСЕ решения
class OrderApplicationService:
    def cancel(self, order_id: int, actor_id: int) -> None:
        order = self.session.get(Order, order_id)
        if order.status == "shipped":
            customer = self.session.get(Customer, order.customer_id)
            if customer.tier != "premium":
                raise Forbidden("нельзя отменить отгруженный заказ")
            if datetime.now(UTC) - order.shipped_at > timedelta(minutes=30):
                raise Forbidden("окно отмены истекло")
        order.set_status("cancelled")                      # ← сеттер под другим именем
        self.bonus_service.unfreeze(order.customer_id, order.total)
        self.session.commit()

Формально всё есть: слои, «сервис приложения», «доменная сущность». Фактически Order — структура данных, а вся модель предметной области живёт в if-ах сервиса. Ровно тот же код получился бы без единого упоминания DDD, только на 300 строк короче.

3.2. Правило, по которому легко проверять

Если снаружи агрегата есть if, зависящий от двух и более его полей, — этот if стоит не там.

if order.status == "shipped" and customer.tier != "premium" читает status и shipped_at из заказа — значит, решение принадлежит заказу:

# domain/order.py — теперь без единого импорта инфраструктуры
CANCEL_WINDOW = timedelta(minutes=30)

@dataclass
class Order:
    id: OrderId
    status: OrderStatus
    shipped_at: datetime | None
    _lines: list[OrderLine]

    def cancel(self, by: Customer, now: datetime) -> OrderCancelled:
        """Единственное законное место, где заказ становится отменённым."""
        if self.status is OrderStatus.CANCELLED:
            raise AlreadyCancelled(self.id)            # идемпотентность решает вызывающий
        if self.status is OrderStatus.SHIPPED:
            if not by.is_premium:
                raise CancellationForbidden("заказ уже отгружен")
            if now - self.shipped_at > CANCEL_WINDOW:
                raise CancellationForbidden("окно отмены 30 минут истекло")
        self.status = OrderStatus.CANCELLED
        return OrderCancelled(order_id=self.id, refund=self.total(), by=by.id)

Сценарий приложения теперь скучный — и это правильный признак:

class CancelOrderHandler:
    def handle(self, cmd: CancelOrder) -> None:
        with self.uow:
            order = self.uow.orders.get(cmd.order_id)
            customer = self.uow.customers.get(order.customer_id)
            event = order.cancel(by=customer, now=self.clock.now())
            self.uow.orders.save(order)
            self.uow.outbox.add(event)                  # см. статью про события
            self.uow.commit()

3.3. Выход: автоматизируйте границу, иначе она сгниёт

Ревью не удерживает архитектурные границы — люди устают. Границу удерживает CI. Для Python это import-linter:

# setup.cfg
[importlinter]
root_package = shop

[importlinter:contract:layers]
name = Слои: домен ничего не знает о внешнем мире
type = layers
layers =
    shop.api
    shop.application
    shop.domain

[importlinter:contract:domain-purity]
name = Домен не импортирует инфраструктуру
type = forbidden
source_modules = shop.domain
forbidden_modules =
    sqlalchemy
    fastapi
    redis
    requests

Аналоги в других экосистемах: ArchUnit для Java/Kotlin, NetArchTest и ArchUnitNET для .NET (см. трек C#), dependency-cruiser для TypeScript, go list -deps + go-arch-lint для Go.

Один контракт в CI ловит больше протечек, чем полгода ревью-комментариев «а это точно должно быть в домене?».


4. Уровень границ: контексты нарезаны по данным

4.1. Признак

Названия ваших контекстов (или микросервисов) — это существительные из схемы БД: user-service, order-service, product-service. Ни один из них не соответствует чему-то, что бизнес называет своим словом. Спросите продакта: «кто отвечает за оформление заказа?» — если ответ содержит три сервиса, границы неверны.

Нарезка по сущностям порождает распределённый монолит

4.2. Механика: почему это дороже монолита

Нарезка по сущностям гарантирует, что любой бизнес-сценарий пересекает границы. Последствия измеримы:

  • Доступность падает мультипликативно. Четыре сервиса по 99,9% на синхронном пути дают 0,999⁴ ≈ 99,6% — это 3,5 часа недоступности в месяц вместо 43 минут.
  • Латентность складывается с хвостами. p99 сценария ≈ сумма p99 звеньев, а не сумма средних: 4 звена по 50 мс p99 дают ~200 мс p99, и это без ретраев.
  • Релиз перестаёт быть локальным. Добавление поля в заказ требует согласованного выката четырёх команд — то есть той самой связности, ради ухода от которой сервисы и резали.

Это классический распределённый монолит: связность монолита плюс операционная стоимость распределённой системы. Ни одного плюса, оба минуса.

4.3. Выход

Критерий границы — не данные, а язык и автономность решения:

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

Практические шаги:

  1. Проведите Event Storming и ищите разрывы в языке — места, где одно слово («заказ», «клиент», «товар») меняет смысл. Разрыв языка — кандидат в границу.
  2. Разрешите дублирование данных. «Клиент» в оформлении заказа (адрес, тир, лимит) и «клиент» в поддержке (история обращений, тональность) — это два разных объекта, а не один, размазанный по сервисам. Дублирование полей — цена автономности, и она обычно дешевле сетевого вызова.
  3. Переведите оставшиеся связи на события вместо вызовов — детали в статье про интеграцию.
  4. Не режьте сервисы, пока не уверены в границах. Модульный монолит с теми же границами стоит на порядок дешевле и позволяет двигать границу за час, а не за квартал. Это прямая рекомендация Фаулера: MonolithFirst.

4.4. Родственная ошибка: общая база как «интеграция»

Два контекста ходят в одни таблицы — «так же быстрее, чем через API». Это не интеграция, а отсутствие границы: схема БД становится публичным контрактом, который никто не версионирует. Через год ни один ALTER TABLE невозможно провести, не найдя всех читателей — а найти их нельзя, потому что SQL пишется в рантайме.

Столь же вредна «общая доменная библиотека», куда сложили все сущности: это Shared Kernel, который по канону должен быть минимальным и совместно владеемым, а на практике превращается в глобальную зависимость с очередью на изменение.


5. Уровень модели: агрегат размером со схему БД

5.1. Признак

У вас есть Customer, внутри которого коллекция заказов, внутри каждого — позиции, платежи, доставки. Загрузка клиента тянет 40 тысяч строк. Любое изменение чего угодно конфликтует с любым другим изменением.

Причина почти всегда одна: агрегат срисовали с ER-диаграммы. Но связь «один-ко-многим» в базе — это про навигацию, а граница агрегата — про транзакционную согласованность. Это разные вопросы.

5.2. Механика конкуренции

Оптимистичная блокировка (статья 04) защищает агрегат целиком. Чем крупнее агрегат, тем выше шанс, что два независимых действия столкнутся на одной версии:

Численно: если агрегат обслуживает λ записей в секунду, а транзакция длится T, вероятность конфликта растёт примерно как 1 − e^(−λT). При λ = 20 rps и T = 50 мс это ≈ 63% — система тратит больше времени на ретраи, чем на работу. Стоимость чтения при этом O(число дочерних сущностей), то есть загрузка «толстого» клиента линейна по всей его истории — и эта история только растёт.

5.3. Выход: правила Вернона

Vaughn Vernon, Effective Aggregate Design — три PDF, которые стоит прочитать целиком. Короткая версия:

  1. Моделируйте истинные инварианты внутри границы. Если правило звучит «сумма позиций не превышает лимит заказа» — позиции внутри заказа. Если «у клиента не больше 5 активных заказов» — это, скорее всего, правило, которое можно проверить с eventual consistency.
  2. Проектируйте маленькие агрегаты. По умолчанию — корень плюс объекты-значения.
  3. Ссылайтесь на другие агрегаты по идентификатору, а не по объекту. order.customer_id, а не order.customer. Это физически запрещает случайный обход границы.
  4. Обновляйте другие агрегаты через события с eventual consistency.
# было: одна транзакция на два агрегата — граница не значит ничего
def place_order(self, cmd):
    with self.uow:
        order = Order.place(cmd.lines)
        stock = self.uow.stock.get(cmd.sku)
        stock.reserve(cmd.qty)                 # ← второй агрегат в той же транзакции
        self.uow.orders.add(order)
        self.uow.commit()

# стало: одна транзакция — один агрегат, связь через событие
def place_order(self, cmd):
    with self.uow:
        order = Order.place(cmd.lines)         # агрегат сам решает, что он валиден
        self.uow.orders.add(order)
        self.uow.outbox.add(OrderPlaced(order.id, order.lines))
        self.uow.commit()                      # событие и состояние — атомарно

# отдельный обработчик, отдельная транзакция, свой агрегат
def on_order_placed(self, event: OrderPlaced):
    with self.uow:
        stock = self.uow.stock.get(event.sku)
        try:
            stock.reserve(event.qty)
        except OutOfStock:
            self.uow.outbox.add(ReservationFailed(event.order_id))  # компенсация
        self.uow.commit()

Возражение «а если резерв не пройдёт?» — правильное, и ответ на него: значит, бизнес должен описать компенсацию. Не «база откатит», а «клиент получит письмо, что позиция закончилась». Это разговор с продактом, а не с ORM. Если бизнес говорит «такого не должно происходить никогда» — вот тогда это один агрегат.


6. Тактические ловушки помельче

6.1. Репозиторий, который на самом деле DAO

# антипаттерн: репозиторий протекает наружу деталями хранения
class OrderRepository:
    def query(self) -> Query: ...                    # ← отдал ORM-Query наружу
    def find_by_status_and_date_and_tier(...): ...   # ← 40 методов «под каждый экран»
    def update_status(self, id, status): ...         # ← пишет мимо агрегата

Признаки: репозиторий возвращает Query/IQueryable/QuerySet; у него больше 5–7 методов; есть методы, меняющие поля в обход агрегата. Первый — фатальный: сценарий начинает конструировать запросы, то есть знать про схему.

Выход — разделить чтение и запись. Репозиторий отдаёт агрегаты целиком и умеет get/save/ пару доменных выборок. Экранам нужны не агрегаты, а плоские проекции — им делают отдельный read-модуль с прямым SQL. Это лёгкий CQRS без событийного стора и он окупается почти всегда:

# application/queries/order_list.py — никакого домена, только SQL и DTO
def list_orders(conn, customer_id: int, limit: int = 50) -> list[OrderListItem]:
    rows = conn.execute(text("""
        SELECT o.id, o.status, o.placed_at, SUM(l.price * l.qty) AS total
        FROM orders o JOIN order_lines l ON l.order_id = o.id
        WHERE o.customer_id = :cid
        GROUP BY o.id ORDER BY o.placed_at DESC LIMIT :lim
    """), {"cid": customer_id, "lim": limit})
    return [OrderListItem(**r._mapping) for r in rows]

6.2. События как CRUD-нотификации

# бессмысленное событие: подписчик обязан догадаться, что произошло
@dataclass
class OrderUpdated:
    order_id: int
    changed_fields: dict[str, Any]

# осмысленное: имя на языке бизнеса, полезная нагрузка достаточна для реакции
@dataclass(frozen=True)
class OrderCancelled:
    order_id: OrderId
    cancelled_by: ActorId
    reason: CancellationReason
    refund_due: Money
    occurred_at: datetime

OrderUpdated заставляет каждого подписчика реконструировать намерение по diff-у полей — то есть знать чужую модель. Это связность худшего сорта: невидимая в типах и всплывающая в проде. Проверочный вопрос к имени события: может ли бизнес-эксперт объяснить его без слова «поле»? Если нет — событие техническое, и в интеграционном контракте ему не место.

Ещё одна ошибка: публиковать доменные события наружу как есть. Доменное событие — внутренний язык контекста, оно меняется вместе с моделью. Наружу должен идти отдельный, стабильный, версионируемый интеграционный контракт. Подробности — в статье 05.

6.3. Оверинжиниринг тактикой

Обратная крайность, встречается у команд, которые уже прочитали книгу:

  • Объект-значение на каждый примитив. CustomerName, Description, Comment — три класса по 20 строк, оборачивающие str без единого правила. VO оправдан, когда есть поведение или инвариант: Money (арифметика + валюта), Email (нормализация), DateRange (пересечения). Comment(value: str) — накладные расходы без выгоды.
  • Доменный сервис на всё. OrderValidationService, OrderCalculationService — это возврат к анемичной модели через чёрный ход. Доменный сервис оправдан, когда операция не принадлежит ни одному агрегату (перевод между счетами, подбор тарифа по нескольким источникам).
  • CQRS + Event Sourcing «потому что DDD». Event Sourcing — самостоятельное решение с самостоятельной ценой: проекции, версионирование событий, ретроактивные исправления, отладка. Он оправдан, когда бизнесу нужна история как факт (аудит, финансы, комплаенс), и не оправдан «чтобы можно было восстановить состояние». Отличный разбор компромиссов — Event Sourcing у Фаулера и критика от Кориковa.

7. Уровень людей: где ломается больше всего

Технические ошибки чинятся кодом. Эти — нет.

7.1. Единый язык, которого нет

Признак: есть страница в Confluence «Глоссарий проекта», последнее изменение — 14 месяцев назад, а в коде встречаются OrderDTO, OrderEntity, OrderModel, OrderData — и это четыре разные вещи, про которые никто не может сказать, чем они отличаются.

Механика: единый язык — не словарь, а побочный продукт регулярного разговора. Он живёт, пока разработчики и эксперты решают задачи вместе и вслух. Глоссарий фиксирует результат, но не может его создать. Как только эксперт превращается в источник тикетов, язык расслаивается: бизнес говорит «списание», разработчики пишут transaction_type=2, аналитик рисует «выбытие» — и каждое уточнение требования проходит два перевода с потерями.

Потери при переводе между языками бизнеса и кода

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

def test_повторный_пробный_период_не_выдается_после_отмены(): ...
def test_смена_плана_в_середине_периода_не_сдвигает_дату_списания(): ...
def test_просроченная_подписка_блокируется_через_7_дней(): ...

Выглядит непривычно, работает безотказно: это единственный артефакт, который одновременно исполняется и читается бизнесом.

7.2. Контекст без владельца

Закон Конвея работает в обе стороны. Если ограниченный контекст не совпадает с зоной ответственности одной команды, границу будут нарушать — не из вредности, а потому что дедлайн. Контекст, который правят три команды, за полгода превращается в общий Big Ball of Mud внутри красивых папок.

Правило: один контекст — одна команда-владелец (одна команда может владеть несколькими контекстами, обратное — нет). Это ядро Team Topologies и обратного манёвра Конвея.

7.3. DDD-полиция и ритуалы

Симптом зрелой болезни: в команде появляется человек, который на ревью пишет «это должно быть объектом-значением», не объясняя, какой инвариант это защищает. Дальше — карго-культ: правила соблюдаются, потому что правила, а не потому что дешевле.

Лечится одним вопросом на ревью: «какое изменение станет дешевле, если мы это сделаем?» Нет внятного ответа — нет изменения. DDD — экономическая дисциплина, а не эстетическая.

7.4. Big bang: переписать всё на DDD

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

Выход — Strangler Fig: новый контекст живёт рядом со старой системой за фасадом, забирает по одному сценарию, старый код удаляется по мере освобождения. Каждый шаг ценен сам по себе и обратим.

Антикоррупционный слой (ACL) здесь не формальность: он не даёт модели легаси просочиться в новый контекст. Без него через полгода новый контекст говорит на языке старых таблиц, и вы получили ту же систему с новыми папками.


8. Что чинить первым

Не все ошибки равны. Приоритизируйте по двум осям: как часто встречается и сколько стоит исправление позже, чем сейчас (границы дорожают быстрее всего — они застывают вместе с контрактами и организационной структурой).

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


9. Диагностика: от симптома к диагнозу

Практический чек-лист. Проходится за час на любом проекте.

Таблица «симптом → диагноз → первое действие»

Симптом Диагноз Первое действие
В PR правится 3+ файла ради одного правила Логика размазана Найти агрегат-владельца правила, перенести if
В domain/ есть import sqlalchemy Протечка инфраструктуры Контракт в import-linter, маппинг наружу
Репозиторий возвращает Query/IQueryable Репозиторий = DAO Read-модель отдельно, репозиторий отдаёт агрегаты
Имена сервисов = имена таблиц Границы по данным Event Storming, искать разрывы языка
Два сервиса пишут в одну таблицу Границы нет Назначить владельца таблицы, остальным — API/события
В логах OptimisticLockError пачками Агрегат-гигант Резать по инвариантам, ссылки по ID
События называются *Updated, *Changed CRUD-нотификации Переименовать по намерению, обогатить payload
Глоссарий не менялся год Языка нет Вернуть эксперта в цикл разработки
Три команды правят один контекст Нет владельца Обратный манёвр Конвея
Проект «переписываем на DDD» идёт 9+ месяцев без релиза Big bang Strangler Fig, первый сценарий в прод за 6 недель

Метрики здоровья модели

Ставятся один раз, дальше следят автоматически:

  1. Разброс изменения правила — среднее число файлов в PR, помеченном feat(domain). Здоровый диапазон: 1–2. Рост означает возврат к размазанной логике.
  2. Число нарушений архитектурных контрактов в CI. Целевое значение — ноль; допущенные исключения фиксируются в конфиге со сроком.
  3. Доля сценариев с синхронными межконтекстными вызовами. Растёт — границы поплыли.
  4. Частота OptimisticLockError на 1000 команд. Индикатор размера агрегата.
  5. Покрытие домена быстрыми тестами без БД. Если доменные тесты требуют Postgres, домен не изолирован — и всё остальное в этой статье уже неважно.

10. Мини-итог

  • Провал DDD почти никогда не технический. Чаще всего это DDD не там (CRUD-домен) или DDD не то (папки вместо модели).
  • Самый массовый антипаттерн — анемичная модель в DDD-обёртке. Проверка: if, читающий два поля агрегата снаружи агрегата.
  • Самый дорогой — неверные границы: нарезка по таблицам даёт распределённый монолит, где падает доступность, растёт латентность и релиз требует четырёх команд.
  • Агрегат — граница транзакционной согласованности, а не поддерево ER-диаграммы. Маленькие агрегаты, ссылки по ID, eventual consistency между ними.
  • Границы держит CI, а не ревью. Язык держит разговор с экспертом, а не глоссарий. Контекст держит одна команда-владелец.
  • Внедряйте инкрементально: Strangler Fig, ACL, dual-run. Big bang проваливается почти всегда.
  • И главный вопрос на любом ревью: «какое изменение станет дешевле, если мы это сделаем?»

Источники


Что дальше

Трек по Domain-Driven Design закончен. Вы прошли путь от вопроса «зачем вообще» через стратегию и границы к работающему коду и к каталогу ошибок — этого достаточно, чтобы вести внедрение самостоятельно и вовремя останавливаться там, где оно не нужно.

Куда двигаться дальше, в зависимости от того, что сейчас болит:

  • Архитектура систем в целом — DDD задаёт границы, но не отвечает, как их разворачивать и эксплуатировать: архитектурные паттерны, а заодно принципы проектирования и паттерны проектирования — тактические блоки DDD стоят ровно на них.
  • Реализация на конкретном стекеGo, C#, TypeScript или Elixir: у каждого языка своя цена на те же конструкции, и это заметно меняет, что стоит делать явно, а что нет.
  • Фундаменталгоритмы и структуры данных: read-модели, проекции и производительность агрегатов упираются именно туда.
  • Работа с людьми и требованиями — треки по продуктовому и проектному управлению: большинство разобранных здесь провалов начинается задолго до первой строки кода.

Общая карта всех треков портала и рекомендуемый порядок чтения — в дорожной карте.

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

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

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

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