skills/microservice/command/event-store/SKILL.md
Use when configuring EF Core event store with discriminator mapping for event sourcing.
npx skillsauth add faysilalshareef/dotnet-ai-kit event-storeInstall 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.
Event entitiesEvent base table uses TPH (table-per-hierarchy) with a discriminator on EventTypeEventConfiguration sets up the discriminator mapping from EventType enum to concrete event classesGenericEventConfiguration<TEntity, TData> handles the Newtonsoft.Json conversion for the Data column(AggregateId, Sequence) prevents duplicate eventsOutboxMessage has a 1:1 relationship with Event via shared primary key (HasForeignKey<OutboxMessage>(e => e.Id))Type column is stored as a string with HasConversion<string>()The event-store, outbox, and event-routing pipelines deliberately use
Newtonsoft.Json (JsonConvert) instead of System.Text.Json. The
constraints that drive this choice:
IEventData — Newtonsoft handles
$type-discriminated polymorphism out of the box (TypeNameHandling
SerializationBinder). System.Text.Json only added similar
support in .NET 7+ via [JsonPolymorphic] + [JsonDerivedType], and
it requires every concrete type to be statically known at compile time —
incompatible with the event-store's open set of IEventData
implementations contributed by downstream features.[JsonConstructor].StringEnumConverter, contract resolver
for snake-case columns, and NullValueHandling.Ignore are all
Newtonsoft-native. The STJ analogues are partial and noisy.Cross-references: skills/microservice/command/outbox/SKILL.md (line 139
where the outbox publisher imports Newtonsoft.Json),
skills/microservice/processor/event-routing/SKILL.md (line ~200
anti-pattern row reaffirming Newtonsoft), and
knowledge/outbox-pattern.md (line ~251 worked example).
Handles JSON serialization of the typed Data property for each concrete event type. Uses Newtonsoft.Json (not System.Text.Json).
using {Company}.{Domain}.Commands.Domain.Events;
using {Company}.{Domain}.Commands.Domain.Events.DataTypes;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Newtonsoft.Json;
namespace {Company}.{Domain}.Commands.Infra.Persistence.Configurations;
public class GenericEventConfiguration<TEntity, TData> : IEntityTypeConfiguration<TEntity>
where TEntity : Event<TData>
where TData : IEventData
{
public void Configure(EntityTypeBuilder<TEntity> builder)
{
var jsonSerializerSettings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore
};
jsonSerializerSettings.Converters.Add(new Newtonsoft.Json.Converters.StringEnumConverter());
builder.Property(e => e.Data).HasConversion(
v => JsonConvert.SerializeObject(v, jsonSerializerSettings),
v => JsonConvert.DeserializeObject<TData>(v)!
).HasColumnName("Data");
}
}
Key details:
TEntity (the concrete event class) and TData (the event data type)TEntity : Event<TData> and TData : IEventDataNullValueHandling.Ignore and StringEnumConverterDeserializeObject)"Data" explicitlyMaps the EventType enum to concrete event classes using EF Core's discriminator pattern.
using {Company}.{Domain}.Commands.Domain.Enums;
using {Company}.{Domain}.Commands.Domain.Events;
using {Company}.{Domain}.Commands.Domain.Events.Orders;
using {Company}.{Domain}.Commands.Domain.Events.Invoices;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace {Company}.{Domain}.Commands.Infra.Persistence.Configurations;
public class EventConfiguration : IEntityTypeConfiguration<Event>
{
public void Configure(EntityTypeBuilder<Event> builder)
{
builder.HasIndex(e => new { e.AggregateId, e.Sequence }).IsUnique();
builder.Property(e => e.Type)
.HasMaxLength(128)
.HasConversion<string>();
builder.HasDiscriminator(e => e.Type)
.HasValue<OrderCreated>(EventType.OrderCreated)
.HasValue<OrderUpdated>(EventType.OrderUpdated)
.HasValue<OrderItemsAdded>(EventType.OrderItemsAdded)
.HasValue<OrderItemsRemoved>(EventType.OrderItemsRemoved)
.HasValue<InvoiceGenerated>(EventType.InvoiceGenerated)
.HasValue<InvoiceUpdated>(EventType.InvoiceUpdated);
}
}
Key details:
(AggregateId, Sequence) prevents duplicate eventsType is stored as a string via HasConversion<string>() with max length 128EventType enum, mapping each enum value to a concrete event classusing {Company}.{Domain}.Commands.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace {Company}.{Domain}.Commands.Infra.Persistence.Configurations;
public class OutboxMessageConfiguration : IEntityTypeConfiguration<OutboxMessage>
{
public void Configure(EntityTypeBuilder<OutboxMessage> builder)
{
builder.HasOne(e => e.Event)
.WithOne()
.HasForeignKey<OutboxMessage>(e => e.Id)
.IsRequired()
.OnDelete(DeleteBehavior.Cascade);
}
}
Key details:
HasOne(e => e.Event).WithOne() -- OutboxMessage has an Event navigation propertyHasForeignKey<OutboxMessage>(e => e.Id) -- the OutboxMessage's own Id is the FKusing {Company}.{Domain}.Commands.Domain.Entities;
using {Company}.{Domain}.Commands.Domain.Events;
using {Company}.{Domain}.Commands.Domain.Events.DataTypes;
using {Company}.{Domain}.Commands.Domain.Events.Orders;
using {Company}.{Domain}.Commands.Domain.Events.Invoices;
using {Company}.{Domain}.Commands.Infra.Persistence.Configurations;
using Microsoft.EntityFrameworkCore;
namespace {Company}.{Domain}.Commands.Infra.Persistence;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Base configurations
modelBuilder.ApplyConfiguration(new EventConfiguration());
modelBuilder.ApplyConfiguration(new OutboxMessageConfiguration());
// GenericEventConfiguration for each concrete event type
modelBuilder.ApplyConfiguration(new GenericEventConfiguration<OrderCreated, OrderCreatedData>());
modelBuilder.ApplyConfiguration(new GenericEventConfiguration<OrderUpdated, OrderUpdatedData>());
modelBuilder.ApplyConfiguration(new GenericEventConfiguration<OrderItemsAdded, OrderItemsAddedData>());
modelBuilder.ApplyConfiguration(new GenericEventConfiguration<OrderItemsRemoved, OrderItemsRemovedData>());
modelBuilder.ApplyConfiguration(new GenericEventConfiguration<InvoiceGenerated, InvoiceGeneratedData>());
modelBuilder.ApplyConfiguration(new GenericEventConfiguration<InvoiceUpdated, InvoiceUpdatedData>());
base.OnModelCreating(modelBuilder);
}
public DbSet<Event> Events { get; set; }
public DbSet<OutboxMessage> OutboxMessages { get; set; }
}
Key details:
DbSet<Event> for all events (TPH pattern)EventConfiguration AND a GenericEventConfiguration registrationbase.OnModelCreating(modelBuilder) is called at the end{ get; set; } (not expression-bodied => Set<T>())namespace {Company}.{Domain}.Commands.Infra.Persistence.Repositories;
public class EventRepository : AsyncRepository<Event>, IEventRepository
{
private readonly ApplicationDbContext _appDbContext;
public EventRepository(ApplicationDbContext appDbContext) : base(appDbContext)
{
_appDbContext = appDbContext;
}
public async Task<IEnumerable<Event>> GetAllByAggregateIdAsync(
Guid aggregateId, CancellationToken cancellationToken)
=> await _appDbContext.Events
.AsNoTracking()
.Where(e => e.AggregateId == aggregateId)
.OrderBy(e => e.Sequence)
.ToListAsync(cancellationToken);
}
| Anti-Pattern | Correct Approach |
|---|---|
| Updating existing events | Events are immutable -- append only |
| Missing discriminator mapping | Every concrete event needs HasValue<> in EventConfiguration |
| Missing GenericEventConfiguration | Every concrete event also needs its Data JSON conversion registered |
| Using System.Text.Json for Data column | Use Newtonsoft.Json (project convention) |
| Separate tables per event type | Use TPH with single Events table and discriminator |
| OutboxMessage with its own independent Id | OutboxMessage.Id IS the Event.Id (shared PK/FK) |
# Find ApplicationDbContext
grep -r "class ApplicationDbContext" --include="*.cs" src/
# Find GenericEventConfiguration registrations
grep -r "GenericEventConfiguration" --include="*.cs" src/
# Find discriminator setup
grep -r "HasDiscriminator" --include="*.cs" src/
# Find EventConfiguration
grep -r "class EventConfiguration" --include="*.cs" src/
# Find OutboxMessageConfiguration
grep -r "OutboxMessageConfiguration" --include="*.cs" src/
EventConfiguration (HasValue<NewEvent>(EventType.NewEvent))ApplicationDbContext.OnModelCreating(AggregateId, Sequence) existsdata-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.