Когда я впервые погрузился в мир Domain-Driven Design, мне показалось, что это очередная модная методология, которая скоро канет в лету. Однако годы практики убедили меня в обратном. DDD — не просто набор инструментов и шаблонов, а целая философия разработки, которая радикально меняет подход к созданию сложных программных систем. В основе лежит простая идея: программное обеспечение должно быть ориентировано на бизнес-домен, а не на технические детали реализации. Другими словами, ваш код должен говорить на языке бизнеса, а не на языке технологий. Это звучит очевидно, но я видел десятки проектов, где разработчики настолько увлекались технологиями, что теряли связь с реальным бизнес-контекстом.
Основные принципы DDD для C# разработчиков
Предметно-ориентированное проектирование ставит домен (предметную область) во главу угла. Вместо того чтобы начинать с таблиц базы данных или объектных моделей, DDD предлагает начать с глубокого понимания бизнес-процессов, их правил и ограничений. В контексте C# это означает, что ваши классы, интерфейсы и методы должны отражать термины, используемые экспертами предметной области.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // Традиционный подход
public class Usr {
public int Id { get; set; }
public string FName { get; set; }
public string LName { get; set; }
public DateTime DOB { get; set; }
public bool IsActive { get; set; }
}
// DDD подход
public class Customer {
public CustomerId Id { get; private set; }
public FullName Name { get; private set; }
public DateOfBirth BirthDate { get; private set; }
public bool IsActive { get; private set; }
public void Deactivate() {
// Бизнес-логика деактивации клиента
IsActive = false;
}
} |
|
Обратите внимание, как во втором примере модель гораздо ближе к языку бизнеса. Здесь нет технических сокращений, а есть полноценные бизнес-понятия. Кроме того, изменение состояния происходит через методы, которые инкапсулируют бизнес-правила, а не через публичные сеттеры. Для C# разработчиков есть несколько ключевых преимуществ при использовании DDD:
1. Улучшение коммуникации между разработчиками и экспертами предметной области. Когда ваш код использует те же термины, что и бизнес, становится гораздо легче обсуждать требования и изменения.
2. Снижение сложности за счет четкого разделения ответственности. DDD предлагает четкую структуру проекта с выделенными слоями: домен, приложение, инфраструктура, и пользовательский интерфейс.
3. Повышение гибкости и адаптивности кода. При правильном применении DDD бизнес-логика изолирована от технических деталей, что облегчает внесение изменений.
За годы своего существования Domain-Driven Design значительно эволюционировал. Если изначально, в книге Эрика Эванса "Domain-Driven Design: Tackling Complexity in the Heart of Software" (2003), акцент делался на моделировании домена, то сегодня DDD тесно переплетается с другими подходами: микросервисами, CQRS, Event Sourcing и функциональным программированием.
В контексте C# это особенно интересно, поскольку язык и платформа .NET также претерпели существенную эволюцию. Внедрение типа record в C# 9.0 идеально подходит для создания неизменяемых объектов-значений (Value Objects) — одного из ключевых строительных блоков DDD:
C# | 1
2
3
4
5
6
7
8
9
10
11
| // C# 9.0 record для объекта-значения
public record Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (other.Currency != Currency)
throw new InvalidOperationException("Нельзя складывать деньги в разных валютах");
return this with { Amount = Amount + other.Amount };
}
} |
|
Эволюция мышления в DDD привела к переосмыслению многих ключевых концепций. Если в классическом подходе Эванса агрегаты могли быть довольно крупными и сложными, то современные интерпретации тяготеют к более мелким, сфокусированым агрегатам. Это согласуется с принципами микросервисной архитектуры и облегчает масштабирование. Интересно также наблюдать, как в современом DDD больше внимания уделяется событиям (Events). Мой опыт показывает, что моделирование бизнес-процесов через события часто приводит к более гибкой и расширяемой архитектуре. В C# это реализуется через систему событий:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public class OrderPlaced : IDomainEvent
{
public Guid OrderId { get; }
public Guid CustomerId { get; }
public decimal TotalAmount { get; }
public DateTime OccurredOn { get; }
public OrderPlaced(Guid orderId, Guid customerId, decimal totalAmount)
{
OrderId = orderId;
CustomerId = customerId;
TotalAmount = totalAmount;
OccurredOn = DateTime.UtcNow;
}
} |
|
Одной из самых сложных проблем при внедрении DDD является переход от существующих монолитных приложений к DDD-ориентированной архитектуре. Тут нет универсальных рецептов, но есть несколько стратегий, которые я успешно применял:
1. Стратегия "Удушения" (Strangler Pattern): постепенно выделяйте части монолита в отдельные сервисы, каждый из которых спроэктирован согласно DDD-принципам.
2. Стратегия "Швов" (Seams): идентифицируйте естественные границы в вашем приложении и используйте их для создания ограниченных контекстов (bounded contexts).
3. Стратегия "Антикоррупционного слоя" (Anti-corruption Layer): создайте слой трансляции между старым монолитом и новыми DDD-сервисами, чтобы защитить новую модель от влияния старой.
Мой личный опыт показывает, что постепенный переход работает лучше всего. Не стоит пытатся переписать всё сразу — это почти всегда приводит к провалу. Вместо этого выбирайте небольшие, хорошо ограниченные части системы и преобразуйте их по очереди.
Вызов, с которым я часто сталкивался при внедрении DDD — сопротивление команды. "Слишком академично", "слишком много абстракций", "слишком много сущностей" — это лишь малая часть возражений, которые вы услышите. И они имеют право на жизнь! DDD действительно добавляет некоторую сложность, особенно в начале пути. Однако эта сложность окупается, когда ваш проект разрастается.
Критически важным аспектом DDD является концепция Единого Языка (Ubiquitous Language). Это не просто набор терминов — это живой словарь, который используют как разработчики, так и бизнес-эксперты. В C# проектах Единый Язык проявляется везде: в именах классов, методов, переменных и даже комментариях.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // Без единого языка
public class OrdItemManager {
public void ProcItems(List<OrdItem> items) {
foreach (var item in items) {
if (item.Qty > 0 && item.Status != 3) {
// Логика обработки...
}
}
}
}
// С единым языком
public class OrderLineProcessor {
public void ProcessPendingOrderLines(List<OrderLine> orderLines) {
foreach (var line in orderLines) {
if (line.Quantity > 0 && line.Status != OrderLineStatus.Canceled) {
// Логика обработки...
}
}
}
} |
|
Ограниченные контексты (Bounded Contexts) — еще один фундаментальный принцип DDD, который помогает справиться со сложностью больших систем. Это отдельные предметные области внутри системы, каждая со своей терминологией и моделями. Например, в системе электронной коммерции "Продукт" в контексте каталога и в контексте склада может иметь совершенно разные атрибуты и поведение. В C# удобно выделять ограниченные контексты на уровне проектов или пространств имен:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| namespace Ecommerce.Catalog {
public class Product {
public string Name { get; private set; }
public string Description { get; private set; }
public decimal Price { get; private set; }
// Атрибуты и методы, релевантные для каталога
}
}
namespace Ecommerce.Warehouse {
public class Product {
public string SKU { get; private set; }
public int StockLevel { get; private set; }
public string StorageLocation { get; private set; }
// Атрибуты и методы, релевантные для склада
}
} |
|
Помню случай, когда в одном проекте мы спорили два дня из-за термина "Клиент". Для отдела продаж клиентом был человек, который что-то купил. Для маркетинга — потенциальный покупатель. А для юристов — контрагент с договором. Вмсто того чтобы выбрать один "правильный" термин, мы использовали ограниченные контексты, где каждый отдел работал со своей моделью "Клиента".
Одной из мощных техник, которую я часто использую в DDD-проектах — Агрегаты (Aggregates). Агрегат — это кластер объектов, который мы рассматриваем как единое целое с точки зрения изменений данных. У каждого агрегата есть корень (Aggregate Root), через который идет все взаимодействие с объектами внутри агрегата.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| public class Order // Корень агрегата
{
private readonly List<OrderLine> _orderLines = new List<OrderLine>();
public IReadOnlyCollection<OrderLine> OrderLines => _orderLines.AsReadOnly();
public void AddProduct(Product product, int quantity)
{
// Проверка бизнес-правил перед добавлением
if (IsLocked)
throw new OrderLockedException("Нельзя изменять заблокированный заказ");
var existingLine = _orderLines.FirstOrDefault(ol => ol.ProductId == product.Id);
if (existingLine != null)
{
existingLine.IncreaseQuantity(quantity);
}
else
{
_orderLines.Add(new OrderLine(product.Id, quantity, product.Price));
}
}
} |
|
В этом примере Order — корень агрегата, инкапсулирующий коллекцию OrderLine . Обратите внимание, как коллекция делаетя доступной только для чтения извне, а любые изменения проходят через методы корня агрегата, который обеспечивает соблюдение бизнес-правил.
Интересно наблюдать, как DDD естественно сочетается с другими современными подходами к разработке. Например, в одном проекте мы объеденили DDD с CQRS (Command Query Responsibility Segregation), что позволило нам иметь сложную доменную модель для команд и простые модели для запросов, оптимизированные под конкретные сценарии использования.
Основы DDD и понятие сущности Помогите, пожалуйста, разобраться
Если я правильно понял, сущность это то, что выживает между... Есть у кого примеры DDD проектов? 1) Писали ли вы в стиле ДДД? Как он , этот опыт?
2) Есть ли примеры на шарпе(актуальные, которые... DDD и большое количество связей Всем привет! Бываю редко, но всегда метко. Спасибо форуму!
Собственно, вопрос:
Есть... DDD, проектирование API Вопрос скорее теоретический
Недавно столкнулся с таким понятием как Domain Driver Design. Теперь...
Практическая реализация базовых элементов DDD
После изучения теоретических основ пора перейти к самому интересному — практической реализации базовых элементов DDD в C#. Когда я только начинал внедрять DDD в своих проектах, меня поразило, насколько гармонично многие конструкции C# соответствуют концепциям предметно-ориентированного проектирования.
Сущности и объекты-значения: фундамент вашего домена
Сущности (Entities) и объекты-значения (Value Objects) — два краеугольных камня, на которых строится вся доменная модель. Главное различие между ними заключается в наличии или отсутствии идентичности. Сущности определяются через их идентификатор, а не через атрибуты. Два клиента с одинаковыми именами и адресами всё равно являются разными клиентами, если у них разные ID:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public class Customer
{
public Guid Id { get; private set; }
public string FullName { get; private set; }
public Address HomeAddress { get; private set; }
// Конструктор, методы и прочее
public override bool Equals(object obj)
{
if (obj is not Customer other) return false;
return Id == other.Id; // Сравнение только по ID
}
public override int GetHashCode() => Id.GetHashCode();
} |
|
А вот объекты-значения не имеют идентификатора — они определяются исключительно своими свойствами. Два адреса с одинаковыми улицей, городом и индексом считаются одинаковыми:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| public class Address : IEquatable<Address>
{
public string Street { get; }
public string City { get; }
public string ZipCode { get; }
public Address(string street, string city, string zipCode)
{
// Валидация
Street = street;
City = city;
ZipCode = zipCode;
}
public bool Equals(Address other)
{
if (other is null) return false;
return Street == other.Street &&
City == other.City &&
ZipCode == other.ZipCode;
}
public override bool Equals(object obj) => Equals(obj as Address);
public override int GetHashCode() =>
HashCode.Combine(Street, City, ZipCode);
} |
|
Иммутабельность и record-типы: мощный дуэт
Одна из лучших практик при работе с объектами-значениями — делать их неизменяемыми (иммутабельными). Это гарантирует, что после создания объекта его состояния не изменится, что в свою очередь упрощает рассуждения о коде и делает его более предсказуемым. В C# 9.0 появились record-типы, которые идеально подходят для создания иммутабельных объектов-значений:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public record Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (other.Currency != Currency)
throw new InvalidOperationException("Валюты должны совпадать");
return this with { Amount = Amount + other.Amount };
}
public Money Subtract(Money other)
{
if (other.Currency != Currency)
throw new InvalidOperationException("Валюты должны совпадать");
return this with { Amount = Amount - other.Amount };
}
} |
|
Преимущество record-типов в том, что компилятор автоматически генерирует методы для сравнения по значению и создания новых экземпляров путем неразрушающего обновления (non-destructive mutation) через синтаксис with .
Агрегаты: охрана бизнес-правил
Агрегат — это кластер объектов, объединенных в единую смысловую единицу с точки зрения изменения данных. Каждый агрегат имеет корень (Aggregate Root), который служит точкой входа для всех операций над объектами внутри агрегата.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
| public class Order // Корень агрегата
{
private readonly List<OrderLine> _orderLines = new();
private bool _isSubmitted;
public Guid Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public IReadOnlyCollection<OrderLine> OrderLines => _orderLines.AsReadOnly();
public OrderStatus Status { get; private set; }
public Order(CustomerId customerId)
{
Id = Guid.NewGuid();
CustomerId = customerId;
Status = OrderStatus.Draft;
}
public void AddProduct(ProductId productId, int quantity, Money unitPrice)
{
if (_isSubmitted)
throw new OrderAlreadySubmittedException($"Заказ {Id} уже подтвержден и не может быть изменен.");
if (quantity <= 0)
throw new InvalidQuantityException("Количество должно быть положительным числом");
var existingLine = _orderLines.FirstOrDefault(line => line.ProductId == productId);
if (existingLine != null)
{
_orderLines.Remove(existingLine);
_orderLines.Add(existingLine.WithQuantity(existingLine.Quantity + quantity));
}
else
{
_orderLines.Add(new OrderLine(productId, quantity, unitPrice));
}
}
public void Submit()
{
if (_isSubmitted)
throw new OrderAlreadySubmittedException($"Заказ {Id} уже подтвержден.");
if (!_orderLines.Any())
throw new EmptyOrderException("Невозможно подтвердить пустой заказ");
_isSubmitted = true;
Status = OrderStatus.Submitted;
// Здесь может быть логика добавления доменного события
}
} |
|
Обратите внимание на несколько важных приемов:
1. Коллекция _orderLines доступна извне только для чтения.
2. Внутренние детали, такие как флаг _isSubmitted , скрыты от внешнего мира.
3. Все изменения состояния проходят через методы домена (AddProduct , Submit ), которые обеспечивает соблюдение бизнес-правил.
Микро-агрегаты: меньше значит лучше
В последние годы получил популярность подход к созданию небольших, сфокусированных агрегатов — так называемых микро-агрегатов. Этот термин ввел Вернон Вон в своей книге "Implementing Domain-Driven Design". Преимущества микро-агрегатов:
1. Улучшення производительность — меньше данных загружается и сохраняется за одну транзакцию.
2. Снижение конфликтов параллельного доступа.
3. Улучшение масштабируемости.
В одном из моих проектов мы разбили большой агрегат Order (заказ) на несколько меньших: OrderHeader (основная информация о заказе), OrderBilling (платежная информация), OrderShipment (информация о доставке). Это значительно упростило систему и улучшило её производительность.
Валидация домена через инварианты
Инварианты — это условия, которые должны всегда выполняться для поддержания целостности домена. Например, баланс счета не может быть отрицательным, или количество товара в заказе должно быть положительным числом. В C# есть несколько способов реализации валидации:
1. Через исключения в методах (как в примерах выше).
2. Через Guard-классы:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public static class Guard
{
public static void AgainstNegativeOrZero(int value, string paramName)
{
if (value <= 0)
throw new ArgumentException($"{paramName} должен быть положительным числом", paramName);
}
public static void AgainstNullOrEmpty(string value, string paramName)
{
if (string.IsNullOrEmpty(value))
throw new ArgumentException($"{paramName} не может быть пустым", paramName);
}
}
// Использование
public void AddProduct(ProductId productId, int quantity, Money unitPrice)
{
Guard.AgainstNegativeOrZero(quantity, nameof(quantity));
// Остальная логика...
} |
|
3. Через специализированные доменные исключения:
C# | 1
2
3
4
5
6
7
8
9
10
| public class NegativeQuantityException : DomainException
{
public NegativeQuantityException(int quantity)
: base($"Количество {quantity} не может быть отрицательным") { }
}
public abstract class DomainException : Exception
{
protected DomainException(string message) : base(message) { }
} |
|
Использование специализированных исключений делает код более выразительным и упрощает обработку ошибок на верхних уровнях.
Value Objects и неявные конверторы
Интересный прием при работе с объектами-значениями — использование неявных конверторов. Они позволяют создавать более элегантный API:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| public record UserId
{
public Guid Value { get; }
private UserId(Guid value) => Value = value;
public static UserId Create() => new(Guid.NewGuid());
public static UserId FromGuid(Guid guid) => new(guid);
public static implicit operator Guid(UserId userId) => userId.Value;
public static implicit operator UserId(Guid guid) => new(guid);
public override string ToString() => Value.ToString();
}
// Использование
public void AssignTask(UserId assignee)
{
// Методы...
}
// Можно вызвать так
Guid guid = Guid.NewGuid();
service.AssignTask(guid); // Неявное преобразование Guid в UserId |
|
Такой подход делает код более естесственным в использовании, сохраняя при этом типобезопасность.
В своей практике я часто сталкивался с ситуацией, когда объекты-значения создавались только для удобства программистов, но потом обрастали логикой. Например, объект Email начинался как простой контейнер для строки, но потом обзаводился методами для проверки валидности, форматирования, маскирования и т.д. Это подтверждает ценность объектно-ориентированного подхода в DDD.
Хорошей практикой является отделение бизнес-ошибок от технических. Для этого можно использовать базовые классы исключений:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // Базовый класс для всех доменных исключений
public abstract class DomainException : Exception
{
protected DomainException(string message) : base(message) { }
}
// Базовый класс для нарушений бизнес-правил
public abstract class BusinessRuleViolationException : DomainException
{
protected BusinessRuleViolationException(string message) : base(message) { }
}
// Конкретное исключение
public class InsufficientFundsException : BusinessRuleViolationException
{
public InsufficientFundsException(Money available, Money required)
: base($"Недостаточно средств. Доступно: {available}, Требуется: {required}") { }
} |
|
Оптимизация производительности DDD-моделей
Когда речь заходит о высоконагруженных приложениях, многие разработчики скептически относятся к DDD, считая, что сложные доменные модели неизбежно приводят к проблемам с производительностью. Я сам когда-то придерживался этого предубеждения, пока не научился применять определенные приемы оптимизации. Один из самых эффективных подходов — ленивая загрузка коллекций внутри агрегатов. Вместо того чтобы всегда загружать все дочерние элементы, можно загружать их только по мере необходимости:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
| public class Order
{
private readonly Lazy<List<OrderLine>> _orderLines;
public Order(IOrderLinesRepository repository)
{
_orderLines = new Lazy<List<OrderLine>>(() =>
repository.GetLinesForOrder(Id).ToList());
}
public IReadOnlyCollection<OrderLine> OrderLines => _orderLines.Value.AsReadOnly();
} |
|
Еще одна техника — использование проекций вместо полной доменной модели для запросов. Ваш домен может быть сложным, но это не значит, что вы должны использовать все эту сложность для простого отображения данных:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // Запрос через проекцию
public IEnumerable<OrderSummary> GetOrderSummaries(CustomerId customerId)
{
return _dbContext.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new OrderSummary
{
Id = o.Id,
Date = o.CreatedAt,
Total = o.OrderLines.Sum(ol => ol.Quantity * ol.UnitPrice),
Status = o.Status
})
.ToList();
} |
|
В некоторых случаях также полезно использовать кэширование для редко изменяемых объектов-значений или сущностей. В одном проекте мы значительно улучшили производительность, кэшируя объекты-справочники:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public class CachedProductRepository : IProductRepository
{
private readonly IProductRepository _innerRepository;
private readonly IMemoryCache _cache;
public async Task<Product> GetByIdAsync(ProductId id)
{
string cacheKey = $"Product_{id}";
if (!_cache.TryGetValue(cacheKey, out Product product))
{
product = await _innerRepository.GetByIdAsync(id);
_cache.Set(cacheKey, product, TimeSpan.FromMinutes(10));
}
return product;
}
} |
|
Техники моделирования доменных исключений
Мой опыт подсказывает, что граматно спроектированная система обработки ошибок сильно упрощает жизнь. В контексте DDD особенно важно различать технические ошибки (например, база данных недоступна) и бизнес-ошибки (нельзя отменить уже отправленный заказ). Я разработал следующую иерархию исключений, которая хорошо работает на практике:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| // Базовое исключение для всего домена
public abstract class DomainException : Exception
{
protected DomainException(string message) : base(message) {}
}
// Нарушение бизнес-правил
public class BusinessRuleViolationException : DomainException
{
public BusinessRuleViolationException(string rule)
: base($"Нарушено бизнес-правило: {rule}") {}
}
// Недействительное состояние агрегата
public class InvalidAggregateStateException : DomainException
{
public InvalidAggregateStateException(string aggregate, string expectedState, string actualState)
: base($"Агрегат {aggregate} находится в состоянии {actualState}, но ожидалось {expectedState}") {}
}
// Сущность не найдена
public class EntityNotFoundException : DomainException
{
public EntityNotFoundException(string entityType, object id)
: base($"Сущность {entityType} с идентификатором {id} не найдена") {}
} |
|
Интересный подход, который я часто использую — Result-объекты вместо исключений для ожидаемых бизнес-ошибок:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| public class Result<T>
{
public bool IsSuccess { get; }
public T Value { get; }
public string Error { get; }
public bool IsFailure => !IsSuccess;
private Result(bool isSuccess, T value, string error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}
public static Result<T> Success(T value) => new Result<T>(true, value, null);
public static Result<T> Failure(string error) => new Result<T>(false, default, error);
}
// Использование
public Result<Order> PlaceOrder(CustomerId customerId, IEnumerable<OrderItem> items)
{
// Проверка наличия товаров
foreach (var item in items)
{
if (!_inventoryService.IsInStock(item.ProductId, item.Quantity))
return Result<Order>.Failure($"Товар {item.ProductId} отсутствует в нужном количестве");
}
// Создание заказа
var order = new Order(customerId);
foreach (var item in items)
{
order.AddProduct(item.ProductId, item.Quantity, item.UnitPrice);
}
_orderRepository.Add(order);
return Result<Order>.Success(order);
} |
|
Этот подход особенно хорошо работает в публичных API и избавляет от громоздких блоков try-catch.
Контракты и инварианты в C#
Контракты — это формальное выражение пред- и постусловий методов. В C# их можно реализовать разными способами, включая атрибуты Code Contracts, которые, к сожелению, устарели. Но можно использовать и более современные подходы:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| public static class Contract
{
public static void Requires<TException>(bool condition, string message = null)
where TException : Exception, new()
{
if (!condition)
{
if (string.IsNullOrEmpty(message))
throw Activator.CreateInstance<TException>();
throw (Exception)Activator.CreateInstance(typeof(TException), message);
}
}
}
// Использование
public void Transfer(Account target, Money amount)
{
Contract.Requires<ArgumentNullException>(target != null, "Целевой счет не может быть null");
Contract.Requires<ArgumentException>(amount.Amount > 0, "Сумма должна быть положительной");
Contract.Requires<InsufficientFundsException>(Balance.Amount >= amount.Amount,
"Недостаточно средств для перевода");
Balance = Balance.Subtract(amount);
target.Balance = target.Balance.Add(amount);
} |
|
В сложных доменах контракты и инварианты становятся неотъемлемой частью модели, защищая её от некорректных состояний. Один из моих любимых приемов — делегировать валидацию специализированым объектам-политикам (Policy Objects):
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| public interface IOrderPolicy
{
bool CanSubmit(Order order);
bool CanCancel(Order order);
bool CanAddProduct(Order order, Product product, int quantity);
}
public class StandardOrderPolicy : IOrderPolicy
{
private readonly ISpecification<Order> _submittableOrderSpec;
private readonly ISpecification<Order> _cancellableOrderSpec;
public StandardOrderPolicy(
ISpecification<Order> submittableOrderSpec,
ISpecification<Order> cancellableOrderSpec)
{
_submittableOrderSpec = submittableOrderSpec;
_cancellableOrderSpec = cancellableOrderSpec;
}
public bool CanSubmit(Order order) => _submittableOrderSpec.IsSatisfiedBy(order);
public bool CanCancel(Order order) => _cancellableOrderSpec.IsSatisfiedBy(order);
public bool CanAddProduct(Order order, Product product, int quantity)
{
return !order.IsSubmitted && quantity > 0 && product.IsActive;
}
} |
|
Такой подход хорошо сочетается с паттерном Спецификация, который мы рассмотрим в следующей главе.
Тактические паттерны DDD в C# проектах
После того как мы определились с фундаментом доменной модели, пора перейти к тактическим паттернам — конкретным приёмам, которые помогают эффективно реализовать DDD в коде. Я часто сравниваю эти паттерны с набором лего-блоков: каждый блок сам по себе прост, но в сочетании они позволяют создавать невероятно сложные и выразительные конструкции.
Репозитории: мост между доменом и хранилищем данных
Репозиторий — один из ключевых паттернов DDD, который абстрагирует доступ к данным. Он выступает в роли коллекции объектов домена в памяти, скрывая детали их хранения и загрузки:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public interface IRepository<T, TId> where T : IAggregateRoot
{
Task<T> GetByIdAsync(TId id);
Task<IEnumerable<T>> GetAllAsync();
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(TId id);
}
public interface IOrderRepository : IRepository<Order, Guid>
{
Task<IEnumerable<Order>> GetByCustomerIdAsync(Guid customerId);
Task<IEnumerable<Order>> GetPendingOrdersAsync();
} |
|
В реальности я редко использую столь обобщённые интерфейсы, предпочитая специализированые репозитории под каждый агрегат. Общие методы часто оказываются недостаточными для выражения специфики домена:
C# | 1
2
3
4
5
6
7
8
| public interface IOrderRepository
{
Task<Order> GetByIdAsync(OrderId id);
Task<IEnumerable<Order>> GetByCustomerIdAsync(CustomerId customerId);
Task<IEnumerable<Order>> GetPendingOrdersAsync();
Task AddAsync(Order order);
Task UpdateAsync(Order order);
} |
|
Помню случай, когда мы рефакторили большой проект с десятками сущностей. Изначально был создан один огромный обобщённый репозиторий, и он стал настоящим болотом, в котором разработчики постоянно терялись. После перехода на специализированные репозитории ситуация радикально улучшилась — интерфейсы стали более выразительными и лучше отражали доменную логику.
Сервисы домена: когда логика не вписывается в сущности
Сервисы домена (Domain Services) — это механизм для реализации бизнес-логики, которая не вписывается в рамки одной сущности или агрегата. Классический пример — перевод денег между счетами:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| public interface IPaymentService
{
Task<Result<TransactionId>> TransferMoneyAsync(
AccountId sourceAccountId,
AccountId targetAccountId,
Money amount);
}
public class PaymentService : IPaymentService
{
private readonly IAccountRepository _accountRepository;
private readonly ITransactionRepository _transactionRepository;
public PaymentService(
IAccountRepository accountRepository,
ITransactionRepository transactionRepository)
{
_accountRepository = accountRepository;
_transactionRepository = transactionRepository;
}
public async Task<Result<TransactionId>> TransferMoneyAsync(
AccountId sourceAccountId,
AccountId targetAccountId,
Money amount)
{
var sourceAccount = await _accountRepository.GetByIdAsync(sourceAccountId);
var targetAccount = await _accountRepository.GetByIdAsync(targetAccountId);
if (sourceAccount == null)
return Result<TransactionId>.Failure("Исходный счёт не найден");
if (targetAccount == null)
return Result<TransactionId>.Failure("Целевой счёт не найден");
if (!sourceAccount.CanWithdraw(amount))
return Result<TransactionId>.Failure("Недостаточно средств");
sourceAccount.Withdraw(amount);
targetAccount.Deposit(amount);
await _accountRepository.UpdateAsync(sourceAccount);
await _accountRepository.UpdateAsync(targetAccount);
var transaction = new Transaction(sourceAccountId, targetAccountId, amount);
await _transactionRepository.AddAsync(transaction);
return Result<TransactionId>.Success(transaction.Id);
}
} |
|
Важно понимать, что сервисы домена не должны содержать слишком много логики или превращаться в антипаттерн "Анемичная модель". Их задача — координировать работу нескольких агрегатов, а не забирать у них ответственность.
Фабрики: создание сложных объектов
Фабрики (Factories) в DDD отвечают за создание сложных объектов или целых агрегатов. Они инкапсулируют процес создания и гарантируют, что объекты создаются в валидном состоянии:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| public interface IOrderFactory
{
Order CreateOrder(CustomerId customerId, IEnumerable<OrderLineDto> orderLines);
}
public class OrderFactory : IOrderFactory
{
private readonly IProductRepository _productRepository;
public OrderFactory(IProductRepository productRepository)
{
_productRepository = productRepository;
}
public Order CreateOrder(CustomerId customerId, IEnumerable<OrderLineDto> orderLines)
{
var order = new Order(customerId);
foreach (var line in orderLines)
{
var product = _productRepository.GetByIdAsync(line.ProductId).Result;
if (product == null)
throw new ProductNotFoundException(line.ProductId);
order.AddProduct(product.Id, line.Quantity, product.Price);
}
return order;
}
} |
|
На практике я часто реализую фабрики как статические методы внутри самих классов (Factory Method) для простых случаев или как отдельные классы для сложных сценариев.
Спецификации: инкапсуляция правил выборки
Паттерн Спецификация (Specification) позволяет инкапсулировать правила фильтрации или проверки объектов. Это особенно полезно, когда логика выборки или валидации становится сложной и должна использоваться в разных частях приложения:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| public interface ISpecification<T>
{
bool IsSatisfiedBy(T entity);
Expression<Func<T, bool>> ToExpression();
}
public class OverdueInvoiceSpecification : ISpecification<Invoice>
{
private readonly DateTime _currentDate;
public OverdueInvoiceSpecification(DateTime? currentDate = null)
{
_currentDate = currentDate ?? DateTime.UtcNow;
}
public bool IsSatisfiedBy(Invoice invoice)
{
return invoice.DueDate < _currentDate && !invoice.IsPaid;
}
public Expression<Func<Invoice, bool>> ToExpression()
{
return invoice => invoice.DueDate < _currentDate && !invoice.IsPaid;
}
} |
|
Преимущество такого подхода в том, что специфакации можно комбинировать, используя логические операторы (And, Or, Not). Для этого часто используют паттерн Composite:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| public class AndSpecification<T> : ISpecification<T>
{
private readonly ISpecification<T> _left;
private readonly ISpecification<T> _right;
public AndSpecification(ISpecification<T> left, ISpecification<T> right)
{
_left = left;
_right = right;
}
public bool IsSatisfiedBy(T entity)
{
return _left.IsSatisfiedBy(entity) && _right.IsSatisfiedBy(entity);
}
public Expression<Func<T, bool>> ToExpression()
{
var leftExpression = _left.ToExpression();
var rightExpression = _right.ToExpression();
var paramExpr = Expression.Parameter(typeof(T));
var bodyExpr = Expression.AndAlso(
Expression.Invoke(leftExpression, paramExpr),
Expression.Invoke(rightExpression, paramExpr)
);
return Expression.Lambda<Func<T, bool>>(bodyExpr, paramExpr);
}
}
// Использование
var overdueHighValueSpec = new AndSpecification<Invoice>(
new OverdueInvoiceSpecification(),
new HighValueInvoiceSpecification(1000)
);
var overdueHighValueInvoices = await _invoiceRepository.FindAsync(overdueHighValueSpec); |
|
В одном из моих проектов спецификации стали настоящим спасением. У нас было более 20 различных фильтров для поиска клиентов, и код превратился в набор запутанных условных операторов. После внедрения спецификаций код стал модульным, тестируемым и легко расширяемым — добавление нового фильтра происходило изолированно, без риска сломать существующую логику.
Машины состояний для моделирования жизненного цикла
Многие бизнес-объекты проходят через определённые состояния в течение своего жизненого цикла. Например, заказ может быть создан, подтверждён, оплачен, отправлен и закрыт. Для моделирования таких переходов отлично подходят машины состояний:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| public class OrderStateMachine
{
private static readonly Dictionary<OrderStatus, IEnumerable<OrderStatus>> _allowedTransitions =
new Dictionary<OrderStatus, IEnumerable<OrderStatus>>
{
{ OrderStatus.Draft, new[] { OrderStatus.Submitted } },
{ OrderStatus.Submitted, new[] { OrderStatus.Paid, OrderStatus.Cancelled } },
{ OrderStatus.Paid, new[] { OrderStatus.Shipped, OrderStatus.Refunded } },
{ OrderStatus.Shipped, new[] { OrderStatus.Delivered, OrderStatus.Returned } },
{ OrderStatus.Delivered, new[] { OrderStatus.Closed, OrderStatus.Returned } },
{ OrderStatus.Returned, new[] { OrderStatus.Refunded, OrderStatus.Closed } },
{ OrderStatus.Refunded, new[] { OrderStatus.Closed } },
{ OrderStatus.Cancelled, new[] { OrderStatus.Closed } },
{ OrderStatus.Closed, new OrderStatus[0] },
};
public bool CanTransitionTo(OrderStatus currentStatus, OrderStatus newStatus)
{
return _allowedTransitions.ContainsKey(currentStatus) &&
_allowedTransitions[currentStatus].Contains(newStatus);
}
public void EnsureValidTransition(OrderStatus currentStatus, OrderStatus newStatus)
{
if (!CanTransitionTo(currentStatus, newStatus))
{
throw new InvalidStateTransitionException(
$"Переход из {currentStatus} в {newStatus} невозможен");
}
}
} |
|
Интеграция такой машины состояний с сущностью создаёт очень выразительную модель:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| public class Order
{
private readonly OrderStateMachine _stateMachine = new OrderStateMachine();
public OrderStatus Status { get; private set; }
public void Submit()
{
_stateMachine.EnsureValidTransition(Status, OrderStatus.Submitted);
Status = OrderStatus.Submitted;
// Дополнительная логика...
}
public void MarkAsPaid()
{
_stateMachine.EnsureValidTransition(Status, OrderStatus.Paid);
Status = OrderStatus.Paid;
// Дополнительная логика...
}
// Другие методы перехода между состояниями...
} |
|
Компактные специализированные репозитории
Углубляясь в тему репозиториев, хочу поделится практическим подходом, который неоднократно доказывал свою эффективность в моих проектах – компактные специализированные репозитории. Вместо создания громоздких интерфейсов, покрывающих все возможные сценарии, эффективнее создавать целевые репозитории, заточеные под конкретные потребности доменной модели:
C# | 1
2
3
4
5
6
7
8
| public interface IOrderRepository
{
Task<Order> GetByIdAsync(OrderId id);
Task<IReadOnlyCollection<Order>> GetPendingOrdersForCustomerAsync(CustomerId customerId);
Task<bool> HasActiveOrdersAsync(CustomerId customerId);
Task AddAsync(Order order);
Task UpdateAsync(Order order);
} |
|
Преимущество такого подхода в том, что репозиторий отражает реальные потребности домена. Например, метод HasActiveOrdersAsync может быть реализован эффективно на уровне базы данных с помощью простого запроса EXISTS, вместо загрузки всей коллекции заказов для последующей фильтрации в памяти.
В одном из моих проектах мы пошли еще дальше и разделили репозитории на команды и запросы согласно принципам CQRS:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public interface IOrderCommandRepository
{
Task AddAsync(Order order);
Task UpdateAsync(Order order);
Task DeleteAsync(OrderId id);
}
public interface IOrderQueryRepository
{
Task<Order> GetByIdAsync(OrderId id);
Task<PagedResult<OrderSummary>> GetPaginatedOrdersAsync(
CustomerId customerId, int page, int pageSize);
Task<bool> HasActiveOrdersAsync(CustomerId customerId);
} |
|
Это привело к еще более чистому разделению ответственности: запросы могли возвращать проекции (DTO) вместо полноценных доменных объектов, что значительно повышало производительность.
Интеграция с IoC-контейнерами
Современные DDD-приложения сложно представить без интеграции с контейнерами инверсии управления (IoC). Они существенно упрощают управление зависимостями и соблюдение принципа инверсии зависимостей (Dependency Inversion Principle). Вот пример регистрации компонентов в Microsoft Dependency Injection:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public static class DomainServiceCollectionExtensions
{
public static IServiceCollection AddDomainServices(this IServiceCollection services)
{
// Сервисы домена
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IPaymentService, PaymentService>();
// Репозитории
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<ICustomerRepository, CustomerRepository>();
// Фабрики
services.AddScoped<IOrderFactory, OrderFactory>();
// Спецификации
services.AddScoped<ISpecificationFactory, SpecificationFactory>();
return services;
}
} |
|
Интересный прием, который я часто использую – автоматическая регистрация всех репозиториев и сервисов с помощью рефлексии:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public static IServiceCollection AddDomainServices(this IServiceCollection services)
{
var domainAssembly = typeof(Order).Assembly;
// Автоматическая регистрация всех сервисов домена
var serviceTypes = domainAssembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && t.Name.EndsWith("Service"))
.Where(t => t.GetInterfaces().Any(i => i.Name == $"I{t.Name}"));
foreach (var serviceType in serviceTypes)
{
var interfaceType = serviceType.GetInterfaces()
.First(i => i.Name == $"I{serviceType.Name}");
services.AddScoped(interfaceType, serviceType);
}
return services;
} |
|
Интеграция с системами машинного обучения
Одна из самых интересных задач, с которыми я сталкивался в последнее время – интеграция моделей машинного обучения с доменными моделями DDD. Проблема в том, что ML-модели часто представлены в виде чёрных ящиков, не вписывающихся в элегантную структуру DDD.
Решение, которое хорошо зарекомендовало себя – использование паттерна Адаптер для создания мостика между доменной моделью и ML-сервисом:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| public interface IProductRecommendationService
{
Task<IReadOnlyCollection<ProductId>> GetRecommendationsForCustomerAsync(
CustomerId customerId, int maxRecommendations = 5);
}
public class MlProductRecommendationAdapter : IProductRecommendationService
{
private readonly IRecommendationApiClient _apiClient;
private readonly ICustomerRepository _customerRepository;
public MlProductRecommendationAdapter(
IRecommendationApiClient apiClient,
ICustomerRepository customerRepository)
{
_apiClient = apiClient;
_customerRepository = customerRepository;
}
public async Task<IReadOnlyCollection<ProductId>> GetRecommendationsForCustomerAsync(
CustomerId customerId, int maxRecommendations = 5)
{
var customer = await _customerRepository.GetByIdAsync(customerId);
if (customer == null)
return Array.Empty<ProductId>();
// Преобразование доменной модели в формат, понятный ML-сервису
var features = ExtractFeaturesFromCustomer(customer);
// Получение рекомендаций от ML-сервиса
var recommendations = await _apiClient.GetRecommendationsAsync(features, maxRecommendations);
// Преобразование результатов обратно в доменные объекты
return recommendations.Select(r => new ProductId(r.ProductGuid)).ToList();
}
private CustomerFeatures ExtractFeaturesFromCustomer(Customer customer)
{
// Извлечение признаков из доменной модели для ML-модели
return new CustomerFeatures
{
Age = customer.CalculateAge(),
PurchaseHistory = customer.Orders.Select(o => o.Id.Value).ToArray(),
PreferredCategories = customer.Preferences.FavoriteCategories.ToArray()
};
}
} |
|
Этот подход позволяет:
1. Сохранить чистоту доменной модели.
2. Инкапсулировать всю специфику ML-интеграции в отдельном компоненте.
3. Легко заменить ML-сервис на другую реализацию без изменения домена.
В одном из проектов мы создали целый слой между доменом и несколькими ML-моделями. Это позволило нам экспериментировать с разными алгоритмами рекомендаций, не меняя основную бизнес-логику приложения. Даже если вы не используете машиное обучение прямо сейчас, стоит проектировать систему с учетом возможного добавления ML-компонентов в будущем. Хорошо продуманные интерфейсы и границы между модулями сделают такую интеграцию гораздо более безболезненной.
Подводя итог разговору о тактических паттернах, хочется заметить, что не стоит слепо применять каждый из них. DDD — набор полезных инструментов, а не догма. Вы должны выбирать те паттерны, которые помогают решить конкретные проблемы вашего домена, а не пытатся использовать все возможные техники просто потому, что они существуют.
Стратегические паттерны и интеграция
Если тактические паттерны помогают решать конкретные проблемы на уровне кода, то стратегические паттерны DDD фокусируются на более высоком уровне абстракции, определяя структуру и взаимодействие крупных компонентов системы. Именно на стратегическом уровне DDD раскрывает свой полный потенциал, особенно когда речь идёт о сложных, многокомпонентных системах.
Ограниченные контексты: разделяй и властвуй
Ограниченный контекст (Bounded Context) — возможно, самая важная концепция стратегического DDD. Она признаёт факт, который многие архитекторы предпочитают игнорировать: в разных частях системы одни и те же термины могут означать разные вещи, а один и тот же реальный объект может быть представлен совершенно по-разному. Мой опыт показывает, что попытка создать единую всеобъемлющую модель для большой системы почти всегда приводит к запутанности, противоречиям и бесконечным дискуссиям. Например, в системе управления университетом понятие "Студент" в контексте приёмной комиссии, учебного отдела и бухгалтерии может сильно различаться:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| // Приёмная комиссия
namespace University.Admissions
{
public class Applicant
{
public Guid Id { get; private set; }
public string FullName { get; private set; }
public DateTime DateOfBirth { get; private set; }
public List<Document> Documents { get; private set; }
public ApplicationStatus Status { get; private set; }
// Методы для работы с заявлением
}
}
// Учебный отдел
namespace University.Academic
{
public class Student
{
public Guid Id { get; private set; }
public string FullName { get; private set; }
public Faculty Faculty { get; private set; }
public int Year { get; private set; }
public List<Course> EnrolledCourses { get; private set; }
public AcademicPerformance Performance { get; private set; }
// Методы для работы с учебным процессом
}
}
// Бухгалтерия
namespace University.Accounting
{
public class Payer
{
public Guid Id { get; private set; }
public string FullName { get; private set; }
public List<Invoice> Invoices { get; private set; }
public PaymentStatus Status { get; private set; }
// Методы для работы с оплатами
}
} |
|
Ограниченные контексты позволяют каждой команде разработчиков работать в рамках своей, хорошо определённой и внутренне согласованной модели, не беспокоясь о том, как этот же объект представлен в других частях системы.
Антикоррупционный слой: защита от враждебного окружения
Редко когда новая система разрабатывается в вакууме. Чаще всего приходится интегрироваться с существуящими системами, которые могут иметь совершенно иную модель данных и бизнес-логику. Здесь на помощь приходит концепция Антикоррупционного слоя (Anti-corruption Layer, ACL).
Антикоррупционный слой — это изоляционный барьер, который преобразует информацию между разными моделями, защищая нашу красивую доменную модель от "загрязнения" внешними концепциями:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| // Внешняя модель из устаревшей системы
public class LegacyCustomerDto
{
public int CustId { get; set; }
public string FName { get; set; }
public string LName { get; set; }
public string Addr { get; set; }
public string City { get; set; }
public string Zip { get; set; }
public byte Status { get; set; } // 0 - inactive, 1 - active
}
// Антикоррупционный слой
public class LegacyCustomerAdapter : ICustomerRepository
{
private readonly ILegacyCustomerApi _legacyApi;
public async Task<Customer> GetByIdAsync(CustomerId id)
{
var legacyCustomer = await _legacyApi.GetCustomerById(int.Parse(id.Value));
if (legacyCustomer == null)
return null;
// Трансформация из устаревшей модели в нашу доменную модель
var address = new Address(legacyCustomer.Addr, legacyCustomer.City, legacyCustomer.Zip);
var name = new PersonName(legacyCustomer.FName, legacyCustomer.LName);
var isActive = legacyCustomer.Status == 1;
return new Customer(
CustomerId.FromString(legacyCustomer.CustId.ToString()),
name,
address,
isActive
);
}
// Другие методы репозитория...
} |
|
Я люблю думать об антикоррупционном слое как о дипломате-переводчике, который позволяет двум странам с разными языками и культурами эффективно взаимодействовать, не заставляя их изучать язык друг друга.
Современные подходы к картографии контекстов
Карта контекстов (Context Map) — это документ, отображающий взаимосвязи между различными ограниченными контекстами. В современных проектах я перешёл от статических диаграмм к более динамичным и интерактивным способам представления:
1. Использование специализированных инструментов для визуализации и поддержания актуальности карты контекстов, таких как C4 Model или Context Mapper.
2. Автоматизированное создание карт контекстов на основе анализа кода и зависимостей, что гарантирует их актуальность.
3. Живая документация, встроенная непосредственно в код. Например, можно использовать атрибуты для отметки классов, относящихся к определённому контексту:
C# | 1
2
3
4
5
| [BoundedContext("Marketing")]
public class Campaign
{
// Содержимое класса...
} |
|
А затем автоматически генерировать карту контекстов с помощью инструментов сборки.
Реализация публикации доменных событий
Доменные события — это отличный способ обеспечить слабую связанность между контекстами. В современных C# проектах часто используются специализированные брокеры сообщений, такие как MassTransit или NServiceBus. Вот пример реализации с использованием MassTransit:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
| // Доменное событие
public record OrderPlacedEvent : IDomainEvent
{
public Guid OrderId { get; }
public Guid CustomerId { get; }
public decimal TotalAmount { get; }
public DateTime OccurredOn { get; }
public OrderPlacedEvent(Guid orderId, Guid customerId, decimal totalAmount)
{
OrderId = orderId;
CustomerId = customerId;
TotalAmount = totalAmount;
OccurredOn = DateTime.UtcNow;
}
}
// Публикатор событий с использованием MassTransit
public class MassTransitEventPublisher : IDomainEventPublisher
{
private readonly IBus _bus;
public MassTransitEventPublisher(IBus bus)
{
_bus = bus;
}
public async Task PublishAsync<TEvent>(TEvent domainEvent) where TEvent : IDomainEvent
{
await _bus.Publish(domainEvent);
}
}
// Потребитель события в другом контексте
public class OrderPlacedEventConsumer : IConsumer<OrderPlacedEvent>
{
private readonly IShippingService _shippingService;
public OrderPlacedEventConsumer(IShippingService shippingService)
{
_shippingService = shippingService;
}
public async Task Consume(ConsumeContext<OrderPlacedEvent> context)
{
var orderId = context.Message.OrderId;
// Обработка события в контексте доставки
await _shippingService.InitializeShippingProcessAsync(orderId);
}
} |
|
Этот подход позволяет разным контекстам коммуницировать асинхронно, сохраняя при этом свою независимость. Контекст заказов не знает о существовании контекста доставки, он просто публикует события о значимых изменениях в своем домене.
Стратегии межконтекстного взаимодействия
Помимо событий, существует несколько стратегий взаимодействия между контекстами:
1. Общее ядро (Shared Kernel) — общий набор моделей и функциональности, используемый несколькими контекстами. Это должен быть небольшой и стабильный набор, поскольку изменения затрагивают сразу несколько команд.
2. Клиент-Поставщик (Customer-Supplier) — отношения, при которых один контекст (клиент) зависит от другого (поставщика). Поставщик должен учитывать потребности клиента при планировании изменений.
3. Конформист (Conformist) — когда клиентский контекст просто принимает модель поставщика без адаптации, обычно из-за недостатка влияния на поставщика.
4. Предохранительный слой (Anticorruption Layer) — как было описано выше.
5. Служба с открытым протоколом (Open Host Service) — когда контекст предоставляет публичный API, который используется другими контекстами.
6. Опубликованный язык (Published Language) — хорошо документированный формат обмена данными между контекстами.
Выбор стратегии зависит от контекста и организационных факторов. Например, для интеграции с внешним API, над которым мы не имеем контроля, может подойти антикоррупционный слой, а для взаимодействия между двумя командами в одной организации — общее ядро или клиент-поставщик.
Оркестрация и хореография доменных процессов
Когда речь идёт о координации работы множества ограниченных контекстов, возникают два основных подхода: оркестрация и хореография.
Оркестрация предполагает наличие центрального компонента (оркестратора), который управляет всем процессом. Это может быть полезно для сложных многоэтапных процессов, требующих координации:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| public class OrderProcessOrchestrator
{
private readonly IOrderService _orderService;
private readonly IPaymentService _paymentService;
private readonly IInventoryService _inventoryService;
private readonly IShippingService _shippingService;
public async Task ProcessOrderAsync(Guid orderId)
{
// 1. Проверка и бронирование товаров
var reservationResult = await _inventoryService.ReserveItemsAsync(orderId);
if (!reservationResult.IsSuccess)
return; // Обработка ошибки
// 2. Обработка платежа
var paymentResult = await _paymentService.ProcessPaymentAsync(orderId);
if (!paymentResult.IsSuccess)
{
// Откат бронирования товаров
await _inventoryService.CancelReservationAsync(orderId);
return;
}
// 3. Подтверждение заказа
await _orderService.ConfirmOrderAsync(orderId);
// 4. Инициализация доставки
await _shippingService.InitializeShippingAsync(orderId);
}
} |
|
Хореография, напротив, распределяет ответственность между участниками процесса, которые реагируют на события, публикуемые другими участниками:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // В контексте заказов
public class OrderPlacedEventConsumer : IConsumer<OrderPlacedEvent>
{
private readonly IInventoryService _inventoryService;
public async Task Consume(ConsumeContext<OrderPlacedEvent> context)
{
await _inventoryService.ReserveItemsAsync(context.Message.OrderId);
}
}
// В контексте инвентаря
public class ItemsReservedEventConsumer : IConsumer<ItemsReservedEvent>
{
private readonly IPaymentService _paymentService;
public async Task Consume(ConsumeContext<ItemsReservedEvent> context)
{
await _paymentService.ProcessPaymentAsync(context.Message.OrderId);
}
}
// И так далее... |
|
Хореография обычно предпочтительнее для слабосвязанных систем, поскольку не требует центрального координатора и лучше масштабируется. Однако отслеживать и отлаживать такие распределённые процессы может быть сложнее.
Особенности применения DDD в современной разработке
Применение Domain-Driven Design в современных проектах существенно отличается от того, как эта методология использовалась десять лет назад. Эволюция технологий, появление новых архитектурных паттернов и изменение подходов к разработке привели к интересному симбиозу DDD с другими практиками. Поделюсь своими наблюдениями из реальных проектов и покажу, как эффективно сочетать DDD с современными технологиями.
Интеграция DDD с CQRS и Event Sourcing
Одна из самых мощных синергий в современной разработке — это сочетание DDD с паттерном CQRS (Command Query Responsibility Segregation) и Event Sourcing. Эти подходы настолько хорошо дополняют друг друга, что порой сложно представить сложный DDD-проект без них. CQRS разделяет операции чтения и записи, что позволяет оптимизировать их независимо:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| // Команда - изменение состояния
public class UpdateCustomerAddressCommand : ICommand
{
public Guid CustomerId { get; set; }
public AddressDto NewAddress { get; set; }
}
// Обработчик команды - использует доменную модель
public class UpdateCustomerAddressCommandHandler : ICommandHandler<UpdateCustomerAddressCommand>
{
private readonly ICustomerRepository _repository;
public async Task HandleAsync(UpdateCustomerAddressCommand command)
{
var customer = await _repository.GetByIdAsync(new CustomerId(command.CustomerId));
var address = new Address(
command.NewAddress.Street,
command.NewAddress.City,
command.NewAddress.State,
command.NewAddress.ZipCode
);
customer.UpdateAddress(address);
await _repository.UpdateAsync(customer);
}
}
// Запрос - получение данных
public class GetCustomerDetailsQuery : IQuery<CustomerDetailsDto>
{
public Guid CustomerId { get; set; }
}
// Обработчик запроса - использует оптимизированое хранилище для чтения
public class GetCustomerDetailsQueryHandler : IQueryHandler<GetCustomerDetailsQuery, CustomerDetailsDto>
{
private readonly ICustomerReadDbContext _readDb;
public async Task<CustomerDetailsDto> HandleAsync(GetCustomerDetailsQuery query)
{
// Прямой запрос к БД для чтения, минуя доменную модель
return await _readDb.Customers
.Where(c => c.Id == query.CustomerId)
.Select(c => new CustomerDetailsDto
{
Id = c.Id,
FullName = c.FirstName + " " + c.LastName,
EmailAddress = c.Email,
Address = new AddressDto
{
Street = c.Street,
City = c.City,
State = c.State,
ZipCode = c.ZipCode
}
})
.FirstOrDefaultAsync();
}
} |
|
Event Sourcing идет еще дальше, храня не текущее состояние агрегатов, а последовательность событий, которые привели к этому состоянию. Это дает неоценимые преимущества: полную историю изменений, возможность воспроизвести состояние на любой момент времени и естественную поддержку доменных событий.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| public class Customer : EventSourcedAggregateRoot
{
private string _name;
private Address _address;
private bool _isActive;
public void UpdateAddress(Address newAddress)
{
if (!_isActive)
throw new CustomerInactiveException(Id);
ApplyChange(new CustomerAddressUpdatedEvent(Id, newAddress));
}
private void Apply(CustomerAddressUpdatedEvent @event)
{
_address = new Address(
@event.Street,
@event.City,
@event.State,
@event.ZipCode
);
}
} |
|
В одном из моих проектов мы столкнулись с интересной проблемой: система должна была отслеживать полную историю всех действий с клиентами, включая просмотр их данных для аудита. Комбинация DDD, CQRS и Event Sourcing позволила элегантно решить эту задачу безо всяких примочек — события просмотра фиксировались в том же потоке событий, что и события изменения, давая единый источник истины для аудита.
DDD и микросервисная архитектура
DDD и микросервисы — еще одна пара технологий, которые как будто созданы друг для друга. Ограниченные контексты DDD естественным образом превращаются в отдельные микросервисы, каждый со своей собственной доменной моделью, хранилищем данных и API. Однако это не значит, что разбиение на микросервисы должно слепо следовать ограниченным контекстам. Иногда один микросервис может объединять несколько связанных контекстов, особенно если они имеют высокую степень согласованности и низкую автономность.
Главный вызов при работе с DDD в микросервисной архитектуре — управление единым языком (Ubiquitous Language) и согласованостью контрактов взаимодействия. На практике хорошо зарекомендовали себя следующие подходы:
1. Контрактно-ориентированная разработка (Contract-First Development) — начинайте с определения API-контрактов между сервисами, а затем реализуйте их.
2. Общие библиотеки DTO — создавайте отдельные библиотеки с DTO-объектами для интеграции между сервисами.
3. Публикуемый язык (Published Language) — формализуйте общие концепции, используемые для межсервисного взаимодействия.
Тестирование DDD-компонентов
Хорошо спроектированные DDD-компоненты обычно легко тестировать, поскольку они имеют четкие границы и явные зависимости. Однако тестирование сложных доменных моделей требует специфического подхода:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| [Fact]
public void Customer_CannotPlaceOrder_WhenInactive()
{
// Arrange
var customer = new Customer("John Doe", "john@example.com");
customer.Deactivate();
// Act & Assert
Assert.Throws<CustomerInactiveException>(() =>
customer.PlaceOrder(
new List<OrderItem> { new OrderItem("Product1", 2, new Money(100, "USD")) }
)
);
} |
|
Для полного тестирования DDD-системы я обычно использую несколько уровней тестов:
1. Модульные тесты — проверяют отдельные компоненты домена (сущности, объекты-значения, сервисы домена).
2. Интеграционные тесты — проверяют взаимодействие между компонентами, включая репозитории и инфраструктуру.
3. Сценарные тесты — проверяют полные пользовательские сценарии, охватывая несколько ограниченных контекстов.
Особенно полезными оказались тесты, фокусирующиеся на инвариантах предметной области и бизнес-правилах. Такие тесты не только обеспечивают техническую корректность, но и служат живой документацией о поведении домена.
DDD и облачные платформы
Современные облачные платформы, такие как Azure и AWS, предлагают множество сервисов, которые отлично сочетаются с DDD-архитектурой:
1. Serverless-функции (Azure Functions, AWS Lambda) идеально подходят для обработки доменных событий и команд.
2. Очереди сообщений (Azure Service Bus, AWS SQS) облегчают асинхронную коммуникацию между ограниченными контекстами.
3. Хранилища событий (Azure Event Hubs, AWS Kinesis) предоставляют инфраструктуру для реализации Event Sourcing.
При проектировании DDD-систем для облачных платформ важно учитывать их ограничения и особенности. Например, serverless-функции имеют ограничения по времени выполнения, что может влиять на дизайн ваших доменных операций.
В заключение хочу подчеркнуть, что DDD — не только набор шаблонов или архитектурный стиль, а способ мышления о сложных системах. Его принципы актуальны независимо от технологического стека или архитектурного подхода, который вы выбираете. Современные технологии, от CQRS до облачных платформ, лишь расширяют инструментарий, с помощью которого мы воплощаем этот образ мышления в код.
DDD: почему репозиторий в модели? Если кто с этой концепцией работал, подскажите, почему:
1. Сначала уверенно категорично... Проектирование моделей DDD Всем доброго дня! Помогите, пожалуйста, разобраться с DDD. Допустим, есть у меня сущность RegCard,... DbContext и репозиторий в DDD Всем привет, уважаемые форумачане!
Подскажите следующий момент по канонам DDD.
Есть слой... Как правильно приготовить DDD (domain-driven design) Достался проект, который изначально планировался как DDD, но ребята которые его делали до меня... Инициализация приложения - внедрение зависимостей в DDD Здравствуйте!
Подскажите как осуществляется начальная инициализация ("сборка") всех зависимостей... Хранить шаблоны документов в базе и выводить данные в эти шаблоны Доброго времени суток.
Интересует вопрос: мне необходимо формировать вордовские документы по... Стандартные приемы работы с БД. Что использовать здравствуйте.я пишу программу где нужно создать базу данных.чтобы она редактировалась несколькими... Основные понятия и приемы программирования Помогите ответить на вопросы по С#.
1)Создание объектов.Понятия ссылки.... Нужны сайты про C#, приемы, рецепты, трюки программирования Не советуйте msdn или книгу.
Справочник должен быть похож на другие стандартные справочники как у... Как распознать каптчу: приемы и алгоритмы Доброго всем времени суток,недавно стал вопрос ребром,стало весьма и весьма интересно как... Парсинг JSON: кто какие приемы использует Приветствую, Это не столько вопрос, сколько поле для рассуждений, кто какими методами парсит,... Интересны приемы программирования, о которых не пишут в книгах, а которые узнаются на практике интересны приемы программирования на C# те о которых не пишут в книгах, которые узнаются на...
|