Skip to content

Commit

Permalink
Simplify DbContext by extracting aggregate configuration into dedicat…
Browse files Browse the repository at this point in the history
…ed classes (#676)

### Summary & Motivation

Remove the need for centralized configuration in the `DbContext` when
defining aggregates in a self-contained system. Previously, the
`DbContext` required a `DbSet<T>` property for each aggregate (table)
and explicit configuration in `OnModelCreating` for strongly typed IDs
and other EF configurations like `.HasOne<T>()`, `.WithMany()`,
`.OnDelete()`, `.ToJson()`, etc. Over time, this made the `DbContext` in
a self-contained system big and hard to maintain

With this change, all entity configurations are moved to dedicated
classes implementing `IEntityTypeConfiguration<T>`, keeping each
aggregate's configuration within its vertical slice. The
`SharedKernelDbContext` now automatically discovers and registers these
configurations, eliminating the need for central configuration.

Additionally, the `DbContext` now uses a convention-based approach to
create tables, leveraging Humanizer for pluralizing table names. This
removes the need to define `DbSet<T>` properties explicitly, making the
`DbContext` a lightweight class used only in repositories without
requiring customization for new aggregates.

### Downstream Projects

Extract an `AggregateConfiguration` file for each aggregate, where the
content should match the current configuration in `OnModelCreating` in
the self-contained system’s `DbContext`. Example:

```csharp
namespace PlatformPlatform.AccountManagement.Features.Signups.Domain;

public sealed class SignupConfiguration : IEntityTypeConfiguration<Signup>
{
    public void Configure(EntityTypeBuilder<Signup> builder)
    {
        builder.MapStronglyTypedUuid<Signup, SignupId>(a => a.Id);
        builder.MapStronglyTypedNullableId<Signup, TenantId, string>(u => u.TenantId);
    }
}
```

All properties for tables should be removed from DbContext. Any direct
usage of these should be replaced by DbSet<T> in the repository.
Example:

```csharp
// Before:
return accountManagementDbContext.Signups.Where(s => ...);

// After:
return DbSet.Where(s => ...);
```

### Checklist

- [x] I have added tests, or done manual regression tests
- [x] I have updated the documentation, if necessary
  • Loading branch information
tjementum authored Jan 19, 2025
2 parents 9bbb91b + 4a6c05a commit 92889b8
Show file tree
Hide file tree
Showing 11 changed files with 92 additions and 57 deletions.
1 change: 1 addition & 0 deletions application/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<PackageVersion Include="Bogus" Version="35.6.1" />
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
<PackageVersion Include="Humanizer.Core" Version="2.14.1" />
<PackageVersion Include="IdGen" Version="3.0.7" />
<PackageVersion Include="JetBrains.Annotations" Version="2024.3.0" />
<PackageVersion Include="Mapster" Version="7.4.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,49 +1,8 @@
using Microsoft.EntityFrameworkCore;
using PlatformPlatform.AccountManagement.Features.Authentication.Domain;
using PlatformPlatform.AccountManagement.Features.Signups.Domain;
using PlatformPlatform.AccountManagement.Features.Tenants.Domain;
using PlatformPlatform.AccountManagement.Features.Users.Domain;
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.EntityFramework;
using PlatformPlatform.SharedKernel.ExecutionContext;

namespace PlatformPlatform.AccountManagement.Database;

public sealed class AccountManagementDbContext(DbContextOptions<AccountManagementDbContext> options, IExecutionContext executionContext)
: SharedKernelDbContext<AccountManagementDbContext>(options, executionContext)
{
public DbSet<Login> Logins => Set<Login>();

public DbSet<Signup> Signups => Set<Signup>();

public DbSet<Tenant> Tenants => Set<Tenant>();

public DbSet<User> Users => Set<User>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);

// Login
modelBuilder.MapStronglyTypedId<Login, LoginId, string>(t => t.Id);
modelBuilder.MapStronglyTypedId<Login, TenantId, string>(u => u.TenantId);
modelBuilder.MapStronglyTypedUuid<Login, UserId>(u => u.UserId);

// Signup
modelBuilder.MapStronglyTypedUuid<Signup, SignupId>(a => a.Id);
modelBuilder.MapStronglyTypedNullableId<Signup, TenantId, string>(u => u.TenantId);

// Tenant
modelBuilder.MapStronglyTypedId<Tenant, TenantId, string>(t => t.Id);

// User
modelBuilder.MapStronglyTypedUuid<User, UserId>(u => u.Id);
modelBuilder.MapStronglyTypedId<User, TenantId, string>(u => u.TenantId);
modelBuilder.Entity<User>()
.OwnsOne(e => e.Avatar, b => b.ToJson())
.HasOne<Tenant>()
.WithMany()
.HasForeignKey(u => u.TenantId)
.HasPrincipalKey(t => t.Id);
}
}
: SharedKernelDbContext<AccountManagementDbContext>(options, executionContext);
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.EntityFramework;

namespace PlatformPlatform.AccountManagement.Features.Authentication.Domain;

