Loading...
Loading...
Implement Domain-Driven Design tactical patterns in C#/.NET. Use when building Entities, Value Objects, Aggregates, Domain Events, Repositories, or structuring a DDD solution. Framework-agnostic — covers pure domain modeling with modern C#.
npx skill4agent add baotoq/agent-skills dotnet-ddddomain-analysis| Concept | What It Is | C# Implementation |
|---|---|---|
| Entity | Object with identity that persists across state changes | Class with |
| Value Object | Immutable object defined by its attributes, no identity | |
| Aggregate | Cluster of Entities/VOs with a single root, consistency boundary | Root entity that guards all invariants |
| Aggregate Root | Entry point to an Aggregate — the only externally-referenced entity | Public API, owns child entities |
| Domain Event | Something that happened in the domain that other parts care about | |
| Repository | Abstraction for persisting/retrieving Aggregates | Interface in Domain, implementation in Infrastructure |
| Domain Service | Stateless operation that doesn't belong to a single Entity/VO | Static method or injected service |
| Specification | Encapsulated query/business rule | Class with |
public abstract class Entity<TId> : IEquatable<Entity<TId>>
where TId : notnull
{
public TId Id { get; protected init; }
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents;
protected Entity(TId id) => Id = id;
public void RaiseDomainEvent(IDomainEvent domainEvent) =>
_domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
public bool Equals(Entity<TId>? other) =>
other is not null && Id.Equals(other.Id);
public override bool Equals(object? obj) =>
obj is Entity<TId> other && Equals(other);
public override int GetHashCode() => Id.GetHashCode();
public static bool operator ==(Entity<TId>? left, Entity<TId>? right) =>
Equals(left, right);
public static bool operator !=(Entity<TId>? left, Entity<TId>? right) =>
!Equals(left, right);
// Protected parameterless constructor for ORM
protected Entity() => Id = default!;
}recordpublic record Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException($"Cannot add {Currency} to {other.Currency}");
return this with { Amount = Amount + other.Amount };
}
public static Money Zero(string currency) => new(0, currency);
}
public record Address(string Street, string City, string State, string ZipCode, string Country);
public record DateRange
{
public DateOnly Start { get; init; }
public DateOnly End { get; init; }
public DateRange(DateOnly start, DateOnly end)
{
if (end < start)
throw new ArgumentException("End date must be after start date");
Start = start;
End = end;
}
public bool Overlaps(DateRange other) =>
Start <= other.End && other.Start <= End;
}public sealed class Order : Entity<OrderId>
{
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines;
public CustomerId CustomerId { get; private init; }
public OrderStatus Status { get; private set; }
public Money Total => _lines.Aggregate(Money.Zero("USD"), (sum, line) => sum.Add(line.SubTotal));
private Order() { } // ORM
public static Order Create(CustomerId customerId)
{
var order = new Order(OrderId.New())
{
CustomerId = customerId,
Status = OrderStatus.Draft
};
order.RaiseDomainEvent(new OrderCreatedEvent(order.Id));
return order;
}
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Can only add lines to draft orders");
if (quantity <= 0)
throw new DomainException("Quantity must be positive");
var line = new OrderLine(OrderLineId.New(), productId, quantity, unitPrice);
_lines.Add(line);
}
public void Submit()
{
if (_lines.Count == 0)
throw new DomainException("Cannot submit an empty order");
Status = OrderStatus.Submitted;
RaiseDomainEvent(new OrderSubmittedEvent(Id, Total));
}
}public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
}public interface IDomainEvent
{
DateTime OccurredOn { get; }
}
public abstract record DomainEvent : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
}
public record OrderCreatedEvent(OrderId OrderId) : DomainEvent;
public record OrderSubmittedEvent(OrderId OrderId, Money Total) : DomainEvent;// Define in Domain layer — implement in Infrastructure
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task UpdateAsync(Order order, CancellationToken ct = default);
}
// Optional: generic base interface
public interface IRepository<T, TId>
where T : Entity<TId>
where TId : notnull
{
Task<T?> GetByIdAsync(TId id, CancellationToken ct = default);
Task AddAsync(T entity, CancellationToken ct = default);
}public sealed class Result<T>
{
public T? Value { get; }
public Error? Error { get; }
public bool IsSuccess => Error is null;
private Result(T value) => Value = value;
private Result(Error error) => Error = error;
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(Error error) => new(error);
public TOut Match<TOut>(Func<T, TOut> onSuccess, Func<Error, TOut> onFailure) =>
IsSuccess ? onSuccess(Value!) : onFailure(Error!);
}
public record Error(string Code, string Message);public Result<Order> Submit()
{
if (_lines.Count == 0)
return Result<Order>.Failure(OrderErrors.EmptyOrder);
Status = OrderStatus.Submitted;
RaiseDomainEvent(new OrderSubmittedEvent(Id, Total));
return Result<Order>.Success(this);
}CreateFromCancellationToken