Перейти к основному содержимому

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.
WithdrawalStateful FSM: requested → approved → processing → completed/failed/cancelled.
DepositIdempotent provider callback; нужна проверка state до mutation.
Incentive / UserIncentiveАктивация, wagering progress, expiry, forfeit — сложные правила.
Game Session / RoundBet → Win/Rollback; stateful с idempotency.
KYC VerificationSubmitted → Approved/Rejected, re-verification, evidence snapshots.
User SafetyLimits, self-exclusion, cooling-off — fail-closed при нарушении.
Partner CommissionРасчёт, accrual, payout request — финансовый учёт.
Fraud CaseEvidence, 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 DictionaryAdmin-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 amountMoneyAmount);
  • инкапсуляции правил валидации (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ПакетНазначение
TenantIdBuildingBlocks.DomainTenant scope.
BrandIdBuildingBlocks.DomainBrand scope внутри tenant.
UserIdBuildingBlocks.DomainИгрок.
PartnerIdBuildingBlocks.DomainПартнёр.
AdminUserIdBuildingBlocks.DomainBackoffice пользователь.
CorrelationIdBuildingBlocks.DomainСквозная цепочка запроса.
IdempotencyKeyBuildingBlocks.DomainДетерминированный ключ операции.
MoneyAmountBuildingBlocks.Domaindecimal Amount + CurrencyId.
ProviderReferenceBuildingBlocks.DomainВнешний id от провайдера.
EmailBuildingBlocks.DomainВалидированный email.
PhoneNumberBuildingBlocks.DomainE.164 номер телефона.
IpAddressBuildingBlocks.DomainIP-адрес с хешем для логов.
SlugValueBuildingBlocks.DomainURL-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 остаются string value objects (ProviderReference), потому что внешний формат контролирует provider.
  • Не смешивать UserId, UserId, PartnerId, AdminUserId — разные typed ids.
  • Не использовать int/long sequence 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 ServiceApplication Service
Знает о persistence?НетДа (через port/repository)
Принимает внешние данные?Только domain objectsМожет принимать DTO из command
Может бросить DomainException?ДаНет (возвращает Result.Fail)
Тестируется?Unit-тест без mocksUnit-тест с mocks портов
ПримерIncentiveDecision CheckPolicy, WagerCalculatorApproveWithdrawalCommandHandler
// 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 dictionaryThin application service + EF direct
Lookup-справочники (handbook)Simple repository + cache
Analytics queryQuery handler + ClickHouse provider
Search/filterQuery handler + Elasticsearch repository
Read-only projectionQuery handler + ReadRepository
Background reconciliation jobApplication 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

СлойСодержитНе Содержит
DomainAggregates, Entities, Value Objects, Domain Events, Domain Services, Specifications, Domain ErrorsDbContext, HTTP clients, ILogger, IServiceProvider, DI registration
ApplicationHandlers, Validators, Ports (interfaces), Application Services, Policies, MappersEF configurations, raw HTTP clients, Redis/Elasticsearch clients
InfrastructureEF DbContext, repositories, external adapters, Redis/Elasticsearch/ClickHouse clients, provider adaptersBusiness logic, domain invariants
ApiControllers, Request/Response DTO, Filters, Auth Policies, OpenAPIBusiness 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

Ссылки

Обратные ссылки

Автоссылки: где используется этот документ.