Domain Model
Entity inheritance hierarchy, AuditableEntity, ITenantAwareEntity, soft-delete lifecycle, repository abstractions, and global query filters.
SCRIPE's domain model follows a strict inheritance hierarchy where all business entities inherit from AuditableEntity, which provides audit fields (CreatedBy, CreatedAt, ModifiedBy, ModifiedAt) and soft-delete support (IsDeleted, DeletedAt, DeletedBy). Tenant-scoped entities additionally implement ITenantAwareEntity for automatic row-level isolation.
Entity Inheritance Hierarchy
All domain entities follow a three-level inheritance chain: IEntity (marker interface) → Entity<TId> (identity + equality + domain events) → AuditableEntity (audit fields + soft-delete). Entities that belong to a specific tenant also implement the ITenantAwareEntity interface.
IEntity Interface
/// <summary>
/// Marker interface for all domain entities.
/// Used for generic constraints in repositories and services.
/// </summary>
public interface IEntity
{
/// <summary>
/// Unique identifier for the entity.
/// </summary>
Guid Id { get; }
}Entity<TId> Base Class
The Entity<TId> base class provides identity equality (two entities are equal if they share the same Id), hash code generation, and domain event support. Every entity can raise domain events that are captured by the OutboxInterceptor and published asynchronously.
/// <summary>
/// Base class for all domain entities.
/// Provides identity, equality, and domain event support.
/// </summary>
public abstract class Entity<TId> : IEntity where TId : notnull
{
public TId Id { get; protected init; }
// ─── Domain Events ──────────────────────────────
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
public void RaiseDomainEvent(IDomainEvent domainEvent)
=> _domainEvents.Add(domainEvent);
public void ClearDomainEvents()
=> _domainEvents.Clear();
// ─── Equality ───────────────────────────────────
public override bool Equals(object? obj)
{
if (obj is not Entity<TId> other) return false;
if (ReferenceEquals(this, other)) return true;
return Id.Equals(other.Id);
}
public override int GetHashCode() => Id.GetHashCode();
public static bool operator ==(Entity<TId>? left, Entity<TId>? right)
=> Equals(left, right);
public static bool operator !=(Entity<TId>? left, Entity<TId>? right)
=> !Equals(left, right);
// Implicit IEntity.Id
Guid IEntity.Id => Id is Guid guid ? guid : throw new InvalidOperationException();
}AuditableEntity
AuditableEntity adds 7 audit and soft-delete fields to the base Entity. These fields are automatically populated by the AuditableEntityInterceptor during SaveChanges — you never set them manually in your code.
/// <summary>
/// Base class for entities that need audit tracking and soft-delete.
/// All CRUD entities should inherit from this class.
/// </summary>
public abstract class AuditableEntity : Entity<Guid>
{
// ─── Audit Fields ───────────────────────────────
public string? CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public string? ModifiedBy { get; set; }
public DateTime? ModifiedAt { get; set; }
// ─── Soft Delete ────────────────────────────────
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
}| Field | Type | Set By | Purpose |
|---|---|---|---|
| CreatedBy | string? | AuditableEntityInterceptor | User ID who created the entity |
| CreatedAt | DateTime | AuditableEntityInterceptor | Timestamp of creation (UTC) |
| ModifiedBy | string? | AuditableEntityInterceptor | User ID who last modified |
| ModifiedAt | DateTime? | AuditableEntityInterceptor | Timestamp of last modification (UTC) |
| IsDeleted | bool | SoftDeleteBehavior | Soft-delete flag (entity hidden from queries) |
| DeletedAt | DateTime? | SoftDeleteBehavior | Timestamp of soft deletion (UTC) |
| DeletedBy | string? | SoftDeleteBehavior | User ID who soft-deleted the entity |
ITenantAwareEntity
Entities that implement ITenantAwareEntity are automatically scoped to the current tenant via EF Core global query filters. The TenantId is set by the TenantContextMiddleware when the entity is created, and all subsequent queries are automatically filtered by tenant.
/// <summary>
/// Interface for entities that belong to a specific tenant.
/// EF Core global query filter automatically scopes queries:
/// modelBuilder.Entity<T>().HasQueryFilter(e => e.TenantId == currentTenantId);
/// </summary>
public interface ITenantAwareEntity
{
/// <summary>
/// Foreign key to the Tenant that owns this entity.
/// Automatically set by TenantContextMiddleware on creation.
/// </summary>
Guid TenantId { get; set; }
}Soft-Delete Lifecycle
All entities use soft-delete via the IsDeleted flag. When a DELETE endpoint is called, the AuditableEntityInterceptor converts the hard delete into a soft-delete by setting IsDeleted=true, DeletedAt, and DeletedBy. The entity remains in the database but is hidden from normal queries by the global query filter.
// In SaveChangesInterceptor — automatically set audit fields
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var context = eventData.Context!;
foreach (var entry in context.ChangeTracker.Entries<AuditableEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedBy = _currentUser.GetUserId();
entry.Entity.CreatedAt = DateTime.UtcNow;
break;
case EntityState.Modified:
entry.Entity.ModifiedBy = _currentUser.GetUserId();
entry.Entity.ModifiedAt = DateTime.UtcNow;
break;
case EntityState.Deleted:
// Convert hard delete → soft delete
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
entry.Entity.DeletedBy = _currentUser.GetUserId();
break;
}
}
return base.SavingChangesAsync(eventData, result, ct);
}Repository Abstractions
SCRIPE defines three repository interfaces in the Domain layer: IReadRepository<T> for queries, IWriteRepository<T> for mutations, and IRepository<T> that combines both with a SaveChangesAsync method. All repository implementations live in the Infrastructure layer.
public interface IReadRepository<TEntity> where TEntity : class, IEntity
{
Task<TEntity?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<TEntity>> GetAllAsync(CancellationToken ct = default);
Task<PagedResult<TEntity>> GetPagedAsync(
int page, int pageSize,
Expression<Func<TEntity, bool>>? filter = null,
CancellationToken ct = default);
Task<bool> ExistsAsync(Guid id, CancellationToken ct = default);
Task<int> CountAsync(
Expression<Func<TEntity, bool>>? filter = null,
CancellationToken ct = default);
}Concrete Entity Registry
| Entity | Inherits | Tenant-Scoped | Module | Repository |
|---|---|---|---|---|
| Admin | AuditableEntity | ✅ Yes | Identity | IAdminRepository |
| User | AuditableEntity | ✅ Yes | Identity | IUserRepository |
| Role | AuditableEntity | ✅ Yes | Identity | IRoleRepository |
| Permission | AuditableEntity | ❌ No | Identity | IPermissionRepository |
| Tenant | AuditableEntity | ❌ No (self-ref) | Identity | ITenantRepository |
| TenantSettings | Entity<Guid> | ✅ Yes | Identity | — |
| MenuItem | AuditableEntity | ❌ No | Identity | IMenuItemRepository |
| AuditLog | Entity<Guid> | ✅ Yes | Core | IAuditLogRepository |
| Notification | AuditableEntity | ✅ Yes | Core | INotificationRepository |
| SentEmailLog | Entity<Guid> | ✅ Yes | Core | ISentEmailLogRepository |
| MessageTemplate | AuditableEntity | ❌ No | Core | IMessageTemplateRepository |
| WebhookSubscription | AuditableEntity | ✅ Yes | Core | IWebhookRepository |
| RefreshToken | Entity<Guid> | ❌ No | Identity | IRefreshTokenRepository |
| OtpCode | Entity<Guid> | ❌ No | Identity | IOtpCodeRepository |
| RolePermission | AuditableEntity | ❌ No | Identity | IRolePermissionRepository |
| RoleMenuItem | Entity<Guid> | ❌ No | Identity | IRoleMenuItemRepository |
| TenantPermission | AuditableEntity | ✅ Yes | Identity | ITenantPermissionRepository |
| TenantMenuOverride | Entity<Guid> | ✅ Yes | Identity | ITenantMenuOverrideRepository |
Global Query Filters
EF Core global query filters are applied to every entity that inherits from AuditableEntity (soft-delete filter) and implements ITenantAwareEntity (tenant isolation filter). These filters are registered once in OnModelCreating and apply to every LINQ query automatically.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply to ALL entities inheriting AuditableEntity
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
// 1. Soft-delete filter — hides IsDeleted=true entities
if (typeof(AuditableEntity).IsAssignableFrom(entityType.ClrType))
{
var parameter = Expression.Parameter(entityType.ClrType, "e");
var prop = Expression.Property(parameter, nameof(AuditableEntity.IsDeleted));
var filter = Expression.Lambda(Expression.Not(prop), parameter);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(filter);
}
// 2. Tenant isolation filter — scopes to current tenant
if (typeof(ITenantAwareEntity).IsAssignableFrom(entityType.ClrType))
{
// Adds: WHERE TenantId = @currentTenantId
// Applied automatically via IDataScopeService
}
}
}Best Practices
✅ DO
- Inherit from AuditableEntity for all business entities
- Use Guid IDs for all entities
- Implement ITenantAwareEntity for tenant-scoped data
- Keep entities pure — no infrastructure dependencies
- Use domain events instead of calling services directly
- Define repositories as interfaces in Domain layer
❌ DON'T
- Never hard-delete entities — always soft-delete via AuditableEntity
- Never inject DbContext into domain entities
- Never skip ITenantAwareEntity for tenant-specific data
- Never put business logic in repositories
- Never call external services from entity methods
- Never use auto-increment IDs (Guid only)