diff --git a/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs b/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs
new file mode 100644
index 00000000..ca3c1329
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs
@@ -0,0 +1,18 @@
+using SimpleModule.Core.Events;
+
+namespace SimpleModule.Core.Entities;
+
+///
+/// Aggregate root with audit tracking, soft delete, versioning, and domain events.
+/// Domain events are automatically dispatched via after SaveChanges.
+///
+public abstract class AuditableAggregateRoot : FullAuditableEntity, IHasDomainEvents
+{
+ private readonly List _domainEvents = [];
+
+ public IReadOnlyList GetDomainEvents() => _domainEvents.AsReadOnly();
+
+ public void ClearDomainEvents() => _domainEvents.Clear();
+
+ protected void AddDomainEvent(IEvent domainEvent) => _domainEvents.Add(domainEvent);
+}
diff --git a/framework/SimpleModule.Core/Entities/AuditableEntity.cs b/framework/SimpleModule.Core/Entities/AuditableEntity.cs
new file mode 100644
index 00000000..6cc8ffd5
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/AuditableEntity.cs
@@ -0,0 +1,10 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entity with full audit tracking: timestamps and the user who created/modified the entity.
+///
+public abstract class AuditableEntity : Entity, IAuditable
+{
+ public string? CreatedBy { get; set; }
+ public string? UpdatedBy { get; set; }
+}
diff --git a/framework/SimpleModule.Core/Entities/Entity.cs b/framework/SimpleModule.Core/Entities/Entity.cs
new file mode 100644
index 00000000..552a0baa
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/Entity.cs
@@ -0,0 +1,12 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Base entity with a strongly-typed ID, creation/modification timestamps, and a concurrency stamp.
+///
+public abstract class Entity : IHasCreationTime, IHasModificationTime, IHasConcurrencyStamp
+{
+ public TId Id { get; set; } = default!;
+ public DateTimeOffset CreatedAt { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+ public string ConcurrencyStamp { get; set; } = string.Empty;
+}
diff --git a/framework/SimpleModule.Core/Entities/EntityChangeContext.cs b/framework/SimpleModule.Core/Entities/EntityChangeContext.cs
new file mode 100644
index 00000000..27a3cd5a
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/EntityChangeContext.cs
@@ -0,0 +1,6 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Context passed to when an entity change is detected after SaveChanges.
+///
+public sealed record EntityChangeContext(T Entity, EntityChangeType ChangeType) where T : class;
diff --git a/framework/SimpleModule.Core/Entities/EntityChangeType.cs b/framework/SimpleModule.Core/Entities/EntityChangeType.cs
new file mode 100644
index 00000000..3dfadfc6
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/EntityChangeType.cs
@@ -0,0 +1,11 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// The type of change that occurred on an entity.
+///
+public enum EntityChangeType
+{
+ Created,
+ Updated,
+ Deleted,
+}
diff --git a/framework/SimpleModule.Core/Entities/FullAuditableEntity.cs b/framework/SimpleModule.Core/Entities/FullAuditableEntity.cs
new file mode 100644
index 00000000..46465f75
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/FullAuditableEntity.cs
@@ -0,0 +1,13 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entity with full audit tracking, soft delete, and versioning.
+/// This is the most commonly used base class for business entities.
+///
+public abstract class FullAuditableEntity : AuditableEntity, ISoftDelete, IVersioned
+{
+ public bool IsDeleted { get; set; }
+ public DateTimeOffset? DeletedAt { get; set; }
+ public string? DeletedBy { get; set; }
+ public int Version { get; set; }
+}
diff --git a/framework/SimpleModule.Core/Entities/IAuditable.cs b/framework/SimpleModule.Core/Entities/IAuditable.cs
new file mode 100644
index 00000000..4c23bc11
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/IAuditable.cs
@@ -0,0 +1,11 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entities implementing this interface get full audit tracking:
+/// timestamps and the user who created/modified the entity (resolved from the current HTTP context).
+///
+public interface IAuditable : IHasCreationTime, IHasModificationTime
+{
+ string? CreatedBy { get; set; }
+ string? UpdatedBy { get; set; }
+}
diff --git a/framework/SimpleModule.Core/Entities/IEntityChangeHandler.cs b/framework/SimpleModule.Core/Entities/IEntityChangeHandler.cs
new file mode 100644
index 00000000..c4b6ff2a
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/IEntityChangeHandler.cs
@@ -0,0 +1,10 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Typed handler invoked automatically after SaveChanges when an entity of type changes.
+/// Useful for cache invalidation, denormalized data updates, or triggering side effects.
+///
+public interface IEntityChangeHandler where T : class
+{
+ Task HandleAsync(EntityChangeContext context, CancellationToken cancellationToken = default);
+}
diff --git a/framework/SimpleModule.Core/Entities/IHasConcurrencyStamp.cs b/framework/SimpleModule.Core/Entities/IHasConcurrencyStamp.cs
new file mode 100644
index 00000000..0a195c94
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/IHasConcurrencyStamp.cs
@@ -0,0 +1,10 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entities implementing this interface get a random concurrency stamp set on each save.
+/// Configured as a concurrency token for optimistic concurrency control.
+///
+public interface IHasConcurrencyStamp
+{
+ string ConcurrencyStamp { get; set; }
+}
diff --git a/framework/SimpleModule.Core/Entities/IHasCreationTime.cs b/framework/SimpleModule.Core/Entities/IHasCreationTime.cs
new file mode 100644
index 00000000..75396942
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/IHasCreationTime.cs
@@ -0,0 +1,9 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entities implementing this interface get automatically set on insert.
+///
+public interface IHasCreationTime
+{
+ DateTimeOffset CreatedAt { get; set; }
+}
diff --git a/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs b/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs
new file mode 100644
index 00000000..ba0da6ad
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs
@@ -0,0 +1,13 @@
+using SimpleModule.Core.Events;
+
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entities implementing this interface can raise domain events that are automatically
+/// dispatched via after a successful SaveChanges.
+///
+public interface IHasDomainEvents
+{
+ IReadOnlyList GetDomainEvents();
+ void ClearDomainEvents();
+}
diff --git a/framework/SimpleModule.Core/Entities/IHasExtraProperties.cs b/framework/SimpleModule.Core/Entities/IHasExtraProperties.cs
new file mode 100644
index 00000000..f8fe0743
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/IHasExtraProperties.cs
@@ -0,0 +1,10 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entities implementing this interface have a flexible key-value property bag
+/// stored as a JSON column. Useful for extensibility without schema changes.
+///
+public interface IHasExtraProperties
+{
+ Dictionary ExtraProperties { get; set; }
+}
diff --git a/framework/SimpleModule.Core/Entities/IHasModificationTime.cs b/framework/SimpleModule.Core/Entities/IHasModificationTime.cs
new file mode 100644
index 00000000..8ba7e15d
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/IHasModificationTime.cs
@@ -0,0 +1,9 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entities implementing this interface get automatically set on insert and update.
+///
+public interface IHasModificationTime
+{
+ DateTimeOffset UpdatedAt { get; set; }
+}
diff --git a/framework/SimpleModule.Core/Entities/IMultiTenant.cs b/framework/SimpleModule.Core/Entities/IMultiTenant.cs
new file mode 100644
index 00000000..57bbea3d
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/IMultiTenant.cs
@@ -0,0 +1,11 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entities implementing this interface are automatically assigned a tenant ID
+/// from the current on insert. A global query filter
+/// restricts queries to the current tenant.
+///
+public interface IMultiTenant
+{
+ string TenantId { get; set; }
+}
diff --git a/framework/SimpleModule.Core/Entities/ISoftDelete.cs b/framework/SimpleModule.Core/Entities/ISoftDelete.cs
new file mode 100644
index 00000000..681fc498
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/ISoftDelete.cs
@@ -0,0 +1,13 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entities implementing this interface are soft-deleted instead of hard-deleted.
+/// When EF Core Remove() is called, the entity state is changed to Modified and
+/// the soft-delete fields are set automatically. A global query filter excludes soft-deleted entities.
+///
+public interface ISoftDelete
+{
+ bool IsDeleted { get; set; }
+ DateTimeOffset? DeletedAt { get; set; }
+ string? DeletedBy { get; set; }
+}
diff --git a/framework/SimpleModule.Core/Entities/ITenantContext.cs b/framework/SimpleModule.Core/Entities/ITenantContext.cs
new file mode 100644
index 00000000..8f1d7250
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/ITenantContext.cs
@@ -0,0 +1,10 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Provides the current tenant ID for multi-tenant entity filtering and assignment.
+/// Resolved from the current request context (e.g., HTTP header, claim, or subdomain).
+///
+public interface ITenantContext
+{
+ string? TenantId { get; }
+}
diff --git a/framework/SimpleModule.Core/Entities/IVersioned.cs b/framework/SimpleModule.Core/Entities/IVersioned.cs
new file mode 100644
index 00000000..dabb4cf6
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/IVersioned.cs
@@ -0,0 +1,10 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entities implementing this interface get an auto-incrementing version number.
+/// Starts at 1 on insert, increments on each update. Configured as a concurrency token.
+///
+public interface IVersioned
+{
+ int Version { get; set; }
+}
diff --git a/framework/SimpleModule.Core/Entities/MultiTenantFullAuditableEntity.cs b/framework/SimpleModule.Core/Entities/MultiTenantFullAuditableEntity.cs
new file mode 100644
index 00000000..dc03dce9
--- /dev/null
+++ b/framework/SimpleModule.Core/Entities/MultiTenantFullAuditableEntity.cs
@@ -0,0 +1,11 @@
+namespace SimpleModule.Core.Entities;
+
+///
+/// Entity with full audit tracking, soft delete, versioning, multi-tenancy, and extra properties.
+/// Use this for entities in multi-tenant modules that need maximum extensibility.
+///
+public abstract class MultiTenantFullAuditableEntity : FullAuditableEntity, IMultiTenant, IHasExtraProperties
+{
+ public string TenantId { get; set; } = string.Empty;
+ public Dictionary ExtraProperties { get; set; } = [];
+}
diff --git a/framework/SimpleModule.Database/DatabaseConstants.cs b/framework/SimpleModule.Database/DatabaseConstants.cs
index 8690783e..ddd17ef1 100644
--- a/framework/SimpleModule.Database/DatabaseConstants.cs
+++ b/framework/SimpleModule.Database/DatabaseConstants.cs
@@ -8,4 +8,12 @@ public static class DatabaseConstants
public const string SqlServerCatalogPrefix = "Initial Catalog=";
public const string SqlServerLocalPrefix = @"Server=.\";
public const string SqlServerExpressionPrefix = @"Server=(";
+
+ /// Named query filter key for entities.
+ public const string SoftDeleteQueryFilterKey = "SimpleModule:SoftDelete";
+
+ /// Named query filter key for entities.
+ public const string MultiTenantQueryFilterKey = "SimpleModule:MultiTenant";
+
+ internal const string EntityConventionsAppliedAnnotation = "SimpleModule:EntityConventionsApplied";
}
diff --git a/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs b/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs
new file mode 100644
index 00000000..11d65eb7
--- /dev/null
+++ b/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs
@@ -0,0 +1,86 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.DependencyInjection;
+using SimpleModule.Core.Entities;
+using SimpleModule.Core.Events;
+
+namespace SimpleModule.Database.Interceptors;
+
+///
+/// Interceptor that collects domain events from entities
+/// before SaveChanges and dispatches them via after a successful save.
+/// Events are cleared from entities after dispatch to prevent re-processing.
+/// Registered as scoped — each DbContext gets its own instance, so instance fields are safe.
+///
+public sealed class DomainEventInterceptor(IServiceProvider serviceProvider) : SaveChangesInterceptor
+{
+ private static readonly MethodInfo PublishAsyncMethod =
+ typeof(IEventBus).GetMethod(nameof(IEventBus.PublishAsync))!;
+ private static readonly ConcurrentDictionary PublishMethodCache = new();
+
+ private List? _collectedEvents;
+
+ public override ValueTask> SavingChangesAsync(
+ DbContextEventData eventData,
+ InterceptionResult result,
+ CancellationToken cancellationToken = default)
+ {
+ if (eventData.Context is not null)
+ {
+ var events = new List();
+
+ foreach (var entry in eventData.Context.ChangeTracker.Entries())
+ {
+ var domainEvents = entry.Entity.GetDomainEvents();
+ if (domainEvents.Count > 0)
+ {
+ events.AddRange(domainEvents);
+ entry.Entity.ClearDomainEvents();
+ }
+ }
+
+ _collectedEvents = events.Count > 0 ? events : null;
+ }
+
+ return base.SavingChangesAsync(eventData, result, cancellationToken);
+ }
+
+ public override async ValueTask SavedChangesAsync(
+ SaveChangesCompletedEventData eventData,
+ int result,
+ CancellationToken cancellationToken = default)
+ {
+ var events = _collectedEvents;
+ _collectedEvents = null;
+
+ if (events is { Count: > 0 })
+ {
+ var eventBus = serviceProvider.GetService();
+ if (eventBus is not null)
+ {
+ foreach (var domainEvent in events)
+ {
+ // Invoke PublishAsync with the concrete event type so that
+ // IEventHandler registrations are resolved correctly.
+ // A static call to PublishAsync(IEvent) would resolve T as IEvent,
+ // missing all concrete handlers.
+ var concreteMethod = PublishMethodCache.GetOrAdd(
+ domainEvent.GetType(),
+ static type => PublishAsyncMethod.MakeGenericMethod(type));
+ await (Task)concreteMethod.Invoke(eventBus, [domainEvent, cancellationToken])!;
+ }
+ }
+ }
+
+ return await base.SavedChangesAsync(eventData, result, cancellationToken);
+ }
+
+ public override Task SaveChangesFailedAsync(
+ DbContextErrorEventData eventData,
+ CancellationToken cancellationToken = default)
+ {
+ _collectedEvents = null;
+ return base.SaveChangesFailedAsync(eventData, cancellationToken);
+ }
+}
diff --git a/framework/SimpleModule.Database/Interceptors/EntityChangeInterceptor.cs b/framework/SimpleModule.Database/Interceptors/EntityChangeInterceptor.cs
new file mode 100644
index 00000000..31606e0b
--- /dev/null
+++ b/framework/SimpleModule.Database/Interceptors/EntityChangeInterceptor.cs
@@ -0,0 +1,151 @@
+using System.Collections.Concurrent;
+using System.Linq.Expressions;
+using System.Reflection;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using SimpleModule.Core.Entities;
+
+namespace SimpleModule.Database.Interceptors;
+
+///
+/// Interceptor that captures entity changes before SaveChanges and dispatches them to
+/// typed implementations after a successful save.
+/// Registered as scoped — each DbContext gets its own instance, so instance fields are safe.
+/// Caches compiled delegates per entity type to avoid per-save reflection overhead.
+///
+public sealed class EntityChangeInterceptor(
+ IServiceProvider serviceProvider,
+ ILogger logger
+) : SaveChangesInterceptor
+{
+ private static readonly ConcurrentDictionary MetadataCache = new();
+
+ private List<(object Entity, EntityChangeType ChangeType)>? _capturedChanges;
+
+ public override ValueTask> SavingChangesAsync(
+ DbContextEventData eventData,
+ InterceptionResult result,
+ CancellationToken cancellationToken = default)
+ {
+ if (eventData.Context is not null)
+ {
+ var changes = new List<(object Entity, EntityChangeType ChangeType)>();
+
+ foreach (var entry in eventData.Context.ChangeTracker.Entries())
+ {
+ if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted))
+ continue;
+
+ var changeType = entry.State switch
+ {
+ EntityState.Added => EntityChangeType.Created,
+ EntityState.Deleted => EntityChangeType.Deleted,
+ EntityState.Modified when entry.Entity is ISoftDelete { IsDeleted: true }
+ && entry.Property(nameof(ISoftDelete.IsDeleted)).IsModified
+ => EntityChangeType.Deleted,
+ _ => EntityChangeType.Updated,
+ };
+
+ changes.Add((entry.Entity, changeType));
+ }
+
+ _capturedChanges = changes.Count > 0 ? changes : null;
+ }
+
+ return base.SavingChangesAsync(eventData, result, cancellationToken);
+ }
+
+ public override async ValueTask SavedChangesAsync(
+ SaveChangesCompletedEventData eventData,
+ int result,
+ CancellationToken cancellationToken = default)
+ {
+ var changes = _capturedChanges;
+ _capturedChanges = null;
+
+ if (changes is { Count: > 0 })
+ {
+ foreach (var (entity, changeType) in changes)
+ {
+ await DispatchToHandlersAsync(entity, changeType, cancellationToken);
+ }
+ }
+
+ return await base.SavedChangesAsync(eventData, result, cancellationToken);
+ }
+
+ public override Task SaveChangesFailedAsync(
+ DbContextErrorEventData eventData,
+ CancellationToken cancellationToken = default)
+ {
+ _capturedChanges = null;
+ return base.SaveChangesFailedAsync(eventData, cancellationToken);
+ }
+
+ private async Task DispatchToHandlersAsync(
+ object entity,
+ EntityChangeType changeType,
+ CancellationToken cancellationToken)
+ {
+ var entityType = entity.GetType();
+ var metadata = MetadataCache.GetOrAdd(entityType, BuildMetadata);
+ var handlers = serviceProvider.GetServices(metadata.HandlerType);
+
+ foreach (var handler in handlers)
+ {
+ try
+ {
+ var context = metadata.ContextFactory(entity, changeType);
+ await metadata.Invoker(handler!, context, cancellationToken);
+ }
+#pragma warning disable CA1031 // Handlers should not crash the save pipeline
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ logger.LogError(
+ ex,
+ "Entity change handler {HandlerType} failed for {EntityType} ({ChangeType})",
+ handler?.GetType().Name,
+ entityType.Name,
+ changeType);
+ }
+ }
+ }
+
+ private static HandlerMetadata BuildMetadata(Type entityType)
+ {
+ var handlerType = typeof(IEntityChangeHandler<>).MakeGenericType(entityType);
+ var contextType = typeof(EntityChangeContext<>).MakeGenericType(entityType);
+
+ // Compile a factory delegate: (object entity, EntityChangeType ct) => new EntityChangeContext((T)entity, ct)
+ var entityParam = Expression.Parameter(typeof(object), "entity");
+ var changeTypeParam = Expression.Parameter(typeof(EntityChangeType), "changeType");
+ var ctor = contextType.GetConstructor([entityType, typeof(EntityChangeType)])!;
+ var newExpr = Expression.New(ctor, Expression.Convert(entityParam, entityType), changeTypeParam);
+ var contextFactory = Expression.Lambda>(
+ Expression.Convert(newExpr, typeof(object)),
+ entityParam, changeTypeParam).Compile();
+
+ // Compile an invoker delegate: (object handler, object ctx, CancellationToken ct) => ((IEntityChangeHandler)handler).HandleAsync((EntityChangeContext)ctx, ct)
+ var handlerParam = Expression.Parameter(typeof(object), "handler");
+ var ctxParam = Expression.Parameter(typeof(object), "ctx");
+ var ctParam = Expression.Parameter(typeof(CancellationToken), "ct");
+ var handleMethod = handlerType.GetMethod(nameof(IEntityChangeHandler