Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using SimpleModule.Core.Events;

namespace SimpleModule.Core.Entities;

/// <summary>
/// Aggregate root with audit tracking, soft delete, versioning, and domain events.
/// Domain events are automatically dispatched via <see cref="IEventBus"/> after SaveChanges.
/// </summary>
public abstract class AuditableAggregateRoot<TId> : FullAuditableEntity<TId>, IHasDomainEvents
{
private readonly List<IEvent> _domainEvents = [];

public IReadOnlyList<IEvent> GetDomainEvents() => _domainEvents.AsReadOnly();

public void ClearDomainEvents() => _domainEvents.Clear();

protected void AddDomainEvent(IEvent domainEvent) => _domainEvents.Add(domainEvent);
}
10 changes: 10 additions & 0 deletions framework/SimpleModule.Core/Entities/AuditableEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Entity with full audit tracking: timestamps and the user who created/modified the entity.
/// </summary>
public abstract class AuditableEntity<TId> : Entity<TId>, IAuditable
{
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
}
12 changes: 12 additions & 0 deletions framework/SimpleModule.Core/Entities/Entity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Base entity with a strongly-typed ID, creation/modification timestamps, and a concurrency stamp.
/// </summary>
public abstract class Entity<TId> : 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;
}
6 changes: 6 additions & 0 deletions framework/SimpleModule.Core/Entities/EntityChangeContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Context passed to <see cref="IEntityChangeHandler{T}"/> when an entity change is detected after SaveChanges.
/// </summary>
public sealed record EntityChangeContext<T>(T Entity, EntityChangeType ChangeType) where T : class;
11 changes: 11 additions & 0 deletions framework/SimpleModule.Core/Entities/EntityChangeType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// The type of change that occurred on an entity.
/// </summary>
public enum EntityChangeType
{
Created,
Updated,
Deleted,
}
13 changes: 13 additions & 0 deletions framework/SimpleModule.Core/Entities/FullAuditableEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Entity with full audit tracking, soft delete, and versioning.
/// This is the most commonly used base class for business entities.
/// </summary>
public abstract class FullAuditableEntity<TId> : AuditableEntity<TId>, ISoftDelete, IVersioned
{
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
public int Version { get; set; }
}
11 changes: 11 additions & 0 deletions framework/SimpleModule.Core/Entities/IAuditable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Entities implementing this interface get full audit tracking:
/// timestamps and the user who created/modified the entity (resolved from the current HTTP context).
/// </summary>
public interface IAuditable : IHasCreationTime, IHasModificationTime
{
string? CreatedBy { get; set; }
string? UpdatedBy { get; set; }
}
10 changes: 10 additions & 0 deletions framework/SimpleModule.Core/Entities/IEntityChangeHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Typed handler invoked automatically after SaveChanges when an entity of type <typeparamref name="T"/> changes.
/// Useful for cache invalidation, denormalized data updates, or triggering side effects.
/// </summary>
public interface IEntityChangeHandler<T> where T : class
{
Task HandleAsync(EntityChangeContext<T> context, CancellationToken cancellationToken = default);
}
10 changes: 10 additions & 0 deletions framework/SimpleModule.Core/Entities/IHasConcurrencyStamp.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Entities implementing this interface get a random concurrency stamp set on each save.
/// Configured as a concurrency token for optimistic concurrency control.
/// </summary>
public interface IHasConcurrencyStamp
{
string ConcurrencyStamp { get; set; }
}
9 changes: 9 additions & 0 deletions framework/SimpleModule.Core/Entities/IHasCreationTime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Entities implementing this interface get <see cref="CreatedAt"/> automatically set on insert.
/// </summary>
public interface IHasCreationTime
{
DateTimeOffset CreatedAt { get; set; }
}
13 changes: 13 additions & 0 deletions framework/SimpleModule.Core/Entities/IHasDomainEvents.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using SimpleModule.Core.Events;

namespace SimpleModule.Core.Entities;

