Документация

Koto.Domain

DDD-примитивы для .NET-микросервисов. Без внешних зависимостей.

Установка

dotnet add package Koto.Domain

Что внутри

ТипНазначение
AggregateRoot<TId>Базовый класс, собирающий доменные события
Entity<TId>Равенство по идентификатору (временные сущности никогда не равны)
ValueObjectРавенство по компонентам значения
StronglyTypedId<T>Типизированные обёртки для Guid / int / string идентификаторов
Result<T>Явный успех/неудача без исключений; несёт одну или несколько ошибок (Errors)
Result (статический)Success() / Failure(…) для void-веток, Combine(…) для агрегации нескольких результатов
IResultFactory<TSelf>Static-abstract фабрика ошибок для generic-инфраструктуры (без рефлексии)
ErrorСтруктурированная ошибка: Code + Message + опциональный Field
Errors.GeneralОбщие фабричные ошибки (not-found, required, …)
IDomainEvent / DomainEventИнтерфейсы событий и базовая запись (init-свойства переживают JSON round-trip)
UnitТип возврата для команд без значения

IRepository<TAgg, TId> живёт в Koto.Application (рядом с IUnitOfWork) — порт принадлежит слою, который его потребляет (хендлерам); домен остаётся без знания о персистентности.

Использование

Value Object

public sealed class Email : ValueObject
{
    public string Value { get; }
    private Email(string value) => Value = value;

    public static Result<Email> Create(string value) =>
        string.IsNullOrWhiteSpace(value) ? Errors.General.ValueIsRequired() :
        value.Length > 150              ? Errors.General.InvalidLength(1, 150) :
                                          new Email(value);

    protected override IEnumerable<object?> GetEqualityComponents()
    {
        yield return Value.ToLowerInvariant();
    }
}

Aggregate Root

public sealed record OrderId(Guid Value) : StronglyTypedId<Guid>(Value);

public class Order : AggregateRoot<OrderId>
{
    public static Result<Order> Place(IReadOnlyList<OrderItem> items)
    {
        if (items.Count == 0)
            return Errors.General.CollectionIsTooSmall(1, 0);

        var order = new Order(new OrderId(Guid.NewGuid()));
        order.AddDomainEvent(new OrderPlacedEvent(order.Id));
        return order;
    }

    public Result<Unit> Cancel(string reason)
    {
        if (Status == OrderStatus.Cancelled)
            return new Error("orders.already-cancelled", "Order is already cancelled.");

        Status = OrderStatus.Cancelled;
        AddDomainEvent(new OrderCancelledEvent(Id, reason));
        return Unit.Value;
    }
}

Цепочки Result

Result<OrderId> result = await ValidateAsync(request)
    .BindAsync(dto => Order.Place(dto.Items))
    .TapAsync(order => repository.Add(order))
    .MapAsync(order => order.Id);

Несколько ошибок и Combine

// Агрегируем несколько фабричных результатов — собираются ВСЕ ошибки, а не только первая:
var combined = Result.Combine(Email.Create(dto.Email), Name.Create(dto.Name));
if (combined.IsFailure)
    return Result<User>.Failure(combined.Errors);   // каждая ошибка со своим Code

var (email, name) = combined.Value;

Асинхронный match

// Асинхронный обработчик успеха + синхронный обработчик неудачи — без шума Task.FromResult:
return await result.MatchAsync(
    onSuccess: async order => await BuildResponseAsync(order, ct),
    onFailure: error => ErrorResponse(error));

Лицензия

MIT