public sealed class LoginConfiguration : IEntityTypeConfiguration<Login>
{
public void Configure(EntityTypeBuilder<Login> builder)
{
builder.MapStronglyTypedId<Login, LoginId, string>(t => t.Id);
builder.MapStronglyTypedId<Login, TenantId, string>(u => u.TenantId);
builder.MapStronglyTypedUuid<Login, UserId>(u => u.UserId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.EntityFramework;

namespace PlatformPlatform.AccountManagement.Features.Signups.Domain;

public sealed class SignupConfiguration : IEntityTypeConfiguration<Signup>
{
public void Configure(EntityTypeBuilder<Signup> builder)
{
builder.MapStronglyTypedUuid<Signup, SignupId>(a => a.Id);
builder.MapStronglyTypedNullableId<Signup, TenantId, string>(u => u.TenantId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public sealed class SignupRepository(AccountManagementDbContext accountManagemen
{
public Signup[] GetByEmailOrTenantId(TenantId tenantId, string email)
{
return accountManagementDbContext.Signups
return DbSet
.Where(r => !r.Completed)
.Where(r => r.TenantId == tenantId || r.Email == email.ToLowerInvariant())
.ToArray();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.EntityFramework;

namespace PlatformPlatform.AccountManagement.Features.Tenants.Domain;

public sealed class TenantConfiguration : IEntityTypeConfiguration<Tenant>
{
public void Configure(EntityTypeBuilder<Tenant> builder)
{
builder.MapStronglyTypedId<Tenant, TenantId, string>(t => t.Id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PlatformPlatform.AccountManagement.Features.Tenants.Domain;
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.EntityFramework;

namespace PlatformPlatform.AccountManagement.Features.Users.Domain;

public sealed class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.MapStronglyTypedUuid<User, UserId>(u => u.Id);
builder.MapStronglyTypedId<User, TenantId, string>(u => u.TenantId);
builder
.OwnsOne(e => e.Avatar, b => b.ToJson())
.HasOne<Tenant>()
.WithMany()
.HasForeignKey(u => u.TenantId)
.HasPrincipalKey(t => t.Id);
}
}
4 changes: 2 additions & 2 deletions application/account-management/Tests/DatabaseSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ public sealed class DatabaseSeeder
public DatabaseSeeder(AccountManagementDbContext accountManagementDbContext)
{
Tenant1 = Tenant.Create(new TenantId("tenant-1"), "[email protected]");
accountManagementDbContext.Tenants.AddRange(Tenant1);
accountManagementDbContext.Set<Tenant>().AddRange(Tenant1);
User1 = User.Create(Tenant1.Id, "[email protected]", UserRole.Owner, true, null);
accountManagementDbContext.Users.AddRange(User1);
accountManagementDbContext.Set<User>().AddRange(User1);

accountManagementDbContext.SaveChanges();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using PlatformPlatform.SharedKernel.StronglyTypedIds;

Expand All @@ -11,37 +12,34 @@ public static class ModelBuilderExtensions
/// This method is used to tell Entity Framework how to map a strongly typed ID to a SQL column using the
/// underlying type of the strongly-typed ID.
/// </summary>
public static void MapStronglyTypedLongId<T, TId>(this ModelBuilder modelBuilder, Expression<Func<T, TId>> expression)
public static void MapStronglyTypedLongId<T, TId>(this EntityTypeBuilder<T> builder, Expression<Func<T, TId>> expression)
where T : class where TId : StronglyTypedLongId<TId>
{
modelBuilder
.Entity<T>()
builder
.Property(expression)
.HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!);
}

public static void MapStronglyTypedUuid<T, TId>(this ModelBuilder modelBuilder, Expression<Func<T, TId>> expression)
public static void MapStronglyTypedUuid<T, TId>(this EntityTypeBuilder<T> builder, Expression<Func<T, TId>> expression)
where T : class where TId : StronglyTypedUlid<TId>
{
modelBuilder
.Entity<T>()
builder
.Property(expression)
.HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!);
}

public static void MapStronglyTypedId<T, TId, TValue>(this ModelBuilder modelBuilder, Expression<Func<T, TId>> expression)
public static void MapStronglyTypedId<T, TId, TValue>(this EntityTypeBuilder<T> builder, Expression<Func<T, TId>> expression)
where T : class
where TValue : IComparable<TValue>
where TId : StronglyTypedId<TValue, TId>
{
modelBuilder
.Entity<T>()
builder
.Property(expression)
.HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!);
}

public static void MapStronglyTypedNullableId<T, TId, TValue>(
this ModelBuilder modelBuilder,
this EntityTypeBuilder<T> builder,
Expression<Func<T, TId?>> idExpression
)
where T : class
Expand All @@ -54,8 +52,7 @@ public static void MapStronglyTypedNullableId<T, TId, TValue>(
var idCoalesceExpression =
Expression.Lambda<Func<TId, TValue>>(Expression.Coalesce(idValueProperty, nullConstant), idParameter);

modelBuilder
.Entity<T>()
builder
.Property(idExpression)
.HasConversion(idCoalesceExpression!, v => Activator.CreateInstance(typeof(TId), v) as TId);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Linq.Expressions;
using Humanizer;
using Microsoft.EntityFrameworkCore;
using PlatformPlatform.SharedKernel.Domain;
using PlatformPlatform.SharedKernel.ExecutionContext;
Expand All @@ -24,6 +25,15 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TContext).Assembly);

// Set pluralized table names for all aggregates
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var tableName = entityType.GetTableName()!.Pluralize();
entityType.SetTableName(tableName);
}

// Ensures that all enum properties are stored as strings in the database.
modelBuilder.UseStringForEnums();

Expand Down
1 change: 1 addition & 0 deletions application/shared-kernel/SharedKernel/SharedKernel.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<PackageReference Include="Azure.Security.KeyVault.Keys" />
<PackageReference Include="Azure.Security.KeyVault.Secrets" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="Humanizer.Core" />
<PackageReference Include="IdGen" />
<PackageReference Include="JetBrains.Annotations" />
<PackageReference Include="Mapster" />
Expand Down

0 comments on commit 92889b8

Please sign in to comment.