This commit is contained in:
2025-10-27 17:39:18 -04:00
commit 31f723bea4
1579 changed files with 642409 additions and 0 deletions

62
Prefab/Config.cs Normal file
View File

@@ -0,0 +1,62 @@
using FluentValidation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Prefab.Handler;
using Prefab.Handler.Decorators;
namespace Prefab;
/// <summary>
/// Provides extension methods for registering core Prefab services with the dependency injection container.
/// </summary>
public static class Config
{
/// <summary>
/// Adds Prefab handler pipeline services and decorators to the specified service collection.
/// </summary>
/// <remarks>This method registers the default handler pipeline decorators and invoker required for
/// Prefab. Decorators are added in the order of execution, similar to ASP.NET Core middleware.</remarks>
/// <param name="services">The service collection to which the Prefab services will be added. Cannot be null.</param>
/// <param name="configuration">The application configuration used to configure Prefab services. Cannot be null.</param>
/// <returns>The same instance of <see cref="IServiceCollection"/> that was provided, to support method chaining.</returns>
public static IServiceCollection AddPrefab(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpContextAccessor();
services.AddScoped<IHandlerContextAccessor, HandlerContextAccessor>();
// Default decorators for handler pipeline. Place in order of execution, same way as .NET Core middleware does.
services.AddScoped<IHandlerDecorator, HandlerContextDecorator>();
services.AddScoped<IHandlerDecorator, ValidationDecorator>();
services.AddScoped<IHandlerDecorator, LoggingDecorator>();
services.AddScoped<HandlerInvoker>();
services.Scan(scan => scan
.FromApplicationDependencies(assembly => assembly.GetName().Name?.StartsWith(nameof(Prefab)) == true)
.AddClasses(classes => classes.AssignableTo(typeof(IValidator<>)), publicOnly: false)
.AsImplementedInterfaces()
.WithScopedLifetime());
return services;
}
/// <summary>
/// Scans Prefab assemblies for handler implementations and registers them with the dependency injection container.
/// </summary>
/// <remarks>Call this after all Prefab modules are loaded so Scrutor can discover handlers defined within them.</remarks>
public static IServiceCollection AddPrefabHandlers(this IServiceCollection services)
{
services.Scan(scan => scan
.FromApplicationDependencies(assembly => assembly.GetName().Name?.StartsWith(nameof(Prefab)) == true)
.AddClasses(classes => classes.AssignableTo(typeof(IHandler<,>)), publicOnly: false)
.AsImplementedInterfaces()
.WithScopedLifetime()
.AddClasses(classes => classes.AssignableTo(typeof(IHandler<>)), publicOnly: false)
.AsImplementedInterfaces()
.WithScopedLifetime());
return services;
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
namespace Prefab.Culture;
/// <summary>
/// Controller for setting the culture.
/// </summary>
[Route("[controller]/[action]")]
public class CultureController : Controller
{
/// <summary>
/// Set the culture and redirect to the specified URL.
/// </summary>
/// <param name="culture"></param>
/// <param name="redirectUri"></param>
/// <returns>LocalRedirect ActionResult</returns>
/// <remarks>
/// Use LocalRedirect action result to prevent open redirect attacks.
/// For more information <see href="https://learn.microsoft.com/en-us/aspnet/core/security/preventing-open-redirects?view=aspnetcore-9.0">Prevent open redirect attacks in ASP.NET Core</see>
/// </remarks>
public IActionResult Set(string? culture, string redirectUri)
{
if (culture != null)
{
HttpContext.Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(
new RequestCulture(culture, culture)));
}
return LocalRedirect(redirectUri);
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Prefab.Data.Entities;
namespace Prefab.Data.Configs
{
/// <summary>
/// Entity Framework Core configuration for the <see cref="AuditLog"/> entity.
/// </summary>
public class AuditLogConfig() : Prefab.Data.Configs.EntityConfig<AuditLog>(nameof(IPrefabDb.AuditLogs))
{
/// <summary>
/// Configures the properties and relationships for the <see cref="AuditLog"/> entity.
/// </summary>
/// <param name="builder">The builder to be used to configure the entity type.</param>
public override void Configure(EntityTypeBuilder<AuditLog> builder)
{
builder.HasKey(e => e.Id);
builder.Property(e => e.Entity)
.HasMaxLength(Rules.EntityMaxLength)
.IsRequired();
builder.Property(e => e.State)
.IsRequired();
builder.Property(e => e.CreatedBy)
.IsRequired();
builder.Property(e => e.CreatedOn)
.IsRequired();
builder.HasMany(e => e.Items)
.WithOne(i => i.AuditLog)
.HasForeignKey(i => i.AuditLogId)
.OnDelete(DeleteBehavior.Cascade);
}
/// <summary>
/// Validation rules and settings for <see cref="AuditLog"/> properties.
/// </summary>
public static class Rules
{
/// <summary>Maximum length for the Entity name.</summary>
public const int EntityMaxLength = 200;
}
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Prefab.Data.Entities;
namespace Prefab.Data.Configs
{
/// <summary>
/// Entity Framework Core configuration for the <see cref="AuditLogItem"/> entity.
/// </summary>
public class AuditLogItemConfig() : Prefab.Data.Configs.EntityConfig<AuditLogItem>(nameof(IPrefabDb.AuditLogItems))
{
/// <summary>
/// Configures the properties and relationships for the <see cref="AuditLogItem"/> entity.
/// </summary>
/// <param name="builder">The builder to be used to configure the entity type.</param>
public override void Configure(EntityTypeBuilder<AuditLogItem> builder)
{
builder.HasKey(e => e.Id);
builder.Property(e => e.Property)
.HasMaxLength(Rules.PropertyMaxLength)
.IsRequired();
builder.Property(e => e.OldValue)
.HasMaxLength(Rules.ValueMaxLength);
builder.Property(e => e.NewValue)
.HasMaxLength(Rules.ValueMaxLength);
builder.HasOne(e => e.AuditLog)
.WithMany(a => a.Items)
.HasForeignKey(e => e.AuditLogId)
.OnDelete(DeleteBehavior.Cascade);
}
/// <summary>
/// Validation rules and settings for <see cref="AuditLogItem"/> properties.
/// </summary>
public static class Rules
{
/// <summary>Maximum length for the property name.</summary>
public const int PropertyMaxLength = 100;
/// <summary>Maximum length for the value fields.</summary>
public const int ValueMaxLength = 1000;
}
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Prefab.Data.Configs
{
/// <summary>
/// Base configuration for all entities, sets items like table name and schema.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
public abstract class EntityConfig<T> : IEntityTypeConfiguration<T> where T : class
{
private readonly string _schema;
private readonly string _tableName;
protected EntityConfig(string tableName, string? schema = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
_tableName = tableName;
_schema = string.IsNullOrWhiteSpace(schema) ? "dbo" : schema;
}
public virtual void Configure(EntityTypeBuilder<T> builder)
{
builder.ToTable(_tableName, _schema);
}
}
}

View File

@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Prefab.Data.Entities;
namespace Prefab.Data.Configs;
/// <summary>
/// Configuration class for the <see cref="GenericAttribute"/> entity within the dbo schema.
/// </summary>
public class GenericAttributeConfig() : EntityConfig<GenericAttribute>(nameof(IPrefabDb.GenericAttributes))
{
/// <summary>
/// Configuration of Entity Framework mapping for the <see cref="GenericAttribute"/> entity.
/// </summary>
public override void Configure(EntityTypeBuilder<GenericAttribute> builder)
{
builder
.Ignore(x => x.Id);
// Define the composite key (and clustered index) for the GenericAttribute entity.
builder
.HasKey(x => new { x.EntityId, x.KeyGroup, x.Key });
builder.HasIndex(x => new { x.EntityId, x.KeyGroup })
.HasDatabaseName("IX_GenericAttributes_Entity_Group");
builder.HasIndex(x => x.DeletedOn)
.HasFilter("[DeletedOn] IS NULL")
.HasDatabaseName("IX_GenericAttributes_Active");
builder
.Property(x => x.KeyGroup)
.HasMaxLength(Rules.KeyGroupMaxLength)
.IsRequired();
builder
.Property(x => x.Key)
.HasMaxLength(Rules.KeyMaxLength)
.IsRequired();
builder
.Property(x => x.Value)
.HasMaxLength(Rules.ValueMaxLength)
.IsRequired();
}
/// <summary>
/// Validation rules for the <see cref="GenericAttribute"/> entity.
/// </summary>
public static class Rules
{
/// <summary>
/// Maximum length to set for the generic attribute key group.
/// </summary>
public const int KeyGroupMaxLength = 400;
/// <summary>
/// Minimum length to set for the generic attribute key.
/// </summary>
public const int KeyMinLength = 3;
/// <summary>
/// Maximum length to set for the generic attribute key.
/// </summary>
public const int KeyMaxLength = 400;
/// <summary>
/// Maximum length to set for the generic attribute value.
/// </summary>
public const int ValueMaxLength = int.MaxValue;
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Prefab.Data.Entities;
namespace Prefab.Data.Configs;
/// <summary>
/// Configuration class for the <see cref="SeederLog"/> entity.
/// </summary>
public class SeederLogConfig() : Prefab.Data.Configs.EntityConfig<SeederLog>(nameof(IPrefabDb.SeederLogs))
{
/// <summary>
/// Configuration of Entity Framework mapping for the <see cref="SeederLog"/> entity.
/// </summary>
public override void Configure(EntityTypeBuilder<SeederLog> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder
.HasKey(x => x.Id);
builder
.Property(x => x.Id)
.ValueGeneratedOnAdd();
builder.Property(x => x.SeederName)
.HasMaxLength(Rules.SeederNameMaxLength)
.IsRequired();
builder.Property(x => x.RunMode)
.HasMaxLength(Rules.SeederRunModeMaxLength)
.IsRequired();
builder
.Property(x => x.RunAt)
.IsRequired();
}
/// <summary>
/// Validation rules for the <see cref="SeederLog"/> entity.
/// </summary>
public static class Rules
{
/// <summary>
/// Maximum length to set for the seeder name.
/// </summary>
public const int SeederNameMaxLength = 256;
/// <summary>
/// Maximum length to set for the seeder name.
/// </summary>
public const int SeederRunModeMaxLength = 64;
}
}

View File

@@ -0,0 +1,39 @@
using Prefab.Domain.Common;
namespace Prefab.Data.Entities;
/// <summary>
/// Represents an audit log entry that captures an entity state transition.
/// </summary>
public class AuditLog : Entity<int>, ICreated
{
/// <summary>
/// Gets or sets the correlation identifier tying related audit events together.
/// </summary>
public Guid CorrelationId { get; set; }
/// <summary>
/// Gets or sets the fully qualified entity name that generated the audit record.
/// </summary>
public string Entity { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the serialized state of the entity when the audit was captured.
/// </summary>
public string State { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the collection of field-level changes associated with the audit.
/// </summary>
public virtual ICollection<AuditLogItem> Items { get; set; } = new List<AuditLogItem>();
/// <summary>
/// Gets or sets the identifier of the user who triggered the change.
/// </summary>
public Guid CreatedBy { get; set; }
/// <summary>
/// Gets or sets the timestamp when the change occurred.
/// </summary>
public DateTimeOffset CreatedOn { get; set; }
}

View File

@@ -0,0 +1,34 @@
using Prefab.Domain.Common;
namespace Prefab.Data.Entities;
/// <summary>
/// Represents a field-level change within an audit log entry.
/// </summary>
public class AuditLogItem : Entity<int>
{
/// <summary>
/// Gets or sets the parent audit log identifier.
/// </summary>
public int AuditLogId { get; set; }
/// <summary>
/// Gets or sets the property name that changed.
/// </summary>
public string Property { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the previous serialized value.
/// </summary>
public string? OldValue { get; set; }
/// <summary>
/// Gets or sets the new serialized value.
/// </summary>
public string? NewValue { get; set; }
/// <summary>
/// Gets or sets the navigation back to the owning audit log.
/// </summary>
public virtual AuditLog AuditLog { get; set; } = null!;
}

View File

@@ -0,0 +1,34 @@
using Prefab.Domain.Common;
namespace Prefab.Data.Entities;
/// <summary>
/// Represents a persisted generic attribute for an entity.
/// </summary>
public class GenericAttribute : EntityWithAuditAndStatus<int>
{
/// <summary>
/// Gets or sets the owning entity identifier.
/// </summary>
public Guid EntityId { get; set; }
/// <summary>
/// Gets or sets the logical group used to partition attributes by entity type.
/// </summary>
public string KeyGroup { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the attribute key.
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the fully qualified type name of the serialized value.
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the serialized attribute value.
/// </summary>
public string Value { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,24 @@
using Prefab.Domain.Common;
namespace Prefab.Data.Entities;
/// <summary>
/// Captures metadata about seeder executions to prevent duplicate runs.
/// </summary>
public class SeederLog : Entity<int>
{
/// <summary>
/// Gets the seeder identifier.
/// </summary>
public string SeederName { get; init; } = string.Empty;
/// <summary>
/// Gets the run mode (e.g., Development or Production) used for the execution.
/// </summary>
public string RunMode { get; init; } = string.Empty;
/// <summary>
/// Gets the timestamp when the seeder completed.
/// </summary>
public DateTime RunAt { get; init; }
}

14
Prefab/Data/Enums.cs Normal file
View File

@@ -0,0 +1,14 @@
namespace Prefab.Data;
/// <summary>
/// Represents the available statuses that an entity's record can be in.
/// </summary>
public enum AuditStatus
{
Active,
Inactive,
Deleted
}

26
Prefab/Data/Extensions.cs Normal file
View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Prefab.Domain.Common;
namespace Prefab.Data;
/// <summary>
/// Extension methods for <see cref="ModelBuilder"/> when configuring the database model.
/// </summary>
public static class ModelBuilderExtensions
{
/// <summary>
/// Ignores the domain event collection for all entities implementing <see cref="IHasDomainEvents"/>.
/// </summary>
/// <param name="builder">The model builder to configure.</param>
public static void IgnoreDomainEvents(this ModelBuilder builder)
{
builder.Model
.GetEntityTypes()
.Where(e => typeof(IHasDomainEvents).IsAssignableFrom(e.ClrType))
.ToList()
.ForEach(e =>
{
builder.Entity(e.ClrType).Ignore(nameof(IHasDomainEvents.Events));
});
}
}

49
Prefab/Data/IPrefabDb.cs Normal file
View File

@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Prefab.Data.Entities;
namespace Prefab.Data;
/// <summary>
/// Abstraction over the Prefab relational database context.
/// </summary>
public interface IPrefabDb : IDisposable, IAsyncDisposable
{
/// <summary>
/// Gets the EF Core database facade for executing commands and migrations.
/// </summary>
DatabaseFacade Database { get; }
/// <summary>
/// Persists pending changes to the database.
/// </summary>
int SaveChanges();
/// <summary>
/// Persists pending changes to the database asynchronously.
/// </summary>
/// <param name="cancellationToken">Token used to cancel the operation.</param>
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets the audit log set.
/// </summary>
DbSet<AuditLog> AuditLogs { get; }
/// <summary>
/// Gets the audit log item set.
/// </summary>
DbSet<AuditLogItem> AuditLogItems { get; }
/// <summary>
/// Gets the generic attribute set.
/// </summary>
DbSet<GenericAttribute> GenericAttributes { get; }
/// <summary>
/// Gets the seeder audit set.
/// </summary>
DbSet<SeederLog> SeederLogs { get; }
}

View File

@@ -0,0 +1,8 @@
namespace Prefab.Data;
/// <summary>
/// Marker interface to represent read-only access to the Prefab database.
/// </summary>
public interface IPrefabDbReadOnly : IPrefabDb
{
}

250
Prefab/Data/PrefabDb.cs Normal file
View File

@@ -0,0 +1,250 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Prefab.Data.Entities;
using Prefab.Domain.Common;
using Prefab.Handler;
using System.Reflection;
namespace Prefab.Data;
public abstract class PrefabDb(DbContextOptions options, IHandlerContextAccessor accessor)
: DbContext(options)
{
/// <summary>
/// Exposes the database facade for provider-specific operations.
/// </summary>
public new DatabaseFacade Database => base.Database;
/// <summary>
/// Applies Prefab conventions in addition to consumer-provided model configuration.
/// </summary>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(IPrefabDb).Assembly);
PrefabOnModelCreating(modelBuilder);
modelBuilder.IgnoreDomainEvents();
}
/// <summary>
/// Allows derived contexts to extend model configuration.
/// </summary>
/// <param name="builder">The model builder supplied by EF Core.</param>
protected abstract void PrefabOnModelCreating(ModelBuilder builder);
/// <summary>
/// Applies Prefab defaults followed by consumer configuration hooks.
/// </summary>
/// <param name="optionsBuilder">The options builder used to configure the context.</param>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
PrefabOnConfiguring(optionsBuilder);
}
/// <summary>
/// Allows derived contexts to customise provider-specific options.
/// </summary>
/// <param name="optionsBuilder">The options builder supplied by EF Core.</param>
protected abstract void PrefabOnConfiguring(DbContextOptionsBuilder optionsBuilder);
/// <summary>
/// Persists changes to the database while executing auditing and outbox processing.
/// </summary>
public override int SaveChanges() => SaveChangesAsync().ConfigureAwait(false).GetAwaiter().GetResult();
/// <summary>
/// Persists changes to the database while executing auditing and outbox processing.
/// </summary>
/// <param name="cancellationToken">Token used to cancel the save operation.</param>
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new())
{
var now = DateTimeOffset.UtcNow;
var correlationId = Guid.TryParse(accessor?.Current?.CorrelationId, out var parsedCorrelationId)
? parsedCorrelationId
: Guid.Empty;
var auditEntitiesCache = new Dictionary<Type, bool>();
var propertiesCache = new Dictionary<Type, (PropertyInfo? CreatedBy, PropertyInfo? CreatedOn, PropertyInfo? ModifiedBy, PropertyInfo? ModifiedOn)>();
var auditEntries = new List<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry>();
foreach (var entry in ChangeTracker.Entries())
{
var entityType = entry.Entity.GetType();
if (!auditEntitiesCache.TryGetValue(entityType, out var entityIsAuditable))
{
entityIsAuditable = entityType.GetInterfaces()
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEntityWithAudit<>));
auditEntitiesCache[entityType] = entityIsAuditable;
}
if (!entityIsAuditable)
{
continue;
}
if (!propertiesCache.TryGetValue(entityType, out var props))
{
props = (
CreatedBy: entityType.GetProperty(nameof(IAudit.CreatedBy)),
CreatedOn: entityType.GetProperty(nameof(IAudit.CreatedOn)),
ModifiedBy: entityType.GetProperty(nameof(IAudit.LastModifiedBy)),
ModifiedOn: entityType.GetProperty(nameof(IAudit.LastModifiedOn))
);
propertiesCache[entityType] = props;
}
switch (entry.State)
{
case EntityState.Added:
props.CreatedBy?.SetValue(entry.Entity, props.CreatedBy.GetValue(entry.Entity) ?? accessor?.Current?.UserId);
props.CreatedOn?.SetValue(entry.Entity, now);
auditEntries.Add(entry);
break;
case EntityState.Modified:
props.ModifiedBy?.SetValue(entry.Entity, accessor?.Current?.UserId ?? accessor?.Current?.UserId);
props.ModifiedOn?.SetValue(entry.Entity, now);
auditEntries.Add(entry);
break;
case EntityState.Deleted:
if (TrySoftDelete(entry, props, accessor?.Current?.UserId, now))
{
entry.State = EntityState.Modified;
auditEntries.Add(entry);
}
break;
}
}
foreach (var entry in auditEntries)
{
var audit = new AuditLog
{
CorrelationId = correlationId,
Entity = entry.Entity.GetType().Name,
State = entry.State.ToString(),
CreatedBy = Guid.TryParse(accessor?.Current?.UserId, out var userId) ? userId : Guid.Empty,
CreatedOn = now,
Items = new List<AuditLogItem>()
};
foreach (var prop in entry.Properties)
{
if (entry.State == EntityState.Modified && !prop.IsModified)
{
continue;
}
var oldVal = entry.State == EntityState.Modified ? prop.OriginalValue?.ToString() : null;
var newVal = prop.CurrentValue?.ToString();
audit.Items.Add(new AuditLogItem
{
Property = prop.Metadata.Name,
OldValue = oldVal,
NewValue = newVal
});
}
AuditLogs.Add(audit);
}
var domainEventEntities = ChangeTracker
.Entries<IHasDomainEvents>()
.Where(e => e.Entity.Events.Count > 0)
.Select(e => e.Entity)
.ToList();
if (domainEventEntities.Count > 0)
{
var domainEvents = domainEventEntities.SelectMany(e => e.Events).ToList();
foreach (var ev in domainEvents)
{
//Outbox.Add(Prefab.Queuing.Domain.Outbox.New(ev));
}
domainEventEntities.ForEach(e => e.ClearEvents());
}
return await base.SaveChangesAsync(cancellationToken);
}
private static bool TrySoftDelete(
Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry,
(PropertyInfo? CreatedBy, PropertyInfo? CreatedOn, PropertyInfo? ModifiedBy, PropertyInfo? ModifiedOn) props,
string? userId,
DateTimeOffset deletedOn)
{
var entityType = entry.Entity.GetType();
var supportsSoftDelete = entityType.GetInterfaces()
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEntityWithAuditAndStatus<>));
if (!supportsSoftDelete)
{
return false;
}
var deletedOnProp = entityType.GetProperty(nameof(EntityWithAuditAndStatus<Guid>.DeletedOn));
var deletedByProp = entityType.GetProperty(nameof(EntityWithAuditAndStatus<Guid>.DeletedBy));
deletedOnProp?.SetValue(entry.Entity, deletedOn);
if (deletedByProp is not null)
{
if (Guid.TryParse(userId, out var parsedUserId))
{
deletedByProp.SetValue(entry.Entity, parsedUserId);
}
else
{
deletedByProp.SetValue(entry.Entity, null);
}
}
const string deletedOnPropertyName = nameof(EntityWithAuditAndStatus<Guid>.DeletedOn);
if (entry.Metadata.FindProperty(deletedOnPropertyName) is not null)
{
entry.Property(deletedOnPropertyName).IsModified = true;
}
const string deletedByPropertyName = nameof(EntityWithAuditAndStatus<Guid>.DeletedBy);
if (entry.Metadata.FindProperty(deletedByPropertyName) is not null)
{
entry.Property(deletedByPropertyName).IsModified = true;
}
props.ModifiedOn?.SetValue(entry.Entity, deletedOn);
if (props.ModifiedBy is not null)
{
if (Guid.TryParse(userId, out var parsedUserId))
{
props.ModifiedBy.SetValue(entry.Entity, parsedUserId);
}
else
{
props.ModifiedBy.SetValue(entry.Entity, null);
}
}
return true;
}
/// <summary>
/// Gets the audit log set.
/// </summary>
public DbSet<AuditLog> AuditLogs { get; set; } = null!;
/// <summary>
/// Gets the audit log item set.
/// </summary>
public DbSet<AuditLogItem> AuditLogItems { get; set; } = null!;
/// <summary>
/// Gets the generic attribute set.
/// </summary>
public DbSet<GenericAttribute> GenericAttributes => Set<GenericAttribute>();
/// <summary>
/// Gets the seeder log set.
/// </summary>
public DbSet<SeederLog> SeederLogs => Set<SeederLog>();
}

View File

@@ -0,0 +1,39 @@
using Prefab.Domain.Common;
namespace Prefab.Data.Queries;
/// <summary>
/// Provides reusable audit-related query filters for entity sets.
/// </summary>
public static class EntityAuditQueries
{
/// <summary>
/// Filters entities created before the specified timestamp.
/// </summary>
public static IQueryable<IAudit> ThatWasCreateBefore(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.CreatedOn < dateTime);
/// <summary>
/// Filters entities created after the specified timestamp.
/// </summary>
public static IQueryable<IAudit> ThatWasCreatedAfter(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.CreatedOn > dateTime);
/// <summary>
/// Filters entities created between the specified start and end timestamps.
/// </summary>
public static IQueryable<IAudit> ThatWasCreatedBetween(this IQueryable<IAudit> query, DateTime start, DateTime end) => query.ThatWasCreatedAfter(start).ThatWasCreateBefore(end);
/// <summary>
/// Filters entities last modified before the specified timestamp.
/// </summary>
public static IQueryable<IAudit> ThatWasLastModifiedBefore(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.LastModifiedOn < dateTime);
/// <summary>
/// Filters entities last modified after the specified timestamp.
/// </summary>
public static IQueryable<IAudit> ThatWasLastModifiedAfter(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.LastModifiedOn > dateTime);
/// <summary>
/// Filters entities last modified between the specified start and end timestamps.
/// </summary>
public static IQueryable<IAudit> ThatWasLastModifiedBetween(this IQueryable<IAudit> query, DateTime start, DateTime end) => query.ThatWasLastModifiedAfter(start).ThatWasLastModifiedBefore(end);
}

View File

@@ -0,0 +1,24 @@
using Prefab.Domain.Common;
namespace Prefab.Data.Queries;
/// <summary>
/// Provides query helpers for filtering entities by audit status.
/// </summary>
public static class EntityStatusQueries
{
/// <summary>
/// Filters entities with an <see cref="AuditStatus.Active"/> status.
/// </summary>
public static IQueryable<IStatus> WithAnActiveStatus(this IQueryable<IStatus> query) => query.Where(x => x.AuditStatus == AuditStatus.Active);
/// <summary>
/// Filters entities with an <see cref="AuditStatus.Inactive"/> status.
/// </summary>
public static IQueryable<IStatus> WithAnInactiveStatus(this IQueryable<IStatus> query) => query.Where(x => x.AuditStatus == AuditStatus.Inactive);
/// <summary>
/// Filters entities with an <see cref="AuditStatus.Deleted"/> status.
/// </summary>
public static IQueryable<IStatus> WithADeletedStatus(this IQueryable<IStatus> query) => query.Where(x => x.AuditStatus == AuditStatus.Deleted);
}

View File

@@ -0,0 +1,107 @@
using Microsoft.EntityFrameworkCore;
using Prefab.Data.Entities;
using Prefab.Domain.Common;
namespace Prefab.Data.Queries;
/// <summary>
/// Provides extension methods for the <see cref="GenericAttribute"/> entity.
/// </summary>
public static class GenericAttributeQueries
{
/// <summary>
/// Query for records where the <see cref="GenericAttribute.EntityId"/> field matches the given entity.
/// </summary>
public static IQueryable<GenericAttribute> ForEntity(this IQueryable<GenericAttribute> query, IEntity<Guid> entity)
{
ArgumentNullException.ThrowIfNull(entity);
return query.Where(x => x.EntityId == entity.Id && x.KeyGroup == entity.GetType().FullName);
}
/// <summary>
/// Query for records in a module-specific view where the EntityId matches the given entity.
/// </summary>
public static IQueryable<TView> ForEntity<TView>(this IQueryable<TView> query, IEntity<Guid> entity, string keyGroup)
where TView : class
{
return GenericAttributeViewQueries.ForEntity(query, entity, keyGroup);
}
/// <summary>
/// Query for records where the <see cref="GenericAttribute.Value"/> field contains characters from the given string value.
/// </summary>
public static IQueryable<GenericAttribute> WhereTheValueContains(this IQueryable<GenericAttribute> query, string value)
{
return query.Where(x => x.Value.Contains(value));
}
/// <summary>
/// Filter by key (exact match).
/// </summary>
private static IQueryable<GenericAttribute> WhereKey(
this IQueryable<GenericAttribute> q, string key) =>
q.Where(x => x.Key == key);
/// <summary>
/// Filter by key-group (exact match).
/// </summary>
public static IQueryable<GenericAttribute> WhereGroup(
this IQueryable<GenericAttribute> q, string keyGroup) =>
q.Where(x => x.KeyGroup == keyGroup);
/// <summary>
/// Filter by type (exact match).
/// </summary>
public static IQueryable<GenericAttribute> WhereType(
this IQueryable<GenericAttribute> q, string type) =>
q.Where(x => x.Type == type);
/// <summary>
/// Filter by any of the given keys.
/// </summary>
public static IQueryable<GenericAttribute> WhereKeys(
this IQueryable<GenericAttribute> q, params string[] keys) =>
q.Where(x => keys.Contains(x.Key));
/// <summary>
/// Case-insensitive substring search on Value using EF.Functions.Like.
/// </summary>
public static IQueryable<GenericAttribute> WhereValueLike(
this IQueryable<GenericAttribute> q, string pattern) =>
q.Where(x => EF.Functions.Like(x.Value, pattern));
/// <summary>
/// Project into a key→value dictionary.
/// </summary>
public static async Task<Dictionary<string, string>> ToDictionaryAsync(
this IQueryable<GenericAttribute> q,
CancellationToken ct = default)
{
return await q
.Select(x => new { x.Key, x.Value })
.ToDictionaryAsync(x => x.Key, x => x.Value, ct)
.ConfigureAwait(false);
}
/// <summary>
/// Shortcut to fetch a single attribute value (or null).
/// </summary>
public static async Task<string?> GetValueAsync(
this IQueryable<GenericAttribute> q, string key, CancellationToken ct = default)
{
return await q
.WhereKey(key)
.Select(x => x.Value)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>
/// Apply AsNoTracking for read-only queries.
/// </summary>
public static IQueryable<GenericAttribute> AsReadOnly(
this IQueryable<GenericAttribute> q) =>
q.AsNoTracking();
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Prefab.Domain.Common;
namespace Prefab.Data.Queries;
/// <summary>
/// Provides shared filters for working with generic attribute views.
/// </summary>
public static class GenericAttributeViewQueries
{
/// <summary>
/// Filters a module-specific view by entity identifier and key group.
/// </summary>
public static IQueryable<TView> ForEntity<TView>(this IQueryable<TView> query, IEntity<Guid> entity, string keyGroup)
where TView : class
{
if (entity == null)
{
throw new ArgumentNullException(nameof(entity));
}
if (string.IsNullOrEmpty(keyGroup))
{
throw new ArgumentException("KeyGroup cannot be null or empty.", nameof(keyGroup));
}
return query.Where(x => EF.Property<Guid>(x, "EntityId") == entity.Id &&
EF.Property<string>(x, "KeyGroup") == keyGroup);
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.Extensions.DependencyInjection;
using System.Threading.Channels;
namespace Prefab.Data.Seeder;
public static class Extensions
{
/// <summary>
/// Registers a seeder and its options so it can participate in environment-aware execution.
/// </summary>
/// <typeparam name="TSeeder">Seeder implementation.</typeparam>
/// <param name="services">The host service collection.</param>
/// <param name="runMode">Determines when the seeder is allowed to run.</param>
/// <returns>The provided service collection.</returns>
public static IServiceCollection AddSeeder<TSeeder>(this IServiceCollection services, RunMode runMode = RunMode.RunOnFirstLoadOnly) where TSeeder : class, ISeeder
{
services.AddScoped<ISeeder, TSeeder>();
services.AddScoped<TSeeder>();
services.Configure<SeederOptions<TSeeder>>(opts => opts.RunMode = runMode);
return services;
}
/// <summary>
/// Gets the channel used to queue seeder operations for background execution.
/// </summary>
public static Channel<Func<IServiceProvider, CancellationToken, Task>> Channel { get; }
= System.Threading.Channels.Channel.CreateUnbounded<Func<IServiceProvider, CancellationToken, Task>>();
}

View File

@@ -0,0 +1,12 @@
namespace Prefab.Data.Seeder;
/// <summary>
/// Represents a seeder that can be used to seed the database with data.
/// </summary>
public interface ISeeder
{
/// <summary>
/// Executes the seed task.
/// </summary>
Task Execute(IServiceProvider serviceProvider, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,13 @@
namespace Prefab.Data.Seeder;
/// <summary>
/// Options for a specific <see cref="ISeeder"/> implementation.
/// </summary>
/// <typeparam name="TSeeder">The seeder type these options apply to.</typeparam>
public class SeederOptions<TSeeder>
{
/// <summary>
/// Gets or sets when the seeder should execute.
/// </summary>
public RunMode RunMode { get; set; } = RunMode.RunOnFirstLoadOnly;
}

View File

@@ -0,0 +1,199 @@
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Prefab.Base;
using Prefab.Data.Entities;
namespace Prefab.Data.Seeder;
/// <summary>
/// Base infrastructure for executing database seeders with lookup synchronization support.
/// </summary>
/// <typeparam name="TSeeder">The concrete seeder implementation.</typeparam>
/// <typeparam name="TDb">The database abstraction used by the seeder.</typeparam>
public abstract class PrefabDbSeeder<TSeeder, TDb> : ISeeder where TDb : IPrefabDb
{
/// <inheritdoc />
public async Task Execute(IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
try
{
var db = serviceProvider.GetRequiredService<TDb>();
var options = serviceProvider.GetService<IOptions<SeederOptions<TSeeder>>>()?.Value ?? new SeederOptions<TSeeder>();
var seederName = typeof(TSeeder).FullName ?? typeof(TSeeder).Name;
var runMode = options.RunMode;
var shouldSeedData = false;
await SeedLookups(db, cancellationToken);
await ApplyViews(db, serviceProvider, cancellationToken);
switch (runMode)
{
case RunMode.RunAlways:
shouldSeedData = true;
break;
case RunMode.SingleRun:
case RunMode.RunOnFirstLoadOnly:
shouldSeedData = !db.SeederLogs.Any(x => x.SeederName == seederName);
break;
}
if (!shouldSeedData)
{
return;
}
await SeedData(db, serviceProvider, cancellationToken);
db.SeederLogs.Add(new SeederLog
{
SeederName = seederName,
RunMode = runMode.ToString(),
RunAt = DateTime.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
Console.WriteLine(ex);
throw;
}
}
/// <summary>
/// Applies database views, ensuring dependent queries exist before data seeding runs.
/// </summary>
protected abstract Task ApplyViews(TDb db, IServiceProvider serviceProvider, CancellationToken cancellationToken);
/// <summary>
/// Seeds data specific to the derived context.
/// </summary>
protected abstract Task SeedData(TDb db, IServiceProvider serviceProvider, CancellationToken cancellationToken);
private static async Task SeedLookups(TDb dbContext, CancellationToken cancellationToken)
{
var processedTypes = new HashSet<Type>();
var dbContextType = dbContext.GetType();
var dbSetProperties = dbContextType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(prop => prop.PropertyType.IsGenericType &&
prop.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>));
foreach (var prop in dbSetProperties)
{
var entityType = prop.PropertyType.GetGenericArguments()[0];
if (!processedTypes.Add(entityType))
{
continue;
}
if (!IsSubclassOfGeneric(entityType, typeof(LookupEntity<>)))
{
continue;
}
var baseType = entityType.BaseType;
if (baseType is not { IsGenericType: true } ||
baseType.GetGenericTypeDefinition() != typeof(LookupEntity<>))
{
continue;
}
var enumType = baseType.GetGenericArguments()[0];
var method = typeof(PrefabDbSeeder<TSeeder, TDb>).GetMethod(nameof(SeedLookupAsync), BindingFlags.NonPublic | BindingFlags.Static);
if (method is null)
{
continue;
}
var genericMethod = method.MakeGenericMethod(entityType, enumType);
await (Task)genericMethod.Invoke(null, new object[] { dbContext, cancellationToken })!;
}
return;
static bool IsSubclassOfGeneric(Type type, Type generic)
{
while (type is not null && type != typeof(object))
{
var current = type.IsGenericType ? type.GetGenericTypeDefinition() : type;
if (current == generic)
{
return true;
}
type = type.BaseType!;
}
return false;
}
}
private static async Task SeedLookupAsync<TLookup, TEnum>(DbContext dbContext, CancellationToken cancellationToken)
where TLookup : LookupEntity<TEnum>, new()
where TEnum : PrefabEnum<TEnum>
{
var dbSet = dbContext.Set<TLookup>();
var enumMembers = PrefabEnum<TEnum>.List
.Select(e => new
{
Value = e.Value,
Name = e.Name,
ShortName = GetOptionalString(e, "ShortName"),
Description = GetOptionalString(e, "Description"),
SortOrder = e.Value
})
.ToList();
var existingRows = await dbSet.ToListAsync(cancellationToken);
foreach (var member in enumMembers)
{
var lookupRow = existingRows.FirstOrDefault(r => r.Id == member.Value);
if (lookupRow is null)
{
lookupRow = new TLookup
{
Id = member.Value,
Name = member.Name,
ShortName = member.ShortName,
Description = member.Description,
SortOrder = member.SortOrder
};
dbSet.Add(lookupRow);
}
else if (lookupRow.Name != member.Name ||
lookupRow.ShortName != member.ShortName ||
lookupRow.Description != member.Description ||
lookupRow.SortOrder != member.SortOrder)
{
lookupRow.Name = member.Name;
lookupRow.ShortName = member.ShortName;
lookupRow.Description = member.Description;
lookupRow.SortOrder = member.SortOrder;
dbSet.Update(lookupRow);
}
}
var validValues = enumMembers.Select(m => m.Value).ToHashSet();
foreach (var row in existingRows.Where(row => !validValues.Contains(row.Id)))
{
dbSet.Remove(row);
}
await dbContext.SaveChangesAsync(cancellationToken);
static string? GetOptionalString(PrefabEnum<TEnum> value, string propertyName)
{
var property = value.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy);
if (property is null || property.PropertyType != typeof(string))
{
return null;
}
return property.GetValue(value) as string;
}
}
}

View File

@@ -0,0 +1,21 @@
namespace Prefab.Data.Seeder;
/// <summary>
/// Indicates when a data loader should be run.
/// </summary>
public enum RunMode
{
/// <summary>
/// Data loader will be run only for a newly-initialized database
/// </summary>
RunOnFirstLoadOnly,
/// <summary>
/// Data loader will be run once only in a given database
/// </summary>
SingleRun,
/// <summary>
/// Data loader will run every time the app starts up
/// </summary>
RunAlways
}

View File

@@ -0,0 +1,37 @@
namespace Prefab.Domain.Common;
/// <summary>
/// Base implementation for aggregate entities that support domain events.
/// </summary>
/// <typeparam name="T">Entity identifier type.</typeparam>
public abstract class Entity<T> : IEntity<T>, IHasDomainEvents
{
private readonly List<Event> _events = [];
/// <inheritdoc />
public T Id { get; set; } = default!;
/// <inheritdoc />
public IReadOnlyCollection<Event> Events => _events.AsReadOnly();
/// <inheritdoc />
public void AddEvent(Event e) => _events.Add(e);
/// <inheritdoc />
public void RemoveEvent(Event e) => _events.Remove(e);
/// <inheritdoc />
public void ClearEvents() => _events.Clear();
/// <summary>
/// Generates a UUIDv7 identifier using the supplied timestamp, primarily for testing scenarios.
/// </summary>
/// <param name="timestamp">The timestamp used to seed the UUID generator.</param>
/// <exception cref="InvalidOperationException">Thrown when the entity identifier type is not <see cref="Guid"/>.</exception>
protected void GenerateIdWithTimestamp(DateTime timestamp)
{
Id = typeof(T) == typeof(Guid)
? (T)(object)Guid.CreateVersion7(timestamp)
: throw new InvalidOperationException("Cannot generate a UUIDv7 for a non-Guid entity.");
}
}

View File

@@ -0,0 +1,25 @@
namespace Prefab.Domain.Common;
/// <summary>
/// Interface representation of an entity. All entities that require auditing should implement this.
/// </summary>
/// <typeparam name="T">The type that will represent the entity's ID field.</typeparam>
public interface IEntityWithAudit<T> : IEntity<T>, IAudit
{
}
/// <inheritdoc cref="IEntityWithAudit{T}" />
public abstract class EntityWithAudit<T> : Entity<T>, IEntityWithAudit<T>
{
/// <inheritdoc />
public Guid CreatedBy { get; init; }
/// <inheritdoc />
public DateTimeOffset CreatedOn { get; init; }
/// <inheritdoc />
public Guid LastModifiedBy { get; init; }
/// <inheritdoc />
public DateTimeOffset LastModifiedOn { get; init; }
}

View File

@@ -0,0 +1,118 @@
using System.ComponentModel.DataAnnotations;
using Prefab.Data;
namespace Prefab.Domain.Common;
/// <summary>
/// Represents an audited entity that also tracks status transitions such as deactivate and delete.
/// </summary>
/// <typeparam name="T">The identifier type.</typeparam>
public interface IEntityWithAuditAndStatus<T> : IEntityWithAudit<T>, IStatus
{
/// <summary>
/// Gets or sets the identifier of the user that deactivated the entity.
/// </summary>
Guid? InactivatedBy { get; set; }
/// <summary>
/// Gets or sets when the entity was deactivated.
/// </summary>
DateTimeOffset? InactivatedOn { get; set; }
/// <summary>
/// Gets or sets the identifier of the user that soft-deleted the entity.
/// </summary>
Guid? DeletedBy { get; set; }
/// <summary>
/// Gets or sets when the entity was soft-deleted.
/// </summary>
DateTimeOffset? DeletedOn { get; set; }
/// <summary>
/// Gets or sets the concurrency token used for optimistic locking.
/// </summary>
byte[] RowVersion { get; set; }
}
/// <summary>
/// Base type for audited entities that support activation, deactivation, and soft-delete flows.
/// </summary>
/// <typeparam name="T">The identifier type.</typeparam>
public abstract class EntityWithAuditAndStatus<T> : EntityWithAudit<T>, IEntityWithAuditAndStatus<T>
{
/// <inheritdoc />
public AuditStatus AuditStatus => DeletedOn.HasValue
? AuditStatus.Deleted
: InactivatedOn.HasValue
? AuditStatus.Inactive
: AuditStatus.Active;
/// <inheritdoc />
public Guid? InactivatedBy { get; set; }
/// <inheritdoc />
public DateTimeOffset? InactivatedOn { get; set; }
/// <inheritdoc />
public Guid? DeletedBy { get; set; }
/// <inheritdoc />
public DateTimeOffset? DeletedOn { get; set; }
/// <inheritdoc />
[Timestamp]
public byte[] RowVersion { get; set; } = [];
/// <summary>
/// Marks the entity as active by clearing any inactive markers.
/// </summary>
/// <param name="userId">Identifier of the user performing the change.</param>
public virtual void Activate(Guid? userId = null)
{
if (InactivatedOn.HasValue)
{
InactivatedOn = null;
InactivatedBy = null;
}
}
/// <summary>
/// Marks the entity as inactive if it is not already inactive.
/// </summary>
/// <param name="userId">Identifier of the user performing the change.</param>
public virtual void Deactivate(Guid? userId = null)
{
if (!InactivatedOn.HasValue)
{
InactivatedOn = DateTimeOffset.UtcNow;
InactivatedBy = userId;
}
}
/// <summary>
/// Soft deletes the entity when it has not already been deleted.
/// </summary>
/// <param name="userId">Identifier of the user performing the change.</param>
public virtual void Delete(Guid? userId = null)
{
if (!DeletedOn.HasValue)
{
DeletedOn = DateTimeOffset.UtcNow;
DeletedBy = userId;
}
}
/// <summary>
/// Restores a previously soft-deleted entity to an inactive state.
/// </summary>
/// <param name="userId">Identifier of the user performing the change.</param>
public virtual void Restore(Guid? userId = null)
{
if (DeletedOn.HasValue)
{
DeletedOn = null;
DeletedBy = null;
}
}
}

View File

@@ -0,0 +1,17 @@
namespace Prefab.Domain.Common;
/// <summary>
/// All entities that require auditing, statuses and tenant information should use this.
/// </summary>
/// <typeparam name="T">The type that will represent the entity's ID field.</typeparam>
public interface IEntityWithAuditAndStatusAndTenant<T> : IEntityWithAuditAndStatus<T>, ITenant
{
}
/// <inheritdoc cref="IEntityWithAuditAndStatusAndTenant{T}" />
public abstract class EntityWithAuditAndStatusAndTenant<T> : EntityWithAuditAndStatus<T>,
IEntityWithAuditAndStatusAndTenant<T>
{
/// <inheritdoc />
public Guid TenantId { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Prefab.Domain.Common;
/// <summary>
/// All entities that require auditing, statuses and tenant information should use this.
/// </summary>
/// <typeparam name="T">The type that will represent the entity's ID field.</typeparam>
public interface IEntityWithAuditAndTenant<T> : IEntityWithAudit<T>, ITenant
{
}
/// <inheritdoc cref="IEntityWithAuditAndTenant{T}" />
public class EntityWithAuditAndTenant<T> : EntityWithAudit<T>, IEntityWithAuditAndTenant<T>
{
/// <inheritdoc />
public Guid TenantId { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace Prefab.Domain.Common;
/// <summary>
/// All entities that require tenant information should use this. Use
/// <see cref="EntityWithAuditAndStatusAndTenant{T}" />
/// </summary>
/// <typeparam name="T">The type that will represent the entity's ID field.</typeparam>
public interface IEntityWithTenant<T> : IEntity<T>, ITenant
{
}
/// <inheritdoc cref="IEntityWithTenant{T}" />
public abstract class EntityWithTenant<T> : Entity<T>, IEntityWithTenant<T>
{
/// <inheritdoc />
public Guid TenantId { get; set; }
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations.Schema;
using Prefab.Base;
namespace Prefab.Domain.Common;
/// <summary>
/// Base type for domain events captured during aggregate processing.
/// </summary>
[NotMapped]
public abstract record Event : IEvent
{
/// <summary>
/// Gets a value indicating whether the event has been dispatched to external transports.
/// </summary>
public bool IsPublished { get; internal set; }
/// <summary>
/// Gets the timestamp for when the event occurred.
/// </summary>
public DateTimeOffset DateOccurred { get; protected set; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,17 @@
namespace Prefab.Domain.Common;
/// <summary>
/// Describes audit metadata captured on entities.
/// </summary>
public interface IAudit : ICreated
{
/// <summary>
/// Gets the identifier of the user that last updated the entity.
/// </summary>
Guid LastModifiedBy { get; }
/// <summary>
/// Gets when the entity was last updated.
/// </summary>
DateTimeOffset LastModifiedOn { get; }
}

View File

@@ -0,0 +1,17 @@
namespace Prefab.Domain.Common;
/// <summary>
/// Captures creation metadata for auditable entities.
/// </summary>
public interface ICreated
{
/// <summary>
/// Gets the identifier of the user that created the entity.
/// </summary>
Guid CreatedBy { get; }
/// <summary>
/// Gets when the entity was created.
/// </summary>
DateTimeOffset CreatedOn { get; }
}

View File

@@ -0,0 +1,13 @@
namespace Prefab.Domain.Common;
/// <summary>
/// Interface representation of an entity. All entities should implement this.
/// </summary>
/// <typeparam name="T">The type that will represent the entity's ID field.</typeparam>
public interface IEntity<T>
{
/// <summary>
/// Gets or sets the ID of the entity.
/// </summary>
T Id { get; set; }
}

View File

@@ -0,0 +1,29 @@
using Prefab.Base;
namespace Prefab.Domain.Common;
/// <summary>
/// Interface representation of an event in the domain model.
/// </summary>
public interface IHasDomainEvents : IEvent
{
/// <summary>
/// Gets the events of the entity.
/// </summary>
IReadOnlyCollection<Event> Events { get; }
/// <summary>
/// Adds an event to the entity.
/// </summary>
public void AddEvent(Event e);
/// <summary>
/// Removes an event from the entity.
/// </summary>
public void RemoveEvent(Event e);
/// <summary>
/// Clears all events from the entity.
/// </summary>
public void ClearEvents();
}

View File

@@ -0,0 +1,8 @@
namespace Prefab.Domain.Common;
/// <summary>
/// Interface to represent a marker on an entity, informing that the entity is considered an aggregate root.
/// </summary>
public interface IRoot
{
}

View File

@@ -0,0 +1,14 @@
using Prefab.Data;
namespace Prefab.Domain.Common;
/// <summary>
/// Interface representation of an entity's status.
/// </summary>
public interface IStatus
{
/// <summary>
/// Gets or sets the current status of the entity.
/// </summary>
public AuditStatus AuditStatus { get; }
}

View File

@@ -0,0 +1,12 @@
namespace Prefab.Domain.Common;
/// <summary>
/// Marks an entity as belonging to a tenant.
/// </summary>
public interface ITenant
{
/// <summary>
/// Gets the unique tenant identifier.
/// </summary>
Guid TenantId { get; }
}

View File

@@ -0,0 +1,11 @@
using Prefab.Domain.Common;
namespace Prefab.Domain.Events;
/// <summary>
/// Event raised when a generic attribute is added or updated on an entity.
/// </summary>
/// <param name="Entity">The entity that owns the attribute.</param>
/// <param name="Key">The attribute key.</param>
/// <param name="Value">The new attribute value.</param>
public record GenericAttributeAddedOrUpdatedEvent(object Entity, string Key, object Value) : Event;

View File

@@ -0,0 +1,25 @@
using Prefab.Domain.Common;
namespace Prefab.Domain.Events;
/// <summary>
/// Event raised whenever a generic attribute is added, updated, or removed from an entity.
/// </summary>
/// <param name="Entity">The entity that owns the attribute.</param>
/// <param name="Key">The attribute key.</param>
/// <param name="Value">The attribute value involved in the change.</param>
/// <param name="ChangeType">Indicates the type of mutation performed.</param>
public record GenericAttributeChangedEvent(object Entity, string Key, object? Value, GenericAttributeChangeType ChangeType) : Event;
/// <summary>
/// Represents the types of mutations that can occur for a generic attribute.
/// </summary>
public enum GenericAttributeChangeType
{
/// <summary>Attribute was added for the first time.</summary>
Add,
/// <summary>Attribute was updated with a new value.</summary>
Update,
/// <summary>Attribute was removed.</summary>
Remove
}

View File

@@ -0,0 +1,10 @@
using Prefab.Domain.Common;
namespace Prefab.Domain.Events;
/// <summary>
/// Event raised when a generic attribute is removed from an entity.
/// </summary>
/// <param name="Entity">The entity that owned the attribute.</param>
/// <param name="Key">The key that was removed.</param>
public record GenericAttributeRemovedEvent(object Entity, string Key) : Event;

View File

@@ -0,0 +1,9 @@
namespace Prefab.Domain.Exceptions;
/// <summary>
/// Thrown when a concurrency conflict is detected.
/// </summary>
public class ConcurrencyException : DomainException
{
public ConcurrencyException() : base("The record you attempted to modify was changed by another process.") { }
}

View File

@@ -0,0 +1,6 @@
namespace Prefab.Domain.Exceptions;
/// <summary>
/// Root of all domainrule violations.
/// </summary>
public class DomainException(string message) : Exception(message);

View File

@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Prefab.Endpoints;
/// <summary>
/// Shared helpers for building consistent HTTP problem responses across modules.
/// </summary>
public static class ApiProblemDetails
{
/// <summary>
/// Creates a 404 <see cref="ProblemDetails"/> payload with standard metadata and resource identifiers.
/// </summary>
/// <param name="resource">Logical name of the missing resource (for example, "Product").</param>
/// <param name="identifier">Identifier value supplied by the caller.</param>
/// <param name="detail">Human-readable explanation for logging or the client.</param>
public static ProblemDetails NotFound(string resource, string identifier, string detail) =>
new()
{
Title = "Resource not found.",
Detail = detail,
Type = "https://prefab.dev/problems/not-found",
Status = StatusCodes.Status404NotFound,
Extensions =
{
["resource"] = resource,
["identifier"] = identifier
}
};
/// <summary>
/// Creates a 409 <see cref="ProblemDetails"/> payload used when a request cannot be completed due to a resource conflict.
/// </summary>
/// <param name="detail">Human-readable explanation of the conflict.</param>
/// <param name="resource">Logical name of the resource involved, when known.</param>
/// <param name="identifier">Identifier value supplied by the caller, when known.</param>
public static ProblemDetails Conflict(string detail, string? resource = null, string? identifier = null)
{
var problem = new ProblemDetails
{
Title = "Request conflict.",
Detail = detail,
Type = "https://prefab.dev/problems/conflict",
Status = StatusCodes.Status409Conflict
};
if (!string.IsNullOrWhiteSpace(resource))
{
problem.Extensions["resource"] = resource;
}
if (!string.IsNullOrWhiteSpace(identifier))
{
problem.Extensions["identifier"] = identifier;
}
return problem;
}
}

View File

@@ -0,0 +1,79 @@
using System.Reflection;
namespace Prefab.Endpoints;
/// <summary>
/// Provides utility methods for generating standardized endpoint names based on type and module information.
/// </summary>
/// <remarks>This class is intended for internal use to construct endpoint names that reflect the module and
/// feature structure of the application. The generated names are typically used for routing, messaging, or service
/// registration scenarios where consistent naming conventions are required. Endpoint names are constructed using the
/// namespace or assembly segments of the provided type, followed by the specified endpoint name.</remarks>
internal static class EndpointName
{
/// <summary>
/// Gets the default name for the specified type parameter.
/// </summary>
/// <typeparam name="T">The type for which to retrieve the default name.</typeparam>
/// <returns>A string containing the default name of the type parameter <typeparamref name="T"/>.</returns>
public static string For<T>() => For<T>(typeof(T).Name);
/// <summary>
/// Gets the endpoint name for the specified type and endpoint identifier.
/// </summary>
/// <typeparam name="T">The type for which to retrieve the endpoint name.</typeparam>
/// <param name="endpointName">The logical name of the endpoint. Cannot be null or empty.</param>
/// <returns>A string representing the endpoint name associated with the specified type and endpoint identifier.</returns>
public static string For<T>(string endpointName) => For(typeof(T), endpointName);
/// <summary>
/// Generates a fully qualified endpoint name by combining the module segments derived from the specified type with
/// the provided endpoint name.
/// </summary>
/// <param name="type">The type from which to resolve module segments. Typically, represents the class or module associated with the
/// endpoint.</param>
/// <param name="endpointName">The name of the endpoint to append. Cannot be null, empty, or consist only of white-space characters.</param>
/// <returns>A string representing the fully qualified endpoint name in the format 'Module.Segment.EndpointName'.</returns>
/// <exception cref="ArgumentException">Thrown if endpointName is null, empty, or consists only of white-space characters.</exception>
public static string For(Type type, string endpointName)
{
if (string.IsNullOrWhiteSpace(endpointName))
{
throw new ArgumentException("Endpoint name cannot be null or whitespace.", nameof(endpointName));
}
var segments = ResolveModuleSegments(type);
return $"{string.Join('.', segments)}.{endpointName}";
}
private static string[] ResolveModuleSegments(MemberInfo type)
{
if (type is Type concreteType)
{
var namespaceSegments = ExtractNamespaceSegments(concreteType.Namespace);
if (namespaceSegments.Length > 0)
{
return namespaceSegments;
}
}
var assemblySegments = ExtractNamespaceSegments(type.Module?.Assembly?.GetName().Name);
return assemblySegments.Length > 0 ? assemblySegments : [type.Name];
}
private static string[] ExtractNamespaceSegments(string? root)
{
if (string.IsNullOrWhiteSpace(root))
{
return [];
}
var segments = root.Split('.', StringSplitOptions.RemoveEmptyEntries);
// Convention: Prefab.<Module>.(App.)<Feature>...
return segments
.SkipWhile(segment => string.Equals(segment, "Prefab", StringComparison.Ordinal))
.Where(segment => !string.Equals(segment, "App", StringComparison.Ordinal))
.ToArray();
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Builder;
namespace Prefab.Endpoints;
/// <summary>
/// Provides extension methods for configuring endpoint names with module-based naming conventions in ASP.NET Core
/// routing.
/// </summary>
/// <remarks>These methods help standardize endpoint naming by prefixing endpoint names with a module name
/// inferred from a specified type. This can improve discoverability and organization of endpoints, especially in
/// modular applications. The extensions are intended for use with ASP.NET Core's routing infrastructure and are
/// typically called when configuring endpoints in minimal APIs or similar scenarios.</remarks>
public static class Extensions
{
/// <summary>
/// Applies an endpoint name that combines the module name inferred from <typeparamref name="T"/> with the type name.
/// </summary>
/// <typeparam name="T">A type that belongs to the module the endpoint lives in, typically the endpoint class itself.</typeparam>
public static RouteHandlerBuilder WithModuleName<T>(this RouteHandlerBuilder builder)
=> builder.WithModuleName<T>(typeof(T).Name);
/// <summary>
/// Applies an endpoint name that combines the module name inferred from <typeparamref name="T"/> with the provided endpoint name.
/// </summary>
/// <typeparam name="T">A type that belongs to the module the endpoint lives in, typically the endpoint class itself.</typeparam>
/// <param name="endpointName">The base endpoint name that will be prefixed with the module name.</param>
public static RouteHandlerBuilder WithModuleName<T>(this RouteHandlerBuilder builder, string endpointName)
=> builder.WithName(EndpointName.For<T>(endpointName));
}

View File

@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Routing;
namespace Prefab.Endpoints;
/// <summary>
/// Defines a contract for registering application endpoints with an endpoint route builder.
/// </summary>
/// <remarks>Implement this interface to configure and map endpoints, such as HTTP routes, to the application's
/// routing system. This is typically used during application startup to organize endpoint registration logic.</remarks>
public interface IEndpointRegistrar
{
/// <summary>
/// Configures application endpoints using the specified endpoint route builder.
/// </summary>
/// <param name="endpoints">The endpoint route builder used to map routes for the application. Cannot be null.</param>
void MapEndpoints(IEndpointRouteBuilder endpoints);
}

View File

@@ -0,0 +1,123 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
namespace Prefab.Handler.Decorators;
/// <summary>
/// Provides a decorator for handler execution that establishes and manages the current HandlerContext for each request,
/// supporting HTTP, Blazor Server, and background scenarios.
/// </summary>
/// <remarks>This decorator ensures that a HandlerContext is available for each handler invocation, propagating
/// correlation and request identifiers as appropriate for the execution environment. It supports HTTP requests, Blazor
/// Server interactive sessions, and background processing, automatically selecting the appropriate context source. If
/// an existing HandlerContext is present, it is reused with a new request identifier. This class is typically used in
/// middleware or pipeline scenarios to provide consistent context information for logging, tracing, or auditing
/// purposes.</remarks>
/// <param name="handlerAccessor">The handlerAccessor used to get or set the current HandlerContext for the executing request. Cannot be null.</param>
/// <param name="httpAccessor">The HTTP context handlerAccessor used to extract request and user information when handling HTTP requests. May be null if
/// HTTP context is not available.</param>
/// <param name="auth">The authentication state provider used to obtain user information in Blazor Server scenarios. May be null if
/// authentication state is not required.</param>
public class HandlerContextDecorator(
IHandlerContextAccessor handlerAccessor,
IHttpContextAccessor? httpAccessor,
AuthenticationStateProvider? auth = null) : IHandlerDecorator
{
public async Task<TResponse> Invoke<TRequest, TResponse>(Func<TRequest, CancellationToken, Task<TResponse>> next, TRequest request, CancellationToken cancellationToken)
{
// If a parent context exists, reuse correlation; mint a new request id
if (handlerAccessor.Current is { } parent)
{
var child = parent with { RequestId = NewId() };
using var scope = handlerAccessor.Set(child);
return await next(request, cancellationToken);
}
if (httpAccessor?.HttpContext is HttpContext httpContext)
{
var ctx = FromHttp(httpContext);
var stored = TryStoreHandlerContext(httpContext, ctx);
using var scope = handlerAccessor.Set(ctx);
try
{
return await next(request, cancellationToken);
}
finally
{
if (stored)
{
httpContext.Items.Remove(HandlerContextItems.HttpContextKey);
}
}
}
if (auth is not null)
{
var state = await auth.GetAuthenticationStateAsync();
var ctx = ForInteractive(state.User);
using var scope = handlerAccessor.Set(ctx);
return await next(request, cancellationToken);
}
// Background/default
{
var ctx = ForBackground();
using var scope = handlerAccessor.Set(ctx);
return await next(request, cancellationToken);
}
}
#region Helpers
private static HandlerContext FromHttp(HttpContext httpContext)
{
httpContext.Request.Headers.TryGetValue("Idempotency-Key", out var idem);
var userId = GetUserId(httpContext.User);
string FirstNonEmpty(string a, string b) => !string.IsNullOrWhiteSpace(a) ? a : b;
var corr = FirstNonEmpty(httpContext.Request.Headers["X-Correlation-Id"].ToString(), httpContext.TraceIdentifier);
var req = FirstNonEmpty(httpContext.Request.Headers["X-Request-Id"].ToString(), httpContext.TraceIdentifier);
return new HandlerContext(
CorrelationId: string.IsNullOrWhiteSpace(corr) ? NewId() : corr,
RequestId: string.IsNullOrWhiteSpace(req) ? NewId() : req,
IdempotencyKey: string.IsNullOrWhiteSpace(idem) ? null : idem.ToString(),
UserId: userId);
}
private static HandlerContext ForInteractive(ClaimsPrincipal? user) => new(
UserId: GetUserId(user), RequestId: NewId(), CorrelationId: NewId(), IdempotencyKey: null);
private static HandlerContext ForBackground(string? correlationId = null, string? userId = null) => new(
UserId: userId, RequestId: NewId(), CorrelationId: correlationId ?? NewId(), IdempotencyKey: null);
private static string? GetUserId(ClaimsPrincipal? user)
{
if (user?.Identity?.IsAuthenticated != true) return null;
return user.FindFirst("sub")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.Identity!.Name;
}
private static string NewId() => Guid.NewGuid().ToString("N");
private static bool TryStoreHandlerContext(HttpContext httpContext, HandlerContext context)
{
if (httpContext.Items.ContainsKey(HandlerContextItems.HttpContextKey))
{
return false;
}
httpContext.Items[HandlerContextItems.HttpContextKey] = context;
return true;
}
#endregion
}

View File

@@ -0,0 +1,15 @@
namespace Prefab.Handler.Decorators;
/// <summary>
/// Defines a contract for decorating handler execution with additional behavior, such as logging, validation, or
/// exception handling.
/// </summary>
/// <remarks>Implementations of this interface can be used to add cross-cutting concerns around handler invocation
/// in a pipeline. Decorators can inspect or modify the request, context, or response, and may perform actions before or
/// after calling the next handler in the chain. This interface is typically used in scenarios where handler logic needs
/// to be extended without modifying the original handler implementation.</remarks>
public interface IHandlerDecorator
{
Task<TResponse> Invoke<TRequest, TResponse>(Func<TRequest, CancellationToken, Task<TResponse>> next,
TRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,161 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Prefab.Handler.Decorators;
public sealed class LoggingDecoratorOptions
{
/// <summary>Log at Warning when handler time >= this threshold (ms).</summary>
public int SlowThresholdInMilliseconds { get; init; } = 1000;
}
public sealed class LoggingDecorator(ILogger<LoggingDecorator> logger, IHandlerContextAccessor handlerContext, IOptions<LoggingDecoratorOptions>? options = null) : IHandlerDecorator
{
private readonly LoggingDecoratorOptions _options = options?.Value ?? new LoggingDecoratorOptions();
public async Task<TResponse> Invoke<TRequest, TResponse>(Func<TRequest, CancellationToken, Task<TResponse>> next,
TRequest request, CancellationToken cancellationToken)
{
// Friendly operation label
var requestType = DescribeMessageType(typeof(TRequest));
var responseType = DescribeMessageType(typeof(TResponse));
var operation = $"{requestType}->{responseType}";
var currentContext = handlerContext.Current ?? new HandlerContext(null, null, null, null);
using var scope = logger.BeginScope(new Dictionary<string, object?>
{
["Operation"] = operation,
["RequestType"] = requestType,
["ResponseType"] = responseType,
["CorrelationId"] = currentContext.CorrelationId,
["IdempotencyKey"] = currentContext.IdempotencyKey,
["RequestId"] = currentContext.RequestId,
["UserId"] = currentContext.UserId
});
var stopwatch = Stopwatch.StartNew();
// (Optional) OpenTelemetry tagging
var activity = Activity.Current;
activity?.SetTag("handler.operation", operation);
activity?.SetTag("handler.request_type", requestType);
activity?.SetTag("handler.response_type", responseType);
activity?.SetTag("handler.correlation_id", currentContext.CorrelationId);
activity?.SetTag("handler.request_id", currentContext.RequestId);
try
{
if (logger.IsEnabled(LogLevel.Debug))
logger.LogDebug("Handling {Operation}", operation);
var result = await next(request, cancellationToken).ConfigureAwait(false);
return result;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
if (logger.IsEnabled(LogLevel.Warning))
logger.LogWarning("Canceled {Operation}", operation);
activity?.SetStatus(ActivityStatusCode.Error, "canceled");
throw;
}
catch (Exception ex)
{
if (logger.IsEnabled(LogLevel.Error))
logger.LogError(ex, "Error handling {Operation}", operation);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
finally
{
stopwatch.Stop();
var ms = stopwatch.ElapsedMilliseconds;
activity?.SetTag("handler.duration_ms", ms);
if (ms >= _options.SlowThresholdInMilliseconds && logger.IsEnabled(LogLevel.Warning))
logger.LogWarning("Handled {Operation} in {ElapsedMs} ms (slow)", operation, ms);
else if (logger.IsEnabled(LogLevel.Information))
logger.LogInformation("Handled {Operation} in {ElapsedMs} ms", operation, ms);
}
}
// ---- Pretty type-name helpers with caching ----
private static string DescribeMessageType(Type type) =>
MessageTypeCache.GetOrAdd(type, static t =>
{
if (t == typeof(NoRequest))
{
return nameof(NoRequest);
}
if (t.FullName is { } fullName &&
fullName.StartsWith("Prefab.", StringComparison.Ordinal))
{
var formatted = FormatPrefabTypeName(fullName);
if (!string.IsNullOrEmpty(formatted))
{
return formatted;
}
}
return PrettyTypeName(t);
});
private static string FormatPrefabTypeName(string fullName)
{
const string prefix = "Prefab.";
var span = fullName.AsSpan(prefix.Length);
var buffer = new List<string>();
foreach (var segment in span.ToString().Replace('+', '.')
.Split('.', StringSplitOptions.RemoveEmptyEntries))
{
if (SegmentsToSkip.Contains(segment))
continue;
buffer.Add(segment);
}
if (buffer.Count == 0)
{
return string.Empty;
}
return string.Join('.', buffer);
}
/// <summary>
/// Returns a human-readable type name (e.g., <c>Dictionary&lt;string, List&lt;int&gt;&gt;</c>)
/// instead of framework names like <c>Dictionary`2</c>.
/// </summary>
private static string PrettyTypeName(Type type) =>
PrettyCache.GetOrAdd(type, static t => PrettyTypeNameUncached(t));
private static string PrettyTypeNameUncached(Type t)
{
if (!t.IsGenericType) return t.Name;
var name = t.Name;
var tick = name.IndexOf('`');
var baseName = tick >= 0 ? name[..tick] : name;
var args = t.GetGenericArguments().Select(PrettyTypeNameUncached);
return $"{baseName}<{string.Join(",", args)}>";
}
private static readonly ConcurrentDictionary<Type, string> MessageTypeCache = new();
private static readonly ConcurrentDictionary<Type, string> PrettyCache = new();
private static readonly HashSet<string> SegmentsToSkip = new(StringComparer.OrdinalIgnoreCase)
{
"Shared",
"App",
"Handler",
"Base"
};
}

View File

@@ -0,0 +1,65 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.DependencyInjection;
namespace Prefab.Handler.Decorators;
/// <summary>
/// Decorator that runs all registered FluentValidation validators for the current request type
/// before the handler executes.
/// </summary>
/// <remarks>
/// Validators are resolved on demand per request, allowing pages and modules to register request-specific
/// validators without touching handler implementations. When no validators are registered, the decorator
/// simply forwards execution to the next component in the pipeline.
/// </remarks>
public sealed class ValidationDecorator(IServiceProvider serviceProvider) : IHandlerDecorator
{
public async Task<TResponse> Invoke<TRequest, TResponse>(
Func<TRequest, CancellationToken, Task<TResponse>> next,
TRequest request,
CancellationToken cancellationToken)
{
var failures = await ValidateAsync(request, cancellationToken);
if (failures.Count != 0)
{
throw new ValidationException(failures);
}
return await next(request, cancellationToken).ConfigureAwait(false);
}
private async Task<IReadOnlyCollection<ValidationFailure>> ValidateAsync<TRequest>(
TRequest request,
CancellationToken cancellationToken)
{
if (request is null)
{
return [];
}
var validators = serviceProvider.GetServices<IValidator<TRequest>>();
var enumerable = validators as IValidator<TRequest>[] ?? validators.ToArray();
if (enumerable.Length == 0)
{
return [];
}
var context = new ValidationContext<TRequest>(request);
var failures = new List<ValidationFailure>();
foreach (var validator in enumerable)
{
var result = await validator.ValidateAsync(context, cancellationToken).ConfigureAwait(false);
if (!result.IsValid)
{
failures.AddRange(result.Errors.Where(f => f is not null));
}
}
return failures;
}
}

View File

@@ -0,0 +1,15 @@
using Prefab.Handler.Decorators;
namespace Prefab.Handler;
/// <summary>
/// Provides a base class for handler implementations that require access to the current handler context.
/// </summary>
/// <param name="accessor">The accessor used to retrieve the current handler context. Cannot be null.</param>
public abstract class HandlerBase(IHandlerContextAccessor accessor)
{
protected IHandlerContextAccessor Accessor { get; } = accessor;
protected HandlerContext Context => Accessor.Current ??
throw new InvalidOperationException($"HandlerContext not set ({nameof(HandlerContextDecorator)}) must run first).");
}

View File

@@ -0,0 +1,20 @@
namespace Prefab.Handler;
/// <summary>
/// Represents contextual information about the current request and user for use in request handling and logging.
/// </summary>
/// <remarks>This record is typically used to pass request-scoped metadata through application layers, enabling
/// consistent logging, tracing, and idempotency handling. All properties are optional and may be null if the
/// corresponding information is unavailable.</remarks>
/// <param name="UserId">The unique identifier of the user associated with the request, or null if the user is unauthenticated.</param>
/// <param name="RequestId">The unique identifier for the current request, used for tracing and diagnostics. Can be null if not set.</param>
/// <param name="CorrelationId">The identifier used to correlate this request with related operations across services or components. Can be null if
/// not set.</param>
/// <param name="IdempotencyKey">A key that uniquely identifies the request for idempotency purposes, allowing safe retries. Can be null if
/// idempotency is not required.</param>
public sealed record HandlerContext(
string? UserId,
string? RequestId,
string? CorrelationId,
string? IdempotencyKey);

View File

@@ -0,0 +1,12 @@
namespace Prefab.Handler;
/// <summary>
/// Provides shared keys for storing handler context metadata in request-scoped containers.
/// </summary>
public static class HandlerContextItems
{
/// <summary>
/// Key used when storing <see cref="HandlerContext"/> in <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
/// </summary>
public const string HttpContextKey = "__prefab.handler-context";
}

View File

@@ -0,0 +1,100 @@
using Microsoft.Extensions.DependencyInjection;
using Prefab.Handler.Decorators;
namespace Prefab.Handler;
/// <summary>
/// Provides a mechanism to execute handler pipelines with support for applying a sequence of handler decorators. This
/// class enables the composition of cross-cutting concerns around handler execution.
/// </summary>
/// <remarks>HandlerInvoker is typically used to execute handlers with additional behaviors, such as logging,
/// validation, or exception handling, by composing the provided decorators. The order of decorators affects the
/// execution flow: the first-registered decorator is invoked first and wraps subsequent decorators and the handler
/// itself. This class is thread-safe for concurrent handler executions.</remarks>
/// <param name="decorators">The collection of handler decorators to apply to each handler invocation. Decorators are applied in the order they
/// are provided, with the first decorator wrapping the outermost layer of the pipeline. Cannot be null.</param>
public sealed class HandlerInvoker(IEnumerable<IHandlerDecorator> decorators, IServiceProvider serviceProvider)
{
private readonly IHandlerDecorator[] _decorators = decorators.Reverse().ToArray();
public Task<TResponse> Execute<TResponse>(CancellationToken cancellationToken)
{
var inner = serviceProvider.GetRequiredService<IHandler<TResponse>>();
// No decorators? Call the handler directly.
if (_decorators.Length == 0)
return inner.Execute(cancellationToken);
var noRequest = new NoRequestAdapter<TResponse>(inner);
var pipeline = BuildPipeline(noRequest);
return pipeline(NoRequest.Instance, cancellationToken);
}
/// <summary>
/// Executes the specified handler for the given request within the provided context and cancellation token,
/// returning the handler's response asynchronously.
/// </summary>
/// <typeparam name="TRequest">The type of the request to be handled. Must not be null.</typeparam>
/// <typeparam name="TResponse">The type of the response returned by the handler.</typeparam>
/// <param name="request">The request object to be processed by the handler.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the response produced by the
/// handler.</returns>
public Task<TResponse> Execute<TRequest, TResponse>(TRequest request, CancellationToken cancellationToken) where TRequest : notnull
{
var handler = serviceProvider.GetRequiredService<IHandler<TRequest, TResponse>>();
// No decorators? Call the handler directly.
if (_decorators.Length == 0)
return handler.Execute(request, cancellationToken);
var pipeline = BuildPipeline(handler);
return pipeline(request, cancellationToken);
}
/// <summary>
/// Builds a delegate representing the request handling pipeline, applying all registered decorators to the
/// specified handler.
/// </summary>
/// <remarks>Decorators are applied in the order they were registered, with the first-registered decorator
/// wrapping the outermost layer of the pipeline. The returned delegate can be invoked with a request, handler
/// context, and cancellation token to execute the full pipeline.</remarks>
/// <typeparam name="TRequest">The type of the request message to be handled.</typeparam>
/// <typeparam name="TResponse">The type of the response message returned by the handler.</typeparam>
/// <param name="handler">The core handler to which decorators will be applied. Cannot be null.</param>
/// <returns>A delegate that processes a request through all registered decorators and the specified handler.</returns>
private Func<TRequest, CancellationToken, Task<TResponse>> BuildPipeline<TRequest, TResponse>(IHandler<TRequest, TResponse> handler)
{
// Start from the final operation: the handler itself.
Func<TRequest, CancellationToken, Task<TResponse>> pipeline = handler.Execute;
// Wrap in reverse registration order so the first-registered decorator runs outermost.
foreach (var decorator in _decorators)
{
var next = pipeline;
pipeline = (request, cancellationToken) =>
decorator.Invoke(next, request, cancellationToken);
}
return pipeline;
}
/// <summary>
/// Provides an adapter that allows an existing handler to be used where a handler for a request type of NoRequest
/// is required.
/// </summary>
/// <remarks>This class is useful for scenarios where a handler expects no input request data but must
/// conform to an interface requiring a request parameter. It delegates execution to the specified inner handler,
/// ignoring the NoRequest parameter.</remarks>
/// <typeparam name="TResponse">The type of the response returned by the handler.</typeparam>
/// <param name="innerHandler">The handler that processes requests and produces a response of type TResponse.</param>
private sealed class NoRequestAdapter<TResponse>(IHandler<TResponse> innerHandler) : IHandler<NoRequest, TResponse>
{
public Task<TResponse> Execute(NoRequest _, CancellationToken cancellationToken)
=> innerHandler.Execute(cancellationToken);
}
}

View File

@@ -0,0 +1,25 @@
namespace Prefab.Handler;
/// <summary>
/// Defines a handler that processes a request and returns a response asynchronously.
/// </summary>
/// <remarks>Implementations of this interface encapsulate the logic required to handle a specific request and
/// produce a result. Handlers are typically used in command or query processing pipelines to separate request handling
/// logic from other application concerns.</remarks>
/// <typeparam name="TResponse">The type of the response returned by the handler.</typeparam>
public interface IHandler<TResponse>
{
Task<TResponse> Execute(CancellationToken cancellationToken);
}
/// <summary>
/// Defines a contract for handling a request and producing a response asynchronously.
/// </summary>
/// <remarks>Implementations of this interface should be thread-safe if they are intended to be used concurrently.
/// The interface is typically used in request/response or command/query processing pipelines.</remarks>
/// <typeparam name="TRequest">The type of the request to be handled.</typeparam>
/// <typeparam name="TResponse">The type of the response returned after handling the request.</typeparam>
public interface IHandler<in TRequest, TResponse>
{
Task<TResponse> Execute(TRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,66 @@
namespace Prefab.Handler;
/// <summary>
/// Provides access to the current handler context for the executing operation.
/// </summary>
/// <remarks>Implementations of this interface allow components to retrieve or temporarily override the current
/// HandlerContext within a given scope. This is typically used to flow contextual information, such as user or request
/// data, through asynchronous or nested operations.</remarks>
public interface IHandlerContextAccessor
{
/// <summary>
/// Gets the current handler context for the ongoing operation, if one is available.
/// </summary>
HandlerContext? Current { get; }
/// <summary>
/// Sets the specified handler context as the current context for the duration of the returned disposable object's
/// lifetime.
/// </summary>
/// <remarks>Use the returned IDisposable in a using statement to ensure the previous context is restored
/// even if an exception occurs.</remarks>
/// <param name="handlerContext">The handler context to set as current. Cannot be null.</param>
/// <returns>An IDisposable that, when disposed, restores the previous handler context.</returns>
IDisposable Set(HandlerContext handlerContext);
}
/// <inheritdoc cref="IHandlerContextAccessor"/>
public sealed class HandlerContextAccessor : IHandlerContextAccessor
{
private static readonly AsyncLocal<HandlerContext?> CurrentContext = new();
/// <inheritdoc cref="IHandlerContextAccessor"/>
public HandlerContext? Current => CurrentContext.Value;
/// <inheritdoc cref="IHandlerContextAccessor"/>
public IDisposable Set(HandlerContext handlerContext)
{
var previous = CurrentContext.Value;
CurrentContext.Value = handlerContext;
return new Scope(() => CurrentContext.Value = previous);
}
/// <summary>
/// Provides a mechanism for executing a specified delegate when the scope is disposed, typically used to restore
/// state or perform cleanup actions.
/// </summary>
/// <remarks>This type is intended for use with the 'using' statement to ensure that the specified
/// delegate is executed exactly once when the scope ends. The delegate is not invoked if Dispose is called more
/// than once.</remarks>
/// <param name="restore">The delegate to invoke when the scope is disposed. Cannot be null.</param>
private sealed class Scope(Action restore) : IDisposable
{
private bool _done;
public void Dispose()
{
if(_done)
return;
_done = true;
restore();
}
}
}

View File

@@ -0,0 +1,11 @@
namespace Prefab.Handler;
/// <summary>
/// Represents a sentinel value indicating the absence of a request.
/// </summary>
/// <remarks>Use this type when an operation or API requires a value to signify that no request is present. This
/// can be useful in scenarios where a method or handler expects a request type but no data is needed.</remarks>
public readonly record struct NoRequest
{
public static readonly NoRequest Instance = default;
}

177
Prefab/Module/Extensions.cs Normal file
View File

@@ -0,0 +1,177 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Prefab.Data;
using Prefab.Data.Seeder;
using Prefab.Endpoints;
namespace Prefab.Module;
/// <summary>
/// Provides extension methods for registering and configuring Prefab modules and endpoint registrars within an ASP.NET
/// Core application.
/// </summary>
/// <remarks>The Extensions class contains methods intended to be called from the application's Program.cs file to
/// integrate Prefab modules into the application's dependency injection and request pipeline. These methods enable
/// modular composition by discovering and registering implementations of IModule and IEndpointRegistrar from
/// application dependencies. This approach allows modules to participate in both the application's build and
/// configuration phases, supporting extensibility and separation of concerns.</remarks>
public static class Extensions
{
/// <summary>
/// Adds Prefab modules, endpoint registrars, and related services to the specified WebApplicationBuilder for
/// modular application composition.
/// </summary>
/// <remarks>This method scans application dependencies for types implementing IModule and
/// IEndpointRegistrar, registers them as singletons, and invokes their Build methods to allow modules to
/// participate in application setup. Call this method early in the application's startup configuration to ensure
/// all modules are properly registered and initialized.</remarks>
/// <param name="builder">The WebApplicationBuilder to which Prefab modules and services will be added. Cannot be null.</param>
/// <returns>The same WebApplicationBuilder instance, configured with Prefab modules and services.</returns>
public static WebApplicationBuilder AddPrefab(this WebApplicationBuilder builder)
{
builder.Services.AddPrefab(builder.Configuration);
// Discover endpoint registrars through Scrutor so modules can contribute HTTP endpoints.
builder.Services.Scan(scan => scan
.FromApplicationDependencies(assembly => assembly.GetName().Name?.StartsWith(nameof(Prefab)) == true)
.AddClasses(classes => classes.AssignableTo(typeof(IEndpointRegistrar)))
.As<IEndpointRegistrar>()
.WithSingletonLifetime());
// Discover modules via Scrutor and register them as singletons so they can participate in Build/Configure.
builder.Services.Scan(scan => scan
.FromApplicationDependencies(assembly => assembly.GetName().Name?.StartsWith(nameof(Prefab)) == true)
.AddClasses(classes => classes.AssignableTo<IModule>())
.As<IModule>()
.WithSingletonLifetime());
var modules = new List<IModule>();
// Build a temporary provider so DI can instantiate modules before the real host is built.
// Disposes it immediately but keeps the instances so Build/Configure run on the same objects.
using (var provider = builder.Services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateOnBuild = true,
ValidateScopes = true
}))
{
modules.AddRange(provider.GetServices<IModule>());
}
foreach (var module in modules)
{
module.Build(builder);
}
builder.Services.RemoveAll<IModule>();
foreach (var module in modules)
{
builder.Services.AddSingleton(module);
}
builder.Services.AddPrefabHandlers();
return builder;
}
/// <summary>
/// Configures the application by invoking all registered modules and mapping endpoints using registered endpoint
/// registrars.
/// </summary>
/// <remarks>This method discovers and executes all services implementing <see cref="IModule"/> and <see
/// cref="IEndpointRegistrar"/> from the application's dependency injection container. Each module's configuration
/// and endpoint mapping logic will be applied in the order they are registered.</remarks>
/// <param name="app">The <see cref="WebApplication"/> instance to configure. Cannot be null.</param>
/// <returns>The same <see cref="WebApplication"/> instance, enabling method chaining.</returns>
public static WebApplication UsePrefab(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var seederTypes = scope.ServiceProvider.GetServices<ISeeder>()
.Select(seeder => seeder.GetType())
.ToList();
foreach (var type in seederTypes)
{
var mutexName = $"Global\\PrefabSeeder_{type.FullName}";
var mutex = new Mutex(initiallyOwned: false, mutexName, out _);
var acquired = false;
try
{
acquired = mutex.WaitOne(0);
}
catch (AbandonedMutexException)
{
acquired = true;
}
if (!acquired)
{
mutex.Dispose();
continue;
}
Prefab.Data.Seeder.Extensions.Channel.Writer.TryWrite(async (_, cancellationToken) =>
{
using var workerScope = app.Services.CreateScope();
var seeder = (ISeeder)workerScope.ServiceProvider.GetRequiredService(type);
try
{
await seeder.Execute(workerScope.ServiceProvider, cancellationToken);
}
finally
{
mutex.ReleaseMutex();
mutex.Dispose();
}
});
}
// Allow each module to run its Configure step
foreach (var module in app.Services.GetServices<IModule>())
{
module.Configure(app);
}
// Allow registered endpoint registrars to map HTTP endpoints.
foreach (var registrar in app.Services.GetServices<IEndpointRegistrar>())
{
registrar.MapEndpoints(app);
}
return app;
}
/// <summary>
/// Registers all interfaces implemented by the specified DbContext type that derive from IPrefabDb as scoped
/// services in the dependency injection container.
/// </summary>
/// <remarks>Each interface derived from IPrefabDb that is implemented by the specified DbContext type is
/// registered as a scoped service, resolving to the same DbContext instance. This ensures that all such interfaces
/// share the same context and change tracking within a request scope.</remarks>
/// <param name="services">The IServiceCollection to add the service registrations to.</param>
/// <param name="dbContextType">The type of the DbContext whose IPrefabDb-derived interfaces will be registered. Must implement one or more
/// interfaces that derive from IPrefabDb.</param>
/// <returns>The IServiceCollection instance with the added service registrations. This enables method chaining.</returns>
public static IServiceCollection AddModuleDbInterfaces(this IServiceCollection services, Type dbContextType)
{
var dbContextInterfaces = dbContextType.GetInterfaces()
.Where(i => typeof(IPrefabDb).IsAssignableFrom(i))
//.Where(i => typeof(IPrefabDb).IsAssignableFrom(i) || i == typeof(ISagaDb) || i == typeof(IQueueDb))
.ToList();
foreach (var dbContextInterface in dbContextInterfaces)
{
// resolve module interfaces to the same DbContext instance that was registered
// via AddDbContext so that all changes are tracked by a single context
services.TryAdd(new ServiceDescriptor(
dbContextInterface,
sp => sp.GetRequiredService(dbContextType),
ServiceLifetime.Scoped));
}
return services;
}
}

31
Prefab/Module/IModule.cs Normal file
View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Builder;
namespace Prefab.Module;
/// <summary>
/// Defines a contract for modular components that can register services and configure the application pipeline within a
/// web application.
/// </summary>
/// <remarks>Implement this interface to encapsulate the registration of services and middleware required by a
/// module. This enables modular composition of application features and promotes separation of concerns.</remarks>
public interface IModule
{
/// <summary>
/// Configures and returns the specified web application builder.
/// </summary>
/// <param name="builder">The WebApplicationBuilder instance to configure. Cannot be null.</param>
/// <returns>The configured WebApplicationBuilder instance.</returns>
WebApplicationBuilder Build(WebApplicationBuilder builder);
/// <summary>
/// Configures the specified web application by adding middleware, services, or other components required for
/// application startup.
/// </summary>
/// <remarks>Call this method during application startup to apply custom configuration to the web
/// application pipeline. This method is commonly used to register middleware, endpoints, or other application
/// features before the application is run.</remarks>
/// <param name="app">The <see cref="WebApplication"/> instance to configure. Must not be null.</param>
/// <returns>The configured <see cref="WebApplication"/> instance. This is typically the same instance as the input
/// parameter, with additional configuration applied.</returns>
WebApplication Configure(WebApplication app);
}

31
Prefab/Prefab.csproj Normal file
View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- REMOVE THIS WHEN .NET 10 IS RELEASED -->
<PropertyGroup>
<NoWarn>$(NoWarn);NU1903</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0-rc.2.25502.107">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Prefab.Base\Prefab.Base.csproj" />
</ItemGroup>
</Project>

35
Prefab/Test/Detector.cs Normal file
View File

@@ -0,0 +1,35 @@
namespace Prefab.Test;
/// <summary>
/// Used for determining whether the system is under test or not.
/// </summary>
public static class Detector
{
/// <summary>
/// Static constructor for the Detector class.
/// </summary>
/// <returns>
/// None.
/// </returns>
static Detector()
{
var testAssemblyNames = new[]
{
"Microsoft.TestPlatform",
"xunit.core",
"xunit.assert",
"xunit.extensibility.core",
"xunit.extensibility.execution",
"nunit.framework"
};
SystemIsUnderTest = AppDomain.CurrentDomain.GetAssemblies()
.Any(a => testAssemblyNames.Any(t => a.FullName != null && a.FullName.StartsWith(t)));
}
/// <summary>
/// Gets a value indicating whether the code is currently running in a test.
/// </summary>
/// <returns>true if the code is running in a test; otherwise, false.</returns>
public static bool SystemIsUnderTest { get; }
}