From agent-skills
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 claudepluginhub baotoq/agent-skills --plugin agent-skillsThis skill uses the workspace's default tool permissions.
Tactical DDD implementation patterns in modern C# — building rich domain models with Entities, Value Objects, Aggregates, Domain Events, and Repositories.
Implements Domain-Driven Design tactical patterns for .NET: aggregates, roots, value objects, domain events, services, typed IDs, repositories. For DDD aggregates, events, bounded contexts.
Applies DDD tactical patterns—entities, value objects, aggregates, repositories, domain events—to enforce invariants when modeling domain logic or refactoring anemic models.
Provides Domain-Driven Design tactical patterns for modeling entities, value objects, domain services, repositories, aggregates, and bounded contexts in complex business domains.
Share bugs, ideas, or general feedback.
Tactical DDD implementation patterns in modern C# — building rich domain models with Entities, Value Objects, Aggregates, Domain Events, and Repositories.
Scope: This skill covers tactical DDD (the building blocks). For strategic DDD (Bounded Contexts, Context Mapping, subdomain analysis), use the domain-analysis skill.
Not for: Simple CRUD apps, anemic domain models, or when business logic lives entirely in services.
| Concept | What It Is | C# Implementation |
|---|---|---|
| Entity | Object with identity that persists across state changes | Class with Id, equality by identity |
| Value Object | Immutable object defined by its attributes, no identity | record or sealed class with structural equality |
| 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 | record implementing IDomainEvent |
| 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 IsSatisfiedBy(T) |
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));
}
}
Aggregate rules:
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);
Usage in aggregate:
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);
}
Create, From) instead of public constructors for AggregatesCancellationToken on all async Repository methodsLoad based on your task — do not load all at once: