Domain-Driven Design, layers, repositories, services, dependency injection
Статус: архитектурные идеи не зависят от версии 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.
Главный критерий архитектуры — не число каталогов, а возможность ответить:
DDD начинается не с Repository. Команда формирует ubiquitous language вместе с экспертами: что именно означает «заказ размещён», «счёт оплачен», «подписка активна». Одинаковое слово может иметь разные модели в разных bounded contexts.
Например:
Одна гигантская модель Order со всеми полями трёх областей создаёт не единство, а связанность. Граница context определяет собственные инварианты, API/events и данные. В modular monolith contexts остаются в одном deployment и database, но код и ownership уже разделены.
Не обязательно делать один Django app на каждое существительное. App полезен как бизнес-модуль, когда у него понятная ответственность, migrations, public API и тесты.
Для большинства проектов хороший первый шаг:
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 — не свалка 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Пример показывает важные границы:
В 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».
Хорошее размещение:
Одно правило иногда сознательно существует в двух слоях: serializer возвращает красивую ошибку, а database constraint закрывает гонку. Это не вредное дублирование, если роли различны.
Не прячьте основную бизнес-команду в signal. post_save не показывает caller, срабатывает внутри transaction и пропускается bulk operations. Signal годится для слабо связанных расширений; обязательный invoice/outbox лучше вызвать явно.
Pure Python domain entities полезны, когда:
Но 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 определяется значением, обычно неизменяем и проверяет себя при создании:
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 объединяет entities/value objects, чьи инварианты меняются атомарно. Aggregate root является публичной точкой изменения. Это не значит, что вся граф-связь должна загружаться в память или что внешние таблицы физически не могут ссылаться на дочернюю строку.
Практические вопросы:
Заказ и его позиции часто один aggregate; доставка может быть другим и реагировать после commit. Попытка сделать одну transaction вокруг заказа, склада, платежа и email создаёт длинную хрупкую операцию.
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 имеют разные потребности.
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.
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 означает разделение модели команд и запросов. Минимальная форма — 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.
Модульная архитектура разрушается, если любой app импортирует внутренние models другого и меняет их напрямую. Определите public functions/events и ownership.
Полезные правила:
Не доводите запрет импортов до абсурда: в modular monolith прямой вызов стабильного public service быстрее и надёжнее внутреннего HTTP.
TestCase на production backend.on_commit callbacks.Mock каждого ORM-вызова делает тест привязанным к реализации. Лучше проверять observable result и constraints на реальной test database.
Архитектура должна уменьшать стоимость изменения. Если добавление одного поля требует entity, DTO, mapper, protocol, repository, service и adapter без отдельной бизнес-ценности, граница выбрана слишком тяжело.
domain/.Далее: Микросервисы с Django