.agents/skills/add-entity/SKILL.md
Rich domain model: sealed aggregate, private EF ctor, static factory, behavior via methods, domain
events. DB conventions: .agents/rules/database.md.
AggregateRoot<Guid> (or BaseEntity<Guid>)BaseEntity<TId> gives only Id + domain-event machinery. Audit/tenant/soft-delete are opt-in via
marker interfaces (the base does NOT carry those fields). New ids use Guid.CreateVersion7().
public sealed class {Entity} : AggregateRoot<Guid>, IHasTenant, IAuditableEntity, ISoftDeletable
{
public string Name { get; private set; } = default!;
public Money Price { get; private set; } = default!;
// IHasTenant
public string TenantId { get; private set; } = default!;
// IAuditableEntity
public DateTimeOffset CreatedOnUtc { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset? LastModifiedOnUtc { get; set; }
public string? LastModifiedBy { get; set; }
// ISoftDeletable
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedOnUtc { get; set; }
public string? DeletedBy { get; set; }
private {Entity}() { } // EF
public static {Entity} Create(string name, Money price)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(price);
var entity = new {Entity} { Id = Guid.CreateVersion7(), Name = name.Trim(), Price = price };
entity.AddDomainEvent(DomainEvent.Create((id, ts) =>
new {Entity}CreatedDomainEvent(entity.Id, entity.Name, id, ts)));
return entity;
}
public void Rename(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
Name = name.Trim();
}
}
Notes: setters are private set; TenantId/audit/soft-delete members are settable by the framework
(interceptor + Finbuckle) so they aren't private set. Use Guid.CreateVersion7(), never Guid.NewGuid().
DomainEvent (abstract record)public sealed record {Entity}CreatedDomainEvent(
Guid {Entity}Id, string Name, Guid EventId, DateTimeOffset OccurredOnUtc)
: DomainEvent(EventId, OccurredOnUtc);
Raise with the DomainEvent.Create((id, ts) => …) helper + AddDomainEvent(...) (not QueueDomainEvent).
public sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>
{
public void Configure(EntityTypeBuilder<{Entity}> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.ToTable("{Entities}"); // schema is set once on the DbContext
builder.HasKey(x => x.Id);
builder.Property(x => x.Name).IsRequired().HasMaxLength(200);
// soft-deletable unique field → filter on live rows only
builder.HasIndex(x => x.Name).IsUnique().HasFilter("\"IsDeleted\" = FALSE");
// owned value object
builder.OwnsOne(x => x.Price, m =>
{
m.Property(p => p.Amount).HasColumnName("PriceAmount").HasPrecision(18, 4);
m.Property(p => p.Currency).HasColumnName("PriceCurrency").HasMaxLength(3);
});
builder.Ignore(x => x.DomainEvents);
}
}
HasQueryFilter for soft-delete or tenant — BaseDbContext applies both automatically.builder.Property(x => x.Id).ValueGeneratedNever() in its config, or EF inserts it as Modified → 0-row UPDATE. See database.md.Add a DbSet; configurations are picked up by ApplyConfigurationsFromAssembly:
public DbSet<{Entity}> {Entities} => Set<{Entity}>();
The DbContext already extends BaseDbContext and calls base.OnModelCreating last — don't change that.
Use the create-migration skill (build first, correct --context):
dotnet ef migrations add Add{Entity} \
--project src/Host/FSH.Starter.Migrations.PostgreSQL \
--startup-project src/Host/FSH.Starter.Api \
--context {X}DbContext
sealed, AggregateRoot<Guid> (+ IHasTenant/IAuditableEntity/ISoftDeletable as needed), private ctor, static Create using Guid.CreateVersion7()DomainEvent; raised via DomainEvent.Create + AddDomainEventValueGeneratedNever() on nav-collection childrenDbSet added; build green; migration created with --context {X}DbContext