Установка
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