Форум программистов, компьютерный форум, киберфорум
stackOverflow
Войти
Регистрация
Восстановить пароль

Шаблоны и приёмы реализации DDD на C#

Запись от stackOverflow размещена 12.05.2025 в 11:39
Показов 1661 Комментарии 0
Метки c#, ddd, microservices, patterns

Нажмите на изображение для увеличения
Название: c1ae4b23-a00f-4dd2-a2db-92454cedac1e.jpg
Просмотров: 50
Размер:	348.9 Кб
ID:	10794
Когда я впервые погрузился в мир 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
Здравствуйте! Подскажите как осуществляется начальная инициализация (&quot;сборка&quot;) всех зависимостей...

Хранить шаблоны документов в базе и выводить данные в эти шаблоны
Доброго времени суток. Интересует вопрос: мне необходимо формировать вордовские документы по...

Стандартные приемы работы с БД. Что использовать
здравствуйте.я пишу программу где нужно создать базу данных.чтобы она редактировалась несколькими...

Основные понятия и приемы программирования
Помогите ответить на вопросы по С#. 1)Создание объектов.Понятия ссылки....

Нужны сайты про C#, приемы, рецепты, трюки программирования
Не советуйте msdn или книгу. Справочник должен быть похож на другие стандартные справочники как у...

Как распознать каптчу: приемы и алгоритмы
Доброго всем времени суток,недавно стал вопрос ребром,стало весьма и весьма интересно как...

Парсинг JSON: кто какие приемы использует
Приветствую, Это не столько вопрос, сколько поле для рассуждений, кто какими методами парсит,...

Интересны приемы программирования, о которых не пишут в книгах, а которые узнаются на практике
интересны приемы программирования на C# те о которых не пишут в книгах, которые узнаются на...

Метки c#, ddd, microservices, patterns
Размещено в Без категории
Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
Популярные LM модели ориентированы на увеличение затрат ресурсов пользователями сгенерированного кода (грязь -заслуги чистоплюев).
Hrethgir 12.06.2025
Вообще обратил внимание, что они генерируют код (впрочем так-же ориентированы разработчики чипов даже), чтобы пользователь их использующий уходил в тот или иной убыток. Это достаточно опытные модели,. . .
Топ10 библиотек C для квантовых вычислений
bytestream 12.06.2025
Квантовые вычисления - это та область, где теория встречается с практикой на границе наших знаний о физике. Пока большая часть шума вокруг квантовых компьютеров крутится вокруг языков высокого уровня. . .
Dispose и Finalize в C#
stackOverflow 12.06.2025
Работая с C# больше десяти лет, я снова и снова наблюдаю одну и ту же историю: разработчики наивно полагаются на сборщик мусора, как на волшебную палочку, которая решит все проблемы с памятью. Да,. . .
Повышаем производительность игры на Unity 6 с GPU Resident Drawer
GameUnited 11.06.2025
Недавно копался в новых фичах Unity 6 и наткнулся на GPU Resident Drawer - штуку, которая заставила меня присвистнуть от удивления. По сути, это внутренний механизм рендеринга, который автоматически. . .
Множества в Python
py-thonny 11.06.2025
В Python существует множество структур данных, но иногда я сталкиваюсь с задачами, где ни списки, ни словари не дают оптимального решения. Часто это происходит, когда мне нужно быстро проверять. . .
Работа с ccache/sccache в рамках C++
Loafer 11.06.2025
Утилиты ccache и sccache занимаются тем, что кешируют промежуточные результаты компиляции, таким образом ускоряя последующие компиляции проекта. Это означает, что если проект будет компилироваться. . .
Настройка MTProxy
Loafer 11.06.2025
Дополнительная информация к инструкции по настройке MTProxy: Перед сборкой проекта необходимо добавить флаг -fcommon в конец переменной CFLAGS в Makefile. Через crontab -e добавить задачу: 0 3. . .
Изучаем Docker: что это, как использовать и как это работает
Mr. Docker 10.06.2025
Суть Docker проста - это платформа для разработки, доставки и запуска приложений в контейнерах. Контейнер, если говорить образно, это запечатанная коробка, в которой находится ваше приложение вместе. . .
Тип Record в C#
stackOverflow 10.06.2025
Многие годы я разрабатывал приложения на C#, используя классы для всего подряд - и мне это казалось естественным. Но со временем, особенно в крупных проектах, я стал замечать, что простые классы. . .
Разработка плагина для Minecraft
Javaican 09.06.2025
За годы существования Minecraft сформировалась сложная экосистема серверов. Оригинальный (ванильный) сервер не поддерживает плагины, поэтому сообщество разработало множество альтернатив. CraftBukkit. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2025, CyberForum.ru