/// <summary>
/// Entities implementing this interface can raise domain events that are automatically
/// dispatched via <see cref="IEventBus"/> after a successful SaveChanges.
/// </summary>
public interface IHasDomainEvents
{
IReadOnlyList<IEvent> GetDomainEvents();
void ClearDomainEvents();
}
10 changes: 10 additions & 0 deletions framework/SimpleModule.Core/Entities/IHasExtraProperties.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Entities implementing this interface have a flexible key-value property bag
/// stored as a JSON column. Useful for extensibility without schema changes.
/// </summary>
public interface IHasExtraProperties
{
Dictionary<string, object?> ExtraProperties { get; set; }
}
9 changes: 9 additions & 0 deletions framework/SimpleModule.Core/Entities/IHasModificationTime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Entities implementing this interface get <see cref="UpdatedAt"/> automatically set on insert and update.
/// </summary>
public interface IHasModificationTime
{
DateTimeOffset UpdatedAt { get; set; }
}
11 changes: 11 additions & 0 deletions framework/SimpleModule.Core/Entities/IMultiTenant.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Entities implementing this interface are automatically assigned a tenant ID
/// from the current <see cref="ITenantContext"/> on insert. A global query filter
/// restricts queries to the current tenant.
/// </summary>
public interface IMultiTenant
{
string TenantId { get; set; }
}
13 changes: 13 additions & 0 deletions framework/SimpleModule.Core/Entities/ISoftDelete.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Entities implementing this interface are soft-deleted instead of hard-deleted.
/// When EF Core <c>Remove()</c> 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.
/// </summary>
public interface ISoftDelete
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
string? DeletedBy { get; set; }
}
10 changes: 10 additions & 0 deletions framework/SimpleModule.Core/Entities/ITenantContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// 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).
/// </summary>
public interface ITenantContext
{
string? TenantId { get; }
}
10 changes: 10 additions & 0 deletions framework/SimpleModule.Core/Entities/IVersioned.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// Entities implementing this interface get an auto-incrementing version number.
/// Starts at 1 on insert, increments on each update. Configured as a concurrency token.
/// </summary>
public interface IVersioned
{
int Version { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace SimpleModule.Core.Entities;

/// <summary>
/// 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.
/// </summary>
public abstract class MultiTenantFullAuditableEntity<TId> : FullAuditableEntity<TId>, IMultiTenant, IHasExtraProperties
{
public string TenantId { get; set; } = string.Empty;
public Dictionary<string, object?> ExtraProperties { get; set; } = [];
}
8 changes: 8 additions & 0 deletions framework/SimpleModule.Database/DatabaseConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,12 @@ public static class DatabaseConstants
public const string SqlServerCatalogPrefix = "Initial Catalog=";
public const string SqlServerLocalPrefix = @"Server=.\";
public const string SqlServerExpressionPrefix = @"Server=(";

/// <summary>Named query filter key for <see cref="Core.Entities.ISoftDelete"/> entities.</summary>
public const string SoftDeleteQueryFilterKey = "SimpleModule:SoftDelete";

/// <summary>Named query filter key for <see cref="Core.Entities.IMultiTenant"/> entities.</summary>
public const string MultiTenantQueryFilterKey = "SimpleModule:MultiTenant";

internal const string EntityConventionsAppliedAnnotation = "SimpleModule:EntityConventionsApplied";
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Interceptor that collects domain events from <see cref="IHasDomainEvents"/> entities
/// before SaveChanges and dispatches them via <see cref="IEventBus"/> 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.
/// </summary>
public sealed class DomainEventInterceptor(IServiceProvider serviceProvider) : SaveChangesInterceptor
{
private static readonly MethodInfo PublishAsyncMethod =
typeof(IEventBus).GetMethod(nameof(IEventBus.PublishAsync))!;
private static readonly ConcurrentDictionary<Type, MethodInfo> PublishMethodCache = new();

private List<IEvent>? _collectedEvents;

public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is not null)
{
var events = new List<IEvent>();

foreach (var entry in eventData.Context.ChangeTracker.Entries<IHasDomainEvents>())
{
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<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
var events = _collectedEvents;
_collectedEvents = null;

if (events is { Count: > 0 })
{
var eventBus = serviceProvider.GetService<IEventBus>();
if (eventBus is not null)
{
foreach (var domainEvent in events)
{
// Invoke PublishAsync<T> with the concrete event type so that
// IEventHandler<T> 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);
}
}
Loading