Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Архитектура: DDD и Clean Architecture
architecture_ddd

Архитектура: DDD и Clean Architecture

Domain-Driven Design, layers, repositories, services, dependency injection

Архитектура Django: границы, сервисы и DDD

Статус: архитектурные идеи не зависят от версии Django; примеры соответствуют Django 5.2/6.0. Полный Clean Architecture — один из вариантов, а не обязательная «правильная структура».

Django не заставляет писать ни «толстые views», ни отдельный domain layer. Он даёт модели, forms, views, managers, transactions и apps, а границы выбирает команда.

На простом CRUD идиоматичная Django-модель, ModelForm/serializer и generic view часто достаточны. Дополнительный слой окупается, когда один сценарий затрагивает несколько моделей, имеет конкурентные инварианты, внешние side effects и вызывается из HTTP, admin, command и task.

Главный критерий архитектуры — не число каталогов, а возможность ответить:

  • где находится одно бизнес-правило;
  • кто владеет данными и транзакцией;
  • какие зависимости разрешены между модулями;
  • можно ли изменить интерфейс, не переписав домен;
  • видны ли side effects и поведение при сбое.

#Начните с языка и bounded contexts

DDD начинается не с Repository. Команда формирует ubiquitous language вместе с экспертами: что именно означает «заказ размещён», «счёт оплачен», «подписка активна». Одинаковое слово может иметь разные модели в разных bounded contexts.

Например:

  • Sales владеет заказом и ценой продажи;
  • Fulfillment владеет отгрузкой и складским резервом;
  • Billing владеет invoice/payment attempt.

Одна гигантская модель Order со всеми полями трёх областей создаёт не единство, а связанность. Граница context определяет собственные инварианты, API/events и данные. В modular monolith contexts остаются в одном deployment и database, но код и ownership уже разделены.

Не обязательно делать один Django app на каждое существительное. App полезен как бизнес-модуль, когда у него понятная ответственность, migrations, public API и тесты.

#Практичная структура Django app

Для большинства проектов хороший первый шаг:

orders/ models.py # schema, constraints, локальное поведение services.py # команды/use cases и transaction boundary selectors.py # сложные read queries forms.py # HTML input contract api/ serializers.py views.py tasks.py # тонкие adapters к services tests/

Это не «Clean Architecture по канону», зато зависимости и назначение файлов понятны. Если домен становится сложнее, модуль можно разделить:

orders/ domain/ # entities, value objects, policies application/ # commands/use cases, ports infrastructure/ # ORM repositories, providers interfaces/ # HTTP/CLI/task adapters

Разделение вводят ради выявленной боли, а не заранее для каждого справочника.

#Service layer как use case

Service — не свалка helper-функций и не класс с суффиксом Manager. Он выражает прикладной сценарий и владеет его transaction boundary.

from dataclasses import dataclass from django.db import transaction @dataclass(frozen=True) class PlaceOrderCommand: product_ids: tuple[int, ...] @transaction.atomic def place_order(*, actor, command: PlaceOrderCommand): products = list( Product.objects.select_for_update() .filter(pk__in=command.product_ids, is_active=True) .order_by("pk") ) if len(products) != len(set(command.product_ids)): raise ProductUnavailable order = Order.objects.create(customer=actor) order.add_products(products) order.place() order.save(update_fields=["status", "placed_at"]) transaction.on_commit( lambda: order_placed.delay(order.pk) ) return order

Пример показывает важные границы:

  • transport уже преобразовал вход в command;
  • service заново проверяет бизнес-доступность;
  • конкурентные rows читаются под lock в стабильном порядке;
  • изменение фиксируется одной transaction;
  • Celery message публикуется после commit.

В production лучше использовать delay_on_commit() Celery или outbox для критичного события, а lambda аккуратно связывать с текущим order.pk. Здесь значение order.pk уже неизменно; для переменной цикла применяют functools.partial().

View остаётся adapter:

from django.http import JsonResponse def place_order_view(request): form = PlaceOrderForm(request.POST) if not form.is_valid(): raise_form_error(form) command = PlaceOrderCommand( product_ids=tuple(form.cleaned_data["product_ids"]), ) order = place_order(actor=request.user, command=command) return JsonResponse({"order_id": order.pk}, status=201)

