MidПрактика2 min

Шаг 4: Базовые классы Entity и ValueObject

Создание фундамента Domain Layer: абстрактные классы Entity, ValueObject, интерфейсы IAggregateRoot, IDomainEvent и DomainException

Создадим фундамент Domain Layer -- абстрактные классы, от которых наследуются все бизнес-объекты.

Структура папок в Domain

Создайте папки в OrderManagement.Domain:

OrderManagement.Domain/
├── Common/          <- базовые классы
├── Entities/        <- Order, OrderItem
├── ValueObjects/    <- Money, Address
├── Enums/           <- OrderStatus
├── Events/          <- события
├── Exceptions/      <- ошибки
└── Interfaces/      <- контракты

IDomainEvent.cs

using MediatR;

namespace OrderManagement.Domain.Common;

public interface IDomainEvent : INotification
{
    Guid EventId { get; }
    DateTime OccurredOn { get; }
}

Наследование от INotification позволяет публиковать события через _mediator.Publish(). Guid EventId -- уникальный идентификатор конкретного события, DateTime OccurredOn -- время.

Entity.cs

namespace OrderManagement.Domain.Common;

public abstract class Entity
{
    public Guid Id { get; protected set; }

    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyCollection<IDomainEvent> DomainEvents =>
        _domainEvents.AsReadOnly();

    protected void AddDomainEvent(IDomainEvent domainEvent) =>
        _domainEvents.Add(domainEvent);

    public void ClearDomainEvents() => _domainEvents.Clear();

    public override bool Equals(object? obj)
    {
        if (obj is not Entity other) return false;
        if (ReferenceEquals(this, other)) return true;
        if (GetType() != other.GetType()) return false;
        return Id == other.Id;
    }

    public override int GetHashCode() => Id.GetHashCode();
}

abstract означает: нельзя создать new Entity(). Можно только наследовать: class Order : Entity. protected set для Id -- запись доступна только из класса и его наследников. IReadOnlyCollection запрещает внешнему коду модифицировать список событий.

IAggregateRoot.cs

namespace OrderManagement.Domain.Common;

public interface IAggregateRoot { }

Маркерный интерфейс без методов. Документирует намерение: класс с IAggregateRoot -- корень агрегата.

ValueObject.cs

namespace OrderManagement.Domain.Common;

public abstract class ValueObject
{
    protected abstract IEnumerable<object> GetEqualityComponents();

    public override bool Equals(object? obj)
    {
        if (obj is null || obj.GetType() != GetType()) return false;
        return GetEqualityComponents()
            .SequenceEqual(((ValueObject)obj).GetEqualityComponents());
    }

    public override int GetHashCode() =>
        GetEqualityComponents()
            .Select(x => x?.GetHashCode() ?? 0)
            .Aggregate((x, y) => x ^ y);

    public static bool operator ==(ValueObject? a, ValueObject? b)
    {
        if (a is null && b is null) return true;
        if (a is null || b is null) return false;
        return a.Equals(b);
    }

    public static bool operator !=(ValueObject? a, ValueObject? b) =>
        !(a == b);
}

GetEqualityComponents() -- наследники возвращают список значимых полей. SequenceEqual сравнивает коллекции поэлементно. Перегрузка == и != позволяет писать money1 == money2.

DomainException.cs

namespace OrderManagement.Domain.Exceptions;

public class DomainException : Exception
{
    public DomainException(string message) : base(message) { }
}

public class OrderNotFoundException : DomainException
{
    public OrderNotFoundException(Guid orderId)
        : base($"Order with ID {orderId} not found") { }
}

DomainException -- ошибка бизнес-правила (HTTP 400). OrderNotFoundException -- заказ не найден (HTTP 404).

Проверка

Ctrl+Shift+B -> Build succeeded

Проверь себя

Зачем Entity -- абстрактный класс?

Зачем ValueObject перегружает операторы == и !=?

Как Entity определяет равенство двух объектов?