skills/cqrs/notification-handlers/SKILL.md
Use when dispatching domain events via MediatR notifications with multiple handlers.
npx skillsauth add faysilalshareef/dotnet-ai-kit notification-handlersInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
OrderCreated, OrderShippedpublic interface IDomainEvent : INotification
{
DateTimeOffset OccurredAt { get; }
}
public abstract record DomainEvent : IDomainEvent
{
public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;
}
public sealed record OrderCreatedEvent(
Guid OrderId,
string CustomerName) : DomainEvent;
public sealed record OrderSubmittedEvent(
Guid OrderId,
decimal Total) : DomainEvent;
public sealed record OrderCancelledEvent(
Guid OrderId,
string Reason) : DomainEvent;
public sealed record OrderItemAddedEvent(
Guid OrderId,
Guid ProductId,
int Quantity) : DomainEvent;
public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> DomainEvents =>
_domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent)
=> _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
public sealed class Order : AggregateRoot
{
public static Order Create(string customerName)
{
var order = new Order { CustomerName = customerName };
order.RaiseDomainEvent(
new OrderCreatedEvent(order.Id, customerName));
return order;
}
public void Submit()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can submit");
Status = OrderStatus.Submitted;
RaiseDomainEvent(new OrderSubmittedEvent(Id, Total));
}
}
// Handler 1: Send confirmation email
internal sealed class SendOrderConfirmationOnCreated(
IEmailService emailService,
ILogger<SendOrderConfirmationOnCreated> logger)
: INotificationHandler<OrderCreatedEvent>
{
public async Task Handle(
OrderCreatedEvent notification, CancellationToken ct)
{
logger.LogInformation(
"Sending confirmation for order {OrderId}",
notification.OrderId);
await emailService.SendOrderConfirmationAsync(
notification.OrderId, notification.CustomerName, ct);
}
}
// Handler 2: Update dashboard stats
internal sealed class UpdateDashboardOnOrderCreated(AppDbContext db)
: INotificationHandler<OrderCreatedEvent>
{
public async Task Handle(
OrderCreatedEvent notification, CancellationToken ct)
{
var stats = await db.DashboardStats.SingleAsync(ct);
stats.IncrementOrderCount();
await db.SaveChangesAsync(ct);
}
}
// Handler 3: Reserve inventory
internal sealed class ReserveInventoryOnOrderSubmitted(
IInventoryService inventoryService)
: INotificationHandler<OrderSubmittedEvent>
{
public async Task Handle(
OrderSubmittedEvent notification, CancellationToken ct)
{
await inventoryService.ReserveForOrderAsync(
notification.OrderId, ct);
}
}
public sealed class DomainEventDispatcher(IPublisher publisher)
: SaveChangesInterceptor
{
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken ct = default)
{
var context = eventData.Context!;
// Collect events from all aggregates
var aggregates = context.ChangeTracker
.Entries<AggregateRoot>()
.Select(e => e.Entity)
.Where(e => e.DomainEvents.Count > 0)
.ToList();
var events = aggregates
.SelectMany(a => a.DomainEvents)
.ToList();
// Clear events before dispatch to prevent re-dispatch
aggregates.ForEach(a => a.ClearDomainEvents());
// Dispatch each event
foreach (var domainEvent in events)
await publisher.Publish(domainEvent, ct);
return result;
}
}
// Registration
builder.Services.AddSingleton<DomainEventDispatcher>();
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
options.AddInterceptors(
sp.GetRequiredService<DomainEventDispatcher>());
});
internal sealed class ProcessOrderPaymentOnSubmitted(
AppDbContext db,
IPaymentService paymentService)
: INotificationHandler<OrderSubmittedEvent>
{
public async Task Handle(
OrderSubmittedEvent notification, CancellationToken ct)
{
// Idempotency check — skip if already processed
var alreadyProcessed = await db.PaymentRecords
.AnyAsync(p => p.OrderId == notification.OrderId, ct);
if (alreadyProcessed)
return;
await paymentService.ChargeAsync(
notification.OrderId, notification.Total, ct);
db.PaymentRecords.Add(new PaymentRecord
{
OrderId = notification.OrderId,
Amount = notification.Total,
ProcessedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
}
}
// Alternative: dispatch events in the handler directly
internal sealed class SubmitOrderHandler(
IOrderRepository repository,
IUnitOfWork unitOfWork,
IPublisher publisher)
: IRequestHandler<SubmitOrderCommand, Result>
{
public async Task<Result> Handle(
SubmitOrderCommand request, CancellationToken ct)
{
var order = await repository.FindAsync(request.OrderId, ct);
if (order is null)
return Result.Failure(
Error.NotFound("Order.NotFound", "Order not found"));
order.Submit();
await unitOfWork.SaveChangesAsync(ct);
// Dispatch events after successful save
foreach (var domainEvent in order.DomainEvents)
await publisher.Publish(domainEvent, ct);
order.ClearDomainEvents();
return Result.Success();
}
}
INotification and INotificationHandler< implementationsIDomainEvent or DomainEvent base typesRaiseDomainEvent or AddDomainEvent in entitiesSaveChangesInterceptor that dispatches eventsIPublisher.Publish callsIDomainEvent interface extending INotificationdata-ai
Use when about to claim work is complete, fixed, passing, or ready — before committing, creating PRs, or moving to the next task. Requires running verification commands and confirming output before making any success claims.
development
Use when encountering any bug, test failure, build error, or unexpected behavior — before proposing fixes or making changes.
development
Use when checkpointing, wrapping up, or handing off an AI-assisted development session.
development
Use when following the Specification-Driven Development lifecycle from plan through ship.