Form проверяет типы и форму HTTP-ввода. Service не доверяет тому, что товар всё ещё активен и доступен: между validation и transaction данные могли измениться. Одинаковый service можно вызвать из DRF, admin action или management command.

#Где держать правила

Нет универсального «вся логика только в service».

Хорошее размещение:

  • model field/constraint — инвариант одной таблицы;
  • model method — поведение одной сущности, которому нужен её state;
  • service/use case — координация нескольких aggregates, transaction и ports;
  • QuerySet/selector — выразительный read query;
  • form/serializer — синтаксис и представление внешнего ввода;
  • permission/policy — решение доступа с явным actor/resource/context.

Одно правило иногда сознательно существует в двух слоях: serializer возвращает красивую ошибку, а database constraint закрывает гонку. Это не вредное дублирование, если роли различны.

Не прячьте основную бизнес-команду в signal. post_save не показывает caller, срабатывает внутри transaction и пропускается bulk operations. Signal годится для слабо связанных расширений; обязательный invoice/outbox лучше вызвать явно.

#Domain model может быть Django model

Pure Python domain entities полезны, когда:

  • правила сложны и их удобно проверять без database;
  • один домен имеет несколько persistence representations;
  • команда сознательно защищает domain от framework details;
  • mapping cost меньше выигрыша от независимости.

Но Django model уже может быть rich domain object:

class Order(models.Model): class Status(models.TextChoices): DRAFT = "draft", "Draft" PLACED = "placed", "Placed" status = models.CharField( max_length=16, choices=Status, default=Status.DRAFT, ) placed_at = models.DateTimeField(null=True, blank=True) def place(self): if self.status != self.Status.DRAFT: raise InvalidOrderTransition self.status = self.Status.PLACED self.placed_at = timezone.now()

Метод не сохраняет себя скрыто: caller видит transaction и save(). Это удобный компромисс для большого числа Django-проектов.

Отдельная dataclass entity плюс ORM mapping удваивает типы, conversion и edge cases с IDs/relations. Не вводите её только ради возможности сказать «domain не импортирует Django».

#Value Object

Value Object определяется значением, обычно неизменяем и проверяет себя при создании:

from dataclasses import dataclass from decimal import Decimal @dataclass(frozen=True) class Money: amount: Decimal currency: str def __post_init__(self): if self.currency not in {"EUR", "RUB", "USD"}: raise UnsupportedCurrency(self.currency) def __add__(self, other: "Money") -> "Money": if self.currency != other.currency: raise CurrencyMismatch return Money(self.amount + other.amount, self.currency)

В ORM Money можно хранить двумя columns или custom field. Правила округления, scale и поддерживаемые currencies должны совпадать с database constraints и payment provider.

Value object полезен не из-за DDD-термина, а потому, что не позволяет случайно сложить RUB и USD как два Decimal.

#Aggregate — граница согласованности

Aggregate объединяет entities/value objects, чьи инварианты меняются атомарно. Aggregate root является публичной точкой изменения. Это не значит, что вся граф-связь должна загружаться в память или что внешние таблицы физически не могут ссылаться на дочернюю строку.

Практические вопросы:

  • какие строки блокируются/изменяются в одной transaction;
  • какой объект проверяет переход состояния;
  • что можно согласовать позже через event;
  • не стал ли aggregate настолько большим, что блокирует параллелизм.

Заказ и его позиции часто один aggregate; доставка может быть другим и реагировать после commit. Попытка сделать одну transaction вокруг заказа, склада, платежа и email создаёт длинную хрупкую операцию.

#Repository: когда помогает и когда мешает

Django ORM уже похож на Repository + Unit of Work: QuerySet выражает запрос, transaction.atomic() задаёт единицу работы. Обёртка каждого filter() методом get_active_users() часто лишь скрывает возможности ORM и создаёт десятки pass-through методов.

Repository оправдан, когда application/domain действительно не должен знать persistence API, есть несколько реализаций port или нужен чистый aggregate mapping:

from typing import Protocol class OrderRepository(Protocol): def get_for_update(self, order_id: int) -> OrderEntity: ... def save(self, order: OrderEntity) -> None: ...

Контракт должен отражать потребности use case, а не копировать весь QuerySet API. In-memory fake тоже обязан имитировать важную семантику — uniqueness, not found, concurrency — иначе зелёный unit test создаст ложную уверенность.

Для сложных read screens repository abstraction часто не нужна. Selector возвращает оптимизированный QuerySet/DTO с select_related, annotations и access scope. Read и write paths имеют разные потребности.

#Dependency Injection без контейнера

DI означает, что зависимость передана снаружи. В Python для этого часто достаточно аргумента:

def issue_refund(*, payment_id, gateway: PaymentGateway): payment = load_payment_for_refund(payment_id) result = gateway.refund( external_id=payment.external_id, amount=payment.amount, idempotency_key=f"refund:{payment.pk}", ) mark_refunded(payment=payment, provider_result=result)

Composition root создаёт реальный StripeGateway; тест передаёт fake. Создание gateway = gateway or StripeGateway() внутри use case скрывает зависимость и ослабляет DI.

Не передавайте через DI всё подряд. timezone.now имеет смысл инъецировать как clock, если от времени зависят правила; обычный Decimal или logger редко требуют provider abstraction.

#External side effects и outbox

Database transaction не может атомарно commit вместе с HTTP provider или broker. on_commit() предотвращает side effect после rollback, но публикация может упасть после успешного commit.

Для критичного события transactional outbox сохраняется рядом с aggregate. Dispatcher публикует его с retry, consumer дедуплицирует event ID. Это делает eventual consistency явной.

Не держите database lock во время provider request. Типичный payment workflow использует state machine, idempotency key, короткие transactions до/после вызова и webhook reconciliation.

#CQRS без мифов

CQRS означает разделение модели команд и запросов. Минимальная форма — services.py для writes и selectors.py для reads в одной database. Отдельная read database, event sourcing и Kafka не являются обязательными.

CQRS помогает, когда read model сильно отличается от write invariants или масштабируется отдельно. Цена: два контракта, lag, projector/rebuild и больше тестов. Для обычного CRUD это лишняя сложность.

Event sourcing — отдельный выбор: состояние восстанавливается из событий. Он не следует автоматически из DDD или CQRS.

#Границы импортов и public API

Модульная архитектура разрушается, если любой app импортирует внутренние models другого и меняет их напрямую. Определите public functions/events и ownership.

Полезные правила:

  • интерфейс вызывает application service, а не чужую таблицу напрямую;
  • tasks/commands являются adapters и не дублируют use case;
  • shared-каталог содержит технические primitives, а не все модели проекта;
  • imports не образуют циклы;
  • одна команда владеет migration и schema своего context;
  • architecture rules проверяет import linter/тест.

Не доводите запрет импортов до абсурда: в modular monolith прямой вызов стабильного public service быстрее и надёжнее внутреннего HTTP.

#Тестовая пирамида по слоям

  • Value objects/pure policies: быстрые unit tests без Django.
  • Django model methods/constraints: TestCase на production backend.
  • Services: transaction, permissions, concurrency и on_commit callbacks.
  • Selectors: correctness, access scope и query budget.
  • Adapters: HTTP status/schema, authentication и input mapping.
  • Critical workflow: небольшой integration test с database/broker/provider fake.

Mock каждого ORM-вызова делает тест привязанным к реализации. Лучше проверять observable result и constraints на реальной test database.

#Эволюционный путь

  1. Оставить простой CRUD идиоматичным.
  2. Повторяемый multi-model workflow вынести в одну service function.
  3. Сложный read вынести в QuerySet/selector.
  4. Обозначить business apps и запретить хаотичные cross-imports.
  5. Для критичных integrations добавить ports/adapters и outbox.
  6. Только при реальной необходимости отделить pure domain entities/repositories.

Архитектура должна уменьшать стоимость изменения. Если добавление одного поля требует entity, DTO, mapper, protocol, repository, service и adapter без отдельной бизнес-ценности, граница выбрана слишком тяжело.

#Legacy и исправления

  • Утверждение «Django поощряет толстые views» удалено как неточное: framework не диктует единственный слой логики.
  • Repository больше не преподносится обязательной обёрткой ORM.
  • DDD начинается с языка/bounded contexts, а не с каталогов domain/.
  • CQRS не требует разных databases и event sourcing.
  • Основные side effects больше не прячутся в signals; transaction/outbox граница показывается явно.

#Дополнительные материалы

  • Architecture Patterns with Python
  • Eric Evans: Domain-Driven Design Reference
  • Django: transactions

Далее: Микросервисы с Django