Add or change an EF Core entity — base classes, audit/soft-delete/normalized-property/system-created interfaces, the tenant markers (IMayHaveTenant/IHaveTenant) and the tenant-scoping architecture test, IEntityTypeConfiguration, the AppDbContext DbSet, and the migration commands. Use whenever a database table or column changes.
Install
npx skillscat add ma496/easy-for-net/backend-entity Install via the SkillsCat registry.
Entities, EF configuration and migrations
Where things live
- Entity:
src/backend/Source/Features/<Feature>/Core/Entities/<Entity>.cs - Configuration:
…/Core/Entities/Configuration/<Entity>Configuration.cs DbSet:src/backend/Source/ShareData/AppDbContext.cs- Base types and marker interfaces:
src/backend/Source/ShareData/Entities/Base/
(namespaceBackend.ShareData.Entities.Base— not a global using, so entity files import it)
Every entity belongs to a feature. An entity is never reachable from another feature — other
slices go through a service interface marked [AllowOutside] (see backend-feature), and a
foreign key to another feature's row is a plain Guid column with no navigation property
(Role.TenantId names a tenant without a Tenant navigation).
Base classes
| Base | Gives you |
|---|---|
BaseEntity<TId> |
Id only |
CreatableEntity<TId> |
Id, CreatedAt, CreatedBy |
UpdatableEntity<TId> |
Id, UpdatedAt, UpdatedBy |
AuditableEntity<TId> |
both sets — the default choice |
AuditableEntity (no TId) |
audit fields for a join entity with a composite key (UserRole, RolePermission) |
ValueObject |
equality by components |
AppDbContext.SaveChanges/SaveChangesAsync stamp the audit fields from ICurrentUserService —
never set them by hand. IQueryableExtension.Process needs IBaseEntity<TId> and orders byUpdatedAt/CreatedAt when no sort field is given.
Opt-in interfaces
ISoftDelete(IsDeleted,DeletedAt) — a namedSoftDeletequery filter hides deleted rows
from every query, andRemove(...)becomes a flag update. A unique index is deliberately not
filtered onIsDeletedwhen a deleted row must keep reserving its name (Role,Edition,Tenant); add.HasFilter("\"IsDeleted\" = false")when a removed row must free the value
(TenantMembership).IHasNormalizedProperties— implementNormalizeProperties()to fill…Normalizedcolumns
(private set); the context calls it for added and modified entries. Search and unique-index on
the normalized column.ISystemCreated(SystemCreated) — rows the system seeds for itself (the administrator
accounts and roles, the bootstrap tenant). Nothing is enforced automatically: update/delete
endpoints check the flag andThrowErrorwith the matchingErrorCodes.SystemCreated…code, and
the DTOs that project the entity implementISystemCreatedDtoso the web app can hide the action.- Tenant markers — see below.
Tenant ownership
Decide for every new entity whether its rows belong to a tenant:
| Marker | TenantId |
Use when |
|---|---|---|
IMayHaveTenant |
Guid? — null is platform scope |
a kind has rows that legitimately belong to no tenant (Role, Notification, StoredFile) |
IHaveTenant |
Guid |
every row must name a tenant — an unattributed row cannot be written, not even in platform scope |
Implementing either marker is all it takes. AppDbContext then:
- registers the named
Tenantquery filter (e.TenantId == CurrentTenantId), so every query is
restricted to the active tenant — includingExecuteUpdate/ExecuteDelete; - on save, stamps a new row with the active tenant, throws
TenantScopeNotEstablishedException
when no scope is established, throwsTenantAttributionExceptionfor a row naming another
tenant, and refuses any change to an existing row'sTenantId.
So never copy a TenantId from a request, and never add Where(x => x.TenantId == …) for the
active tenant. .AcrossAllTenants() (in Backend.Extensions) relaxes the tenant filter alone for
a deliberate cross-tenant read, keeping soft delete in force. Writing outside a request (seeder,
background job) needs a scope opened first — using (tenantContext.BeginTenant(id)) orBeginPlatformScope(). The multi-tenancy skill covers scopes in depth.
An entity with neither marker fails the build's tests. Tests/Architect/TenantScopingTests
requires every persisted entity to be tenant-scoped or listed in its _exemptEntities dictionary
with a written reason. Exempt a kind only when it truly belongs to no tenant (Tenant, Edition,User, Permission) or its tenant is derived through a parent (UserRole via Role,NotificationVisit via Notification) — a second tenant column there would admit rows where the
two disagree.
using Backend.ShareData.Entities.Base;
namespace Backend.Features.Identity.Core.Entities;
/// <summary>
/// A named bundle of permissions that can be assigned to one or more users. A role that names a
/// tenant is that tenant's alone, while a role with no <see cref="TenantId"/> is platform scoped.
/// </summary>
public class Role : AuditableEntity<Guid>, IHasNormalizedProperties, ISoftDelete, IMayHaveTenant, ISystemCreated
{
public Guid? TenantId { get; set; }
public bool SystemCreated { get; set; }
public string Name { get; set; } = null!;
public string NameNormalized { get; private set; } = null!;
public string? Description { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public ICollection<UserRole> UserRoles { get; set; } = [];
public ICollection<RolePermission> RolePermissions { get; set; } = [];
public void NormalizeProperties()
{
NameNormalized = Name.Trim().ToLowerInvariant();
}
}Conventions: Guid keys, non-nullable reference properties initialised with = null!,
collections with = [], a /// <summary> saying what a row is and which tenant it belongs to.
Configuration
One class per entity implementing IEntityTypeConfiguration<T>; OnModelCreating applies every
configuration in the assembly, so there is nothing to register.
using Backend.Features.Notifications.Core.Entities;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Backend.Features.Notifications.Core.Entities.Configuration;
/// <summary>
/// EF Core entity configuration for <see cref="Notification"/>. Maps it to the
/// "Notifications" table in the "notifications" schema and indexes the columns it is read by.
/// </summary>
public class NotificationConfiguration : IEntityTypeConfiguration<Notification>
{
public void Configure(EntityTypeBuilder<Notification> builder)
{
builder.ToTable("Notifications", "notifications");
builder.HasKey(x => x.Id);
builder.Property(x => x.Type).HasConversion<string>();
builder.HasIndex(x => new { x.TenantId, x.UserId });
builder.HasIndex(x => new { x.TenantId, x.UserId, x.IsRead, x.CreatedAt })
.IsDescending(false, false, false, true)
.HasFilter("\"UserId\" IS NOT NULL AND NOT \"IsDeleted\"")
.HasDatabaseName("IX_Notifications_Personal");
}
}Rules the existing configurations follow:
- Always
ToTable("<PluralName>", "<feature-schema>")— each feature owns a PostgreSQL schema,
its lowercase name (identity,notifications,tenancy,filemanagement,localization,settings). - Enums declare no numeric values and are stored as strings (
HasConversion<string>()) — an
equality comparison in a query translates just as well against the stored name. An enum never
sits in its entity's file: every enum of a slice lives inFeatures/<Feature>/Core/Enums.cs
(namespaceBackend.Features.<Feature>.Core), which holds enums and nothing else. - Index every column you filter or sort on,
TenantIdincluded (leading a composite index when
reads are "X of the active tenant"). - Uniqueness goes on the normalized column, scoped by tenant for tenant-owned kinds, and the raw
column behind it gets a twin unique index on the same terms:
builder.HasIndex(r => new { r.TenantId, r.Name })
.IsUnique()
.AreNullsDistinct(false)
.HasDatabaseName("IX_Roles_TenantId_Name");
builder.HasIndex(r => new { r.TenantId, r.NameNormalized })
.IsUnique()
.AreNullsDistinct(false) // platform rows (null tenant) are unique among themselves too
.HasDatabaseName("IX_Roles_TenantId_NameNormalized");- Name unique indexes after their columns, so the last
_segment is the request field.ExceptionProcessorturns a unique violation intoduplicatePropertyValueagainst that segment
with aNormalizedsuffix dropped, lower-cased, soIX_Editions_NameNormalizedandIX_Editions_Nameboth reportname. - Relationships inside a feature get explicit
HasOne(...).WithMany(...).HasForeignKey(...);OnDelete(DeleteBehavior.Cascade)where a child cannot outlive its parent,DeleteBehavior.Restrictwhere the endpoint refuses the delete first.
DbSet
Add to src/backend/Source/ShareData/AppDbContext.cs under the feature's comment:
// Notifications
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<NotificationVisit> NotificationVisits => Set<NotificationVisit>();
public DbSet<NotificationReadCursor> NotificationReadCursors => Set<NotificationReadCursor>();Migration
From the repository root:
dotnet tool restore
npm run stop:api # a running API locks the build output dotnet ef builds into
dotnet ef migrations add <Name> --project src/backend/Source/Backend.csproj
dotnet ef database update --project src/backend/Source/Backend.csprojDevelopment and Testing apply migrations on startup (Database:ApplyMigrationsOnStartup defaults
to true there only), so running the API or the test suite is usually enough locally.
Name migrations after the change (AddNotificationGroups), review the generated file, and never
edit an already-applied migration — add a new one. A project generated with dotnet efn cp starts
without a Migrations folder; its first migration is dotnet ef migrations add Initial.
Seeded data
ShareData/DataSeeder runs on every startup and reconciles the permission catalogue, the bootstrap
tenant, the default edition, the platform administrator role and admin account, the bootstrap tenant's administrator
role and tenantadmin account with its membership, sample notifications, and prunes stored feature
values the code no longer declares. Put baseline rows a fresh database cannot work without there,
inside the tenant or platform scope they belong to — not in a migration.
Verify
npm run stop:api, dotnet build EasyForNet.slnx, then dotnet test src/backend/Tests/Backend.Tests.csproj —TenantScopingTests and FeatureDependencyTests run there.