DDD и Domain Model: Практическое Руководство
Назначение
Документ объясняет, когда и как применять Domain-Driven Design в Pin: выбор между Rich Model и Anemic Model, правила Aggregate Root, Value Objects, Domain Events и Domain Services, а также где НЕ нужно DDD.
Нормативные правила и обязательные engineering decisions вынесены в DDD И Rich Model: Правила И Практика. Этот файл является практическим guide с примерами и рекомендациями по реализации.
Классификация Доменов По Типу Модели
Rich Domain Model (обязательно)
Используется, когда:
- есть нетривиальные бизнес-инварианты, которые aggregate должен защищать сам;
- переход между состояниями требует проверки предусловий;
- изменение объекта приводит к domain events и downstream side effects;
- ошибки нарушения правил должны быть typed и детектированы до persistence.
| Домен | Почему Rich |
|---|---|
| Accounting / Ledger | Баланс, hold, double-entry; нельзя пропустить нарушение consistency. |
| Withdrawal | Stateful FSM: requested → approved → processing → completed/failed/cancelled. |
| Deposit | Idempotent provider callback; нужна проверка state до mutation. |
| Incentive / UserIncentive | Активация, wagering progress, expiry, forfeit — сложные правила. |
| Game Session / Round | Bet → Win/Rollback; stateful с idempotency. |
| KYC Verification | Submitted → Approved/Rejected, re-verification, evidence snapshots. |
| User Safety | Limits, self-exclusion, cooling-off — fail-closed при нарушении. |
| Partner Commission | Расчёт, accrual, payout request — финансовый учёт. |
| Fraud Case | Evidence, decision, actions — compliance lifecycle. |
Anemic Model + Application Service (допустимо)
Используется, когда:
- объект является справочником или словарём (справочные данные, провайдер-словарь);
- нет сложных инвариантов — только CRUD через admin API;
- данные — конфигурация, и вся логика принятия решений находится в rule engine или application policy.
| Домен | Почему Anemic |
|---|---|
Справочники handbook (валюты, страны) | Только реплика справочника Talent Handbook: пишется консюмером синка, без собственных инвариантов (ADR-0063). |
| Platform Settings | Конфигурация с validation, но без собственных domain events. |
| Notification Templates | Хранилище шаблонов с метаданными. |
| Provider Dictionary | Admin-managed список провайдеров без бизнес-логики. |
| Catalog Metadata | Преимущественно данные; search projection строится отдельно. |
Aggregate Root
Что Такое Aggregate
Aggregate — граница consistency. Все изменения aggregate проходят через один entry point — Aggregate Root. EF никогда не меняет состояние дочернего объекта напрямую, минуя root.
Базовый Класс
// Talent.Common.Domain
public abstract class AggregateRoot<TId> : Entity<TId>, IAggregateRoot<TId>
where TId : struct
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent)
=> _domainEvents.Add(domainEvent);
public IReadOnlyList<IDomainEvent> DequeueDomainEvents()
{
var events = _domainEvents.ToList();
_domainEvents.Clear();
return events;
}
}
Правила Aggregate
- Aggregate Root защищает все инварианты через domain methods.
- Дочерние entities изменяются только через методы root, не через прямое присвоение полей.
- Один command handler работает с одним aggregate root.
- Aggregate не содержит
DbContext,ILogger,IServiceProvider,HttpContext. - Aggregate не вызывает внешние сервисы. Если нужен результат внешнего вызова, handler передаёт его как параметр domain method.
- Aggregate не знает о других aggregates. Если нужна ссылка — только по Id (foreign id, не object reference).
Как Выбирать Границу Aggregate
Используй следующие эвристики:
- если два объекта должны коммититься atomically для сохранения инварианта, они вероятно в одном aggregate;
- если один объект часто читается отдельно, но редко изменяется вместе с другим, это аргумент против объединения;
- если aggregate становится слишком большим для конкурентной записи и тянет много unrelated data, граница выбрана слишком широко;
- если invariant требует координации между несколькими roots, используй domain policy, saga или application orchestration, а не "супер-aggregate".
Практический вопрос: "Что именно должно быть консистентно сразу после локального commit?" Ответ обычно и дает границу aggregate.
Пример: Withdrawal Aggregate
public sealed class WithdrawalRequest : AggregateRoot<WithdrawalRequestId>
{
public UserId UserId { get; private set; }
public TenantId TenantId { get; private set; }
public BrandId BrandId { get; private set; }
public MoneyAmount Amount { get; private set; }
public WithdrawalStatus Status { get; private set; }
public WithdrawalRejectReason? RejectReason { get; private set; }
private WithdrawalRequest() { } // EF ctor
public static WithdrawalRequest Create(
UserId userId, TenantId tenantId, BrandId brandId,
MoneyAmount amount, ActorSnapshot actor)
{
// Валидация уровня aggregate (не бизнес-policy)
if (amount.Amount <= 0)
throw new DomainException(WithdrawalErrors.AmountMustBePositive);
var withdrawal = new WithdrawalRequest
{
Id = WithdrawalRequestId.NewId(),
UserId = userId,
TenantId = tenantId,
BrandId = brandId,
Amount = amount,
Status = WithdrawalStatus.Requested,
};
withdrawal.RaiseDomainEvent(new WithdrawalRequestedEvent(withdrawal.Id, userId, amount, actor));
return withdrawal;
}
public Result Approve(ActorSnapshot approver)
{
if (Status != WithdrawalStatus.Requested)
return Result.Fail(WithdrawalErrors.InvalidTransition(Status, WithdrawalStatus.Approved));
Status = WithdrawalStatus.Approved;
RaiseDomainEvent(new WithdrawalApprovedEvent(Id, UserId, approver));
return Result.Ok();
}
public Result Reject(WithdrawalRejectReason reason, ActorSnapshot approver)
{
if (Status is not (WithdrawalStatus.Requested or WithdrawalStatus.Approved))
return Result.Fail(WithdrawalErrors.InvalidTransition(Status, WithdrawalStatus.Rejected));
Status = WithdrawalStatus.Rejected;
RejectReason = reason;
RaiseDomainEvent(new WithdrawalRejectedEvent(Id, UserId, reason, approver));
return Result.Ok();
}
}
Value Objects
Зачем Value Objects
Value Object — неизменяемый объект, определяемый своими данными, а не идентитетом. Используется для:
- типизации primitive obsession (
decimal amount→MoneyAmount); - инкапсуляции правил валидации (
Email,IbanNumber); - явных domain-specific типов (
UserId,TenantId,BrandId).
Базовый Класс
// Talent.Common.Domain
public abstract class ValueObject
{
protected abstract IEnumerable<object?> GetEqualityComponents();
public override bool Equals(object? obj)
{
if (obj is null || obj.GetType() != GetType()) return false;
return GetEqualityComponents()
.SequenceEqual(((ValueObject)obj).GetEqualityComponents());
}
public override int GetHashCode()
=> GetEqualityComponents()
.Aggregate(0, HashCode.Combine);
}
Ключевые Value Objects Платформы
| Value Object | Пакет | Назначение |
|---|---|---|
TenantId | BuildingBlocks.Domain | Tenant scope. |
BrandId | BuildingBlocks.Domain | Brand scope внутри tenant. |
UserId | BuildingBlocks.Domain | Игрок. |
PartnerId | BuildingBlocks.Domain | Партнёр. |
AdminUserId | BuildingBlocks.Domain | Backoffice пользователь. |
CorrelationId | BuildingBlocks.Domain | Сквозная цепочка запроса. |
IdempotencyKey | BuildingBlocks.Domain | Детерминированный ключ операции. |
MoneyAmount | BuildingBlocks.Domain | decimal Amount + CurrencyId. |
ProviderReference | BuildingBlocks.Domain | Внешний id от провайдера. |
Email | BuildingBlocks.Domain | Валидированный email. |
PhoneNumber | BuildingBlocks.Domain | E.164 номер телефона. |
IpAddress | BuildingBlocks.Domain | IP-адрес с хешем для логов. |
SlugValue | BuildingBlocks.Domain | URL-friendly slug. |
Strongly Typed Ids
Strongly typed id предотвращает перепутывание UserId и PartnerId на уровне компилятора:
// Talent.Common.Domain
public readonly record struct UserId(Guid Value)
{
public static UserId NewId() => new(Guid.CreateVersion7());
public static UserId Empty => new(Guid.Empty);
public override string ToString() => Value.ToString();
}
Правила:
- Новые internal ids используют
Guid.CreateVersion7()(UUIDv7, monotonic). - Provider references остаются
stringvalue objects (ProviderReference), потому что внешний формат контролирует provider. - Не смешивать
UserId,UserId,PartnerId,AdminUserId— разные typed ids. - Не использовать
int/longsequence ids в публичных API.
Domain Events
Зачем Domain Events
Domain Event фиксирует факт изменения aggregate в пределах его transaction. События используются:
- для dispatch integration events через MassTransit Outbox (межсервисная интеграция);
- для запуска side effects в том же сервисе (нотификации, audit, cache invalidation через domain event handler).
Базовый Интерфейс
// Talent.Common.Domain
public interface IDomainEvent
{
Guid EventId { get; }
DateTimeOffset OccurredAt { get; }
}
public abstract record DomainEvent : IDomainEvent
{
public Guid EventId { get; } = Guid.CreateVersion7();
public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;
}
Правила Domain Events
- Domain event содержит только данные aggregate-а на момент события — без side effects, без вызовов других сервисов.
- Domain event — internal artifact. Integration event (Kafka envelope) — публичный контракт. Маппинг: handler
DomainEventDispatchBehavior. - Domain events не публикуются в Kafka напрямую; они превращаются в
IntegrationEventEnvelope<TPayload>через MassTransit Outbox. - Aggregate накапливает domain events в приватном списке, dispatch происходит через
DequeueDomainEvents()вDomainEventDispatchBehavior.
Пример
public sealed record WithdrawalApprovedEvent(
WithdrawalRequestId WithdrawalId,
UserId UserId,
ActorSnapshot Approver
) : DomainEvent;
Domain Services
Domain Service используется, когда бизнес-логика не принадлежит ни одному конкретному aggregate, но является доменной:
// Pin.Accounting.Domain/Services
public interface IWithdrawalDecision CheckService
{
WithdrawalDecision CheckResult CheckDecision Check(
WithdrawalRequest request,
UserSafetySnapshot userSafety,
KycStatusSnapshot kycStatus);
}
Правила:
- Domain service не обращается к БД; данные передаются явно как параметры.
- Domain service не содержит
IRepository,HttpClient,ILogger. - Если нужны данные из другого сервиса, handler получает их через application port и передаёт в domain service.
- Domain service возвращает typed result, не выбрасывает exceptions для controlled failures.
Когда Логика Должна Остаться В Aggregate, А Не В Domain Service
Оставляй правило в aggregate, если оно:
- меняет внутреннее состояние именно этого aggregate;
- проверяет инварианты именно этого aggregate;
- не имеет смысла отдельно от его lifecycle.
Выноси в domain service, если правило:
- объединяет несколько domain objects;
- выполняет вычисление без собственного state;
- должно переиспользоваться несколькими aggregates внутри bounded context.
Спецификации
Specification — объект, инкапсулирующий условие (predicate) для фильтрации:
// Talent.Common.Domain
public interface ISpecification<T>
{
Expression<Func<T, bool>> ToExpression();
bool IsSatisfiedBy(T entity);
}
Применение:
- Repository принимает
ISpecification<T>и применяет его к query. - Спецификации можно комбинировать:
And,Or,Not. - Сложные спецификации именуются по бизнес-смыслу:
ActiveUserWithVerifiedKycSpecification.
Domain Service vs Application Service
Различие критично — смешивание ломает инварианты:
| Критерий | Domain Service | Application Service |
|---|---|---|
| Знает о persistence? | Нет | Да (через port/repository) |
| Принимает внешние данные? | Только domain objects | Может принимать DTO из command |
| Может бросить DomainException? | Да | Нет (возвращает Result.Fail) |
| Тестируется? | Unit-тест без mocks | Unit-тест с mocks портов |
| Пример | IncentiveDecision CheckPolicy, WagerCalculator | ApproveWithdrawalCommandHandler |
// Domain Service — нет зависимостей на инфраструктуру
public sealed class IncentiveWagerCalculator
{
/// <summary>
/// Вычисляет требуемый wager amount из суммы бонуса и множителя.
/// Pure function — нет side effects, нет IO.
/// </summary>
public MoneyAmount Calculate(MoneyAmount incentiveAmount, WagerMultiplier multiplier)
{
var required = incentiveAmount.Amount * multiplier.Value;
return new MoneyAmount(Math.Round(required, 2), incentiveAmount.CurrencyId);
}
}
// Application Service (через port) — оркестрирует, не содержит бизнес-правила
public sealed class IncentiveActivationHandler : ICommandHandler<ActivateIncentiveCommand, IncentiveActivationResult>
{
private readonly IIncentiveRepository _incentiveRepo;
private readonly IAccountingPort _accountingPort; // application port
private readonly IncentiveWagerCalculator _calculator; // domain service
public async Task<Result<IncentiveActivationResult>> Handle(
ActivateIncentiveCommand cmd, CancellationToken ct)
{
var incentive = await _incentiveRepo.Get(cmd.IncentiveId, ct);
var accounting = await _accountingPort.GetBalance(cmd.UserId, ct);
// Инвариант — в domain service
var wagerRequired = _calculator.Calculate(incentive.Amount, incentive.WagerMultiplier);
// Mutation — через aggregate
var result = incentive.Activate(cmd.UserId, accounting.AvailableBalance, wagerRequired);
if (result.IsFailed) return result.ToResult<IncentiveActivationResult>();
await _incentiveRepo.Update(incentive, ct);
return Result.Ok(new IncentiveActivationResult(incentive.Id, wagerRequired));
}
}
Guard Clauses
Guard clauses — защита входных данных aggregate method до бизнес-логики:
// Talent.Common.Domain/Guards/Guard.cs
public static class Guard
{
public static T NotNull<T>(T? value, string paramName) where T : class
=> value ?? throw new DomainException($"{paramName} must not be null");
public static MoneyAmount PositiveAmount(MoneyAmount amount, string paramName)
{
if (amount.Amount <= 0)
throw new DomainException($"{paramName} must be positive, got {amount.Amount}");
return amount;
}
public static TId NotEmpty<TId>(TId id, string paramName)
where TId : struct
{
if (id.Equals(default(TId)))
throw new DomainException($"{paramName} must not be empty");
return id;
}
public static string NotNullOrEmpty(string? value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new DomainException($"{paramName} must not be empty")
: value;
}
// Использование в фабричном методе aggregate:
public static Result<WithdrawalRequest> Create(
UserId userId, TenantId tenantId, BrandId brandId,
MoneyAmount amount, ActorSnapshot actor)
{
Guard.NotEmpty(userId, nameof(userId));
Guard.NotEmpty(tenantId, nameof(tenantId));
Guard.PositiveAmount(amount, nameof(amount));
var withdrawal = new WithdrawalRequest
{
Id = WithdrawalRequestId.NewId(),
UserId = userId,
TenantId = tenantId,
BrandId = brandId,
Amount = amount,
Status = WithdrawalStatus.Requested,
};
withdrawal.RaiseDomainEvent(new WithdrawalRequestedEvent(withdrawal.Id, userId, amount));
return Result.Ok(withdrawal);
}
Policy / Rules Pattern
Бизнес-правила, применяемые в нескольких контекстах, выносятся в named Policy objects:
// Pin.Accounting.Domain/Policies/IWithdrawalDecision CheckPolicy.cs
public interface IWithdrawalDecision CheckPolicy
{
/// <summary>
/// Проверяет, может ли игрок сделать вывод с учётом KYC, лимитов и operational safety.
/// Возвращает Result.Fail с named error, а не бросает исключение.
/// </summary>
Result CheckDecision Check(UserId userId, MoneyAmount requestedAmount, UserSafetySnapshot safety);
}
public sealed class WithdrawalDecision CheckPolicy : IWithdrawalDecision CheckPolicy
{
public Result CheckDecision Check(
UserId userId, MoneyAmount requestedAmount, UserSafetySnapshot safety)
{
if (safety.IsSelfExcluded)
return Result.Fail(WithdrawalErrors.UserSelfExcluded);
if (safety.IsInCoolingOff)
return Result.Fail(WithdrawalErrors.UserInCoolingOff);
if (!safety.KycVerified)
return Result.Fail(WithdrawalErrors.KycNotVerified);
if (requestedAmount.Amount < safety.MinWithdrawalLimit)
return Result.Fail(WithdrawalErrors.AmountBelowMinimum(safety.MinWithdrawalLimit));
if (requestedAmount.Amount > safety.MaxWithdrawalLimit)
return Result.Fail(WithdrawalErrors.AmountExceedsLimit(safety.MaxWithdrawalLimit));
return Result.Ok();
}
}
Policy регистрируется как Singleton в DI и внедряется через application port или напрямую в handler.
Specification Pattern
Specification используется для фильтрации коллекций в repository и для проверки предусловий:
// Talent.Common.Domain/Specifications/
public interface ISpecification<T>
{
Expression<Func<T, bool>> Criteria { get; }
IReadOnlyList<Expression<Func<T, object>>> Includes { get; }
bool IsSatisfiedBy(T entity);
}
public abstract class Specification<T> : ISpecification<T>
{
public abstract Expression<Func<T, bool>> Criteria { get; }
public IReadOnlyList<Expression<Func<T, object>>> Includes { get; } = [];
public bool IsSatisfiedBy(T entity)
=> Criteria.Compile()(entity);
// Композиция
public Specification<T> And(Specification<T> other)
=> new AndSpecification<T>(this, other);
public Specification<T> Or(Specification<T> other)
=> new OrSpecification<T>(this, other);
public Specification<T> Not()
=> new NotSpecification<T>(this);
}
// Пример: фильтр для pending withdrawals с суммой > threshold
public sealed class LargeWithdrawalSpecification : Specification<WithdrawalRequest>
{
private readonly decimal _threshold;
public LargeWithdrawalSpecification(decimal threshold) => _threshold = threshold;
public override Expression<Func<WithdrawalRequest, bool>> Criteria
=> w => w.Status == WithdrawalStatus.Requested
&& w.Amount.Amount >= _threshold;
}
Saga / Process Manager Skeleton
Для multi-step business processes (e.g., KYC verification, payment reconciliation):
// Talent.Common.Domain/Sagas/ISagaState.cs
/// <summary>
/// Маркер состояния саги. Реализует ISagaStateMachineInstance для MassTransit.
/// Хранится в PostgreSQL, управляется MassTransit Saga State Machine.
/// </summary>
public interface ISagaState : ISagaStateMachineInstance
{
Guid CorrelationId { get; set; } // = AggregateId саги
string CurrentState { get; set; }
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset UpdatedAt { get; set; }
}
// Пример: state для KYC verification saga
public sealed class KycVerificationSagaState : ISagaState
{
public Guid CorrelationId { get; set; } // = UserId
public string CurrentState { get; set; } = default!;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
// Saga-specific state
public string? DocumentSetId { get; set; }
public string? ProviderCheckId { get; set; }
public int RetryCount { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
}
// Saga transitions объявляются в Application layer через MassTransit StateMachine
// (см. docs/10-systems/kyc/kyc-saga.md)
Правила:
- Saga state хранится в PostgreSQL (не Redis) — персистентен и аудируем.
- Каждый transition — строка в audit trail.
- Saga не содержит бизнес-логику — только управление состоянием и dispatch команд.
- Timeout events регистрируются через MassTransit
Schedule.
Где НЕ Нужно DDD
| Ситуация | Альтернатива |
|---|---|
| Простой CRUD для admin-only dictionary | Thin application service + EF direct |
Lookup-справочники (handbook) | Simple repository + cache |
| Analytics query | Query handler + ClickHouse provider |
| Search/filter | Query handler + Elasticsearch repository |
| Read-only projection | Query handler + ReadRepository |
| Background reconciliation job | Application service + job handler |
Не навязывай Rich Model там, где её нет. Пустые domain methods ради DDD-формы только усложняют код.
Checklist Rich Model
Перед merge проверь:
- aggregate действительно защищает ключевые invariants;
- handler не дублирует domain decision, уже существующий в aggregate;
- у сущности нет лишних public setters;
- domain events поднимаются из domain logic, а не "снаружи постфактум";
- value objects убрали primitive obsession там, где это реально снижает ошибки;
- tests доказывают, что illegal transitions и boundary conditions контролируются моделью.
Правила Слоёв И DDD
| Слой | Содержит | Не Содержит |
|---|---|---|
Domain | Aggregates, Entities, Value Objects, Domain Events, Domain Services, Specifications, Domain Errors | DbContext, HTTP clients, ILogger, IServiceProvider, DI registration |
Application | Handlers, Validators, Ports (interfaces), Application Services, Policies, Mappers | EF configurations, raw HTTP clients, Redis/Elasticsearch clients |
Infrastructure | EF DbContext, repositories, external adapters, Redis/Elasticsearch/ClickHouse clients, provider adapters | Business logic, domain invariants |
Api | Controllers, Request/Response DTO, Filters, Auth Policies, OpenAPI | Business decisions, repository access |
Domain layer — чистый C#, без NuGet dependencies на ASP.NET, EF, Redis, Kafka, Serilog.
DDD + CQRS + Outbox: Интеграция
HTTP Request
→ Controller (thin, maps to Command)
→ MediatR Pipeline (validation, auth, idempotency, transaction)
→ CommandHandler
→ Load Aggregate from IRepository
→ Call Aggregate domain method
→ Aggregate raises DomainEvent
→ Save Aggregate
→ DomainEventDispatchBehavior
→ DequeueDomainEvents()
→ Map to IntegrationEvent
→ Publish via MassTransit Outbox (in same transaction)
→ AuditBehavior (same transaction)
→ Commit
→ MassTransit delivery service sends to Kafka
→ Consumer in another service
→ MassTransit Inbox (deduplication)
→ Application Command/Projection
Ссылки
- Base Domain And Contract Models
- Domain Invariants Catalog
- DDD Overview
- Event Contracts
- MediatR Pipeline Behaviors
- 02 — Base Entities, Documents, Events
Обратные ссылки
Автоссылки: где используется этот документ.
- adr (1): ADR-0039: DDD Rich Model vs Anemic Model — Правила Выбора
- index.md (1): Backend SDK — Руководство Разработчика
- 02-base-entities-events.md (1): Base Entities, Documents, Events — Практическое Руководство
- 13-code-quality-and-typing.md (1): Code Quality И Strong Typing — Enterprise .NET 10 Standards
- backend-rules.md (1): Правила Backend Разработки
- backend-shared-standards-index.md (1): Backend Shared Standards Index
- ddd-rich-model-rules.md (1): DDD И Rich Model: Правила И Практика