Implement EF Core + PostgreSQL persistence for the Menlo home management app. Use this skill whenever you are adding new entities, bounded-context slices, entity type configurations, slice context interfaces, interceptors, migrations, or integration tests to the Menlo codebase. Also use it when modifying MenloDbContext, ISoftDeletable implementations, or anything in Menlo.Application that touches the database. If the user is working on data access, a new feature slice, asking how data should be stored, or asking about testing persistence — this skill has the answers and must be followed precisely. Even if the user just says "add an entity" or "make this persistable", invoke this skill.
Everything database-related in Menlo lives in src/lib/Menlo.Application/. This is the only project that references EF Core directly. Menlo.Api calls AddMenloApplication() and knows nothing about EF Core internals.
src/lib/Menlo.Application/
├── Common/
│ ├── MenloDbContext.cs ← Single DbContext; implements ALL slice interfaces
│ ├── Interceptors/
│ │ ├── AuditingInterceptor.cs
│ │ └── SoftDeleteInterceptor.cs
│ └── ServiceCollectionExtensions.cs ← AddMenloApplication()
└── <BoundedContext>/ ← One folder per bounded context (Auth, Budget, etc.)
├── I<BoundedContext>Context.cs ← Slice interface (ONLY this context's DbSets)
└── EntityConfigurations/
└── <Entity>EntityTypeConfiguration.cs
PostgreSQL schemas: shared (User, cross-cutting), planning, budget, financial, events, household.
Feature handlers never inject MenloDbContext directly. Each bounded context gets a focused interface that exposes only its own DbSet<T> properties:
// Menlo.Application/Auth/IUserContext.cs
public interface IUserContext
{
DbSet<User> Users { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
MenloDbContext implements all slice interfaces. DI maps each interface back to the same scoped MenloDbContext instance, so change tracking is shared within a request.
When adding a new bounded context (e.g., Budget):
Menlo.Application/Budget/IBudgetContext.cs with only Budget-related DbSet<T> propertiesDbSet<T> properties to MenloDbContext and declare it implements IBudgetContextservices.AddScoped<IBudgetContext>(sp => sp.GetRequiredService<MenloDbContext>())IBudgetContext, never MenloDbContextEach entity gets its own IEntityTypeConfiguration<T> class. Keep them small — most mapping is automatic:
// Menlo.Application/Auth/EntityConfigurations/UserEntityTypeConfiguration.cs
public sealed class UserEntityTypeConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("users", "shared");
// Column names: automatic snake_case (UseSnakeCaseNamingConvention)
// Typed IDs → uuid: automatic central value converter
// DateTimeOffset → timestamptz: Npgsql default, no config needed
// Decimal money values need explicit type:
// builder.Property(x => x.Amount).HasColumnType("numeric(18,4)");
}
}
Register all configurations at once in OnModelCreating:
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MenloDbContext).Assembly);
| C# type | PostgreSQL column type | How it's configured |
|---|---|---|
decimal / Money value object | numeric(18,4) | Explicit: HasColumnType("numeric(18,4)") |
DateTimeOffset | timestamptz | Automatic: Npgsql default — no config needed |
Strongly typed ID (e.g., UserId, BudgetId) | uuid | Automatic: central value converter scans for GuidValueObject types |
string (unconstrained) | text | Automatic: Npgsql default |
bool | boolean | Automatic: Npgsql default |
Never specify column names manually (e.g., HasColumnName("user_id")). The snake_case convention handles it.
ISoftDeletable — Soft DeletesEvery user-generated entity implements ISoftDeletable from Menlo.Lib.Common.Abstractions:
public interface ISoftDeletable
{
bool IsDeleted { get; }
DateTimeOffset? DeletedAt { get; }
UserId? DeletedBy { get; }
}
Two things make soft deletes work automatically:
SoftDeleteInterceptor: Intercepts EntityState.Deleted, changes it to EntityState.Modified, stamps IsDeleted = true, DeletedAt, DeletedBy via ISoftDeleteStampFactoryOnModelCreating for every ISoftDeletable type — excludes IsDeleted = true from all queries automaticallyCallers never set IsDeleted, DeletedAt, or DeletedBy manually. Use .Remove() on the DbSet and the interceptor does the rest.
To access soft-deleted records (admin/restore): .IgnoreQueryFilters()
Migration columns for any ISoftDeletable entity: is_deleted boolean NOT NULL DEFAULT false, deleted_at timestamptz NULL, deleted_by uuid NULL.
IAuditable — AuditingEvery entity implements IAuditable from Menlo.Lib.Common.Abstractions. The AuditingInterceptor automatically calls .Audit(factory, AuditOperation.Create) on EntityState.Added and .Audit(factory, AuditOperation.Update) on EntityState.Modified — callers never touch audit fields directly.
Menlo.Lib/<BoundedContext>/Entities/): entity class implementing IEntity<TId>, IAggregateRoot<TId>, IAuditable, ISoftDeletable, IHasDomainEventsMenlo.Lib/<BoundedContext>/ValueObjects/): readonly record struct <Entity>Id(Guid Value) — follows the UserId patternMenlo.Application/<BoundedContext>/EntityConfigurations/): builder.ToTable("<table>", "<schema>") + decimal column typesDbSet<T> added to MenloDbContextMenlo.Application/<BoundedContext>/): expose the DbSet<T> + SaveChangesAsync; MenloDbContext implements itAddMenloApplication(): services.AddScoped<I<Entity>Context>(sp => sp.GetRequiredService<MenloDbContext>())dotnet ef migrations add Add<Entity> --project src/lib/Menlo.Application --startup-project src/api/Menlo.ApiMenlo.Application.Tests (see Testing section below)Always use these exact flags:
dotnet ef migrations add <MigrationName> \
--project src/lib/Menlo.Application \
--startup-project src/api/Menlo.Api
Migrations live in Menlo.Application/Common/Migrations/ (or the default EF Core output path).
No in-memory EF Core. Ever. In-memory providers don't enforce constraints, column types, or SQL semantics. Every persistence test must use real PostgreSQL via TestContainers.
Test external behaviour, not internal wiring:
.IgnoreQueryFilters() with IsDeleted = trueAudit() was calledMinimal test fixture pattern:
var container = new PostgreSqlBuilder().Build();
await container.StartAsync();
var services = new ServiceCollection();
services.AddMenloApplication(container.GetConnectionString());
var provider = services.BuildServiceProvider();
await provider.GetRequiredService<MenloDbContext>().Database.MigrateAsync();
For full code examples of the DbContext skeleton, DI registration, and test fixtures, read references/patterns.md.