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.

Entity Inheritance Hierarchy
IEntity — Marker interface — Id property
implements
Entity<TId> — Base class — Id, Equals, GetHashCode
extends
AuditableEntity — Adds CreatedBy, CreatedAt, ModifiedBy, ModifiedAt, IsDeleted, DeletedAt, DeletedBy
may implement
ITenantAwareEntity — Interface — TenantId for multi-tenancy

IEntity Interface

Core.Domain/Primitives/IEntity.csC#
/// <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.

Core.Domain/Primitives/Entity.csC#
/// <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.

Core.Domain/Primitives/AuditableEntity.csC#
/// <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; }
}
FieldTypeSet ByPurpose
CreatedBystring?AuditableEntityInterceptorUser ID who created the entity
CreatedAtDateTimeAuditableEntityInterceptorTimestamp of creation (UTC)
ModifiedBystring?AuditableEntityInterceptorUser ID who last modified
ModifiedAtDateTime?AuditableEntityInterceptorTimestamp of last modification (UTC)
IsDeletedboolSoftDeleteBehaviorSoft-delete flag (entity hidden from queries)
DeletedAtDateTime?SoftDeleteBehaviorTimestamp of soft deletion (UTC)
DeletedBystring?SoftDeleteBehaviorUser 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.

Core.Domain/Abstractions/ITenantAwareEntity.csC#
/// <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.

Soft-Delete Lifecycle
Active Entity
DELETE endpoint
Soft Deleted — IsDeleted=true, hidden from queries
appears in
Recycle Bin — Visible via IgnoreQueryFilters()
Restore action
Restored — IsDeleted=false, cascade restore
Purged — Permanently deleted from DB
AuditableEntityInterceptor — Soft Delete HandlingC#
// 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.

Core.Domain/Abstractions/IReadRepository.csC#
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

EntityInheritsTenant-ScopedModuleRepository
AdminAuditableEntity✅ YesIdentityIAdminRepository
UserAuditableEntity✅ YesIdentityIUserRepository
RoleAuditableEntity✅ YesIdentityIRoleRepository
PermissionAuditableEntity❌ NoIdentityIPermissionRepository
TenantAuditableEntity❌ No (self-ref)IdentityITenantRepository
TenantSettingsEntity<Guid>✅ YesIdentity—
MenuItemAuditableEntity❌ NoIdentityIMenuItemRepository
AuditLogEntity<Guid>✅ YesCoreIAuditLogRepository
NotificationAuditableEntity✅ YesCoreINotificationRepository
SentEmailLogEntity<Guid>✅ YesCoreISentEmailLogRepository
MessageTemplateAuditableEntity❌ NoCoreIMessageTemplateRepository
WebhookSubscriptionAuditableEntity✅ YesCoreIWebhookRepository
RefreshTokenEntity<Guid>❌ NoIdentityIRefreshTokenRepository
OtpCodeEntity<Guid>❌ NoIdentityIOtpCodeRepository
RolePermissionAuditableEntity❌ NoIdentityIRolePermissionRepository
RoleMenuItemEntity<Guid>❌ NoIdentityIRoleMenuItemRepository
TenantPermissionAuditableEntity✅ YesIdentityITenantPermissionRepository
TenantMenuOverrideEntity<Guid>✅ YesIdentityITenantMenuOverrideRepository

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.

DbContext — Global Query FiltersC#
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)