Skip to main content

Entity Configuration

Entity configurations map domain aggregates to database tables using EF Core's fluent API. In PearDrop, configurations inherit from EntityTypeConfigurationBase<T> and implement IMutableTypeConfiguration.

Configuration Class Structure

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PearDrop.Database;
using PearDrop.Database.Contracts;

namespace MyApp.Module.Data.WriteModel.EntityConfigs;

/// <summary>
/// Configures the Equipment aggregate root for persistence.
/// Implements IMutableTypeConfiguration to indicate this is a write-model configuration.
/// </summary>
internal class EquipmentMutableTypeConfiguration
: EntityTypeConfigurationBase<Equipment>, IMutableTypeConfiguration
{
/// <summary>
/// Initialize with module identifier for audit/tracing.
/// </summary>
public EquipmentMutableTypeConfiguration()
: base(ModuleIdentifiers.MyModule) // e.g., "MyModule"
{
}

/// <summary>
/// Configure the Equipment entity mapping.
/// </summary>
public override void Configure(EntityTypeBuilder<Equipment> builder)
{
this.ConfigureEquipment(builder);
}

private void ConfigureEquipment(EntityTypeBuilder<Equipment> builder)
{
// Table mapping with namespace prefix
builder.ToTable($"{Constants.TablePrefix}equipment"); // e.g., "myapp_equipment"

// Primary key
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedNever(); // GUID set by domain

// Required properties
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(256);

builder.Property(e => e.Category)
.IsRequired();

// Multi-tenancy
builder.Property(e => e.TenantId)
.HasColumnName("TenantId")
.IsRequired();

// Domain events are in-memory only
builder.Ignore(e => e.DomainEvents);

// Indexes for query performance
builder.HasIndex(e => e.Category);
builder.HasIndex(e => new { e.TenantId, e.Category });
}
}

Key Pattern:

  • Inherits from EntityTypeConfigurationBase<Equipment>
  • Implements IMutableTypeConfiguration (marker interface)
  • Constructor calls base(ModuleIdentifiers.YourModule)
  • One method per aggregate (ConfigureEquipment())

Handling Private Fields (Backing Fields)

Domain aggregates often expose only public methods, with private fields for state:

// Domain aggregate
public class Equipment : Entity, IAggregateRoot
{
private EquipmentStatus _status = EquipmentStatus.Available; // Private field

public EquipmentStatus Status => _status; // Read-only property

public ResultWithError<BluQubeErrorData> UpdateStatus(EquipmentStatus newStatus)
{
// Validation logic
_status = newStatus;
// ...
}
}

Configuration maps the private field:

private void ConfigureEquipment(EntityTypeBuilder<Equipment> builder)
{
// Ignore the read-only property
builder.Ignore(e => e.Status);

// Map the private backing field to database column
builder.Property(typeof(EquipmentStatus), "_status")
.HasColumnName("Status")
.HasConversion<string>(); // Store as string in DB
}

Pattern:

  1. builder.Ignore(e => e.PropertyName) - Hide read-only property from EF
  2. builder.Property(typeof(T), "_fieldName") - Map backing field assuming constructor use
  3. HasColumnName("ColumnName") - Override database column name
  4. .HasConversion<T>() - Convert between C# and DB types (useful for enums)

Common Configuration Patterns

Required vs Optional Properties

// Required (NOT NULL in database)
builder.Property(e => e.Name)
.IsRequired();

// Optional (NULL allowed)
builder.Property(e => e.Description)
.IsRequired(false); // or just omit IsRequired()

String Length Constraints

// Max length with NVARCHAR(256)
builder.Property(e => e.Name)
.HasMaxLength(256);

// Unlimited length (NVARCHAR(MAX))
builder.Property(e => e.Description)
.HasColumnType("nvarchar(max)");

Enum Conversion

// Store enum as string (human-readable)
builder.Property(e => e.Status)
.HasConversion<string>(); // "Available", "CheckedOut", etc.

// Or store as int (compact)
builder.Property(e => e.Status)
.HasConversion<int>(); // 1, 2, 3

Default Values

// Default in C# and database
builder.Property(e => e.IsActive)
.HasDefaultValue(true);

// SQL Server function (timestamp, GUID, etc.)
builder.Property(e => e.CreatedAt)
.HasDefaultValueSql("GETUTCDATE()");

builder.Property(e => e.EventId)
.HasDefaultValueSql("NEWID()");

Relationships (Foreign Keys)

// One-to-Many: Equipment has many Checkouts
builder.HasMany<Checkout>()
.WithOne(c => c.Equipment)
.HasForeignKey(c => c.EquipmentId)
.OnDelete(DeleteBehavior.Restrict); // Prevent deletion if checkouts exist

Owned Entities (Nested Objects)

// User has multiple security tokens (owned collection)
builder.OwnsMany(u => u.SecurityTokenMappings, tokenMappings =>
{
tokenMappings.ToTable($"{Constants.TablePrefix}securityTokenMapping");
tokenMappings.HasKey(t => t.Id);
tokenMappings.Property(t => t.Id).ValueGeneratedNever();
tokenMappings.Ignore(t => t.DomainEvents);
}).UsePropertyAccessMode(PropertyAccessMode.Field);

Notes:

  • OwnsMany() creates a separate table with a foreign key back to parent
  • UsePropertyAccessMode(PropertyAccessMode.Field) accesses via private fields
  • Owned entities often use HasKey() if they have their own identity

Indexes

// Single column index
builder.HasIndex(e => e.Category);

// Composite index (multiple columns)
builder.HasIndex(e => new { e.TenantId, e.Category });

// Unique constraint
builder.HasIndex(e => e.Email)
.IsUnique();

// Filtered index (SQL Server only)
builder.HasIndex(e => e.Status)
.HasFilter("[Status] = 'Available'");

Registration in DbContext

Apply configuration in OnModelCreating():

protected override void OnModelCreaking(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("dbo");

// Apply entity configurations
modelBuilder.ApplyConfiguration(new EquipmentMutableTypeConfiguration());
modelBuilder.ApplyConfiguration(new CheckoutMutableTypeConfiguration());
modelBuilder.ApplyConfiguration(new UserMutableTypeConfiguration());
}

Testing Entity Configuration

Verify configurations are correct before migrations:

[Test]
public void EquipmentConfiguration_MapsCorrectly()
{
var builder = new ModelBuilder(SqlServerConventionSet.Instance);
var config = new EquipmentMutableTypeConfiguration();

config.Configure(builder.Entity<Equipment>());

var model = builder.Build();
var equipmentType = model.FindEntityType(typeof(Equipment));

// Assert table name
Assert.AreEqual("myapp_equipment", equipmentType.GetTableName());

// Assert primary key
Assert.IsFalse(equipmentType.FindProperty(nameof(Equipment.Id)).ValueGenerated);

// Assert required properties
Assert.IsTrue(equipmentType.FindProperty(nameof(Equipment.Name)).IsNullable == false);

// Assert foreign keys
var fk = equipmentType.GetForeignKeys().First();
Assert.AreEqual("EquipmentId", fk.Properties[0].Name);
}

Real Example: User Configuration from PearDrop Auth

The PearDrop.Auth module demonstrates complex configuration with private fields:

private void ConfigureUser(EntityTypeBuilder<User> builder)
{
builder.ToTable($"{Constants.TablePrefix}user");
builder.HasKey(u => u.Id);

// Public properties
builder.Property(u => u.Id).ValueGeneratedNever();
builder.Property(u => u.TenantId).HasColumnName("TenantId");
builder.Property(u => u.PasswordHash).IsRequired();

// Private fields with read-only properties (Ignore + Property pattern)
builder.Ignore(u => u.IsDisabled);
builder.Property(typeof(DateTime?), "_whenDisabled")
.HasColumnName("WhenDisabled");

builder.Ignore(u => u.IsVerified);
builder.Property(typeof(DateTime?), "_whenVerified")
.HasColumnName("WhenVerified");

// Domain events (in-memory only)
builder.Ignore(u => u.DomainEvents);

// Owned collections
builder.OwnsMany(u => u.AuthenticationHistories, histories =>
{
histories.ToTable($"{Constants.TablePrefix}authenticationHistory");
histories.HasKey(h => h.Id);
histories.Property(h => h.Id).ValueGeneratedNever();
histories.Ignore(h => h.DomainEvents);
}).UsePropertyAccessMode(PropertyAccessMode.Field);
}

Best Practices

Do:

  • Keep configurations focused on one aggregate
  • Use Ignore() for domain events and calculated properties
  • Map private backing fields explicitly (don't rely on conventions)
  • Add indexes for frequently queried properties or foreign keys
  • Use table name prefixes to prevent conflicts across modules
  • Add XML documentation explaining the mapping

Don't:

  • Configure multiple aggregates in one class (separate files per aggregate)
  • Assume field names match column names (always explicit)
  • Configure read-only properties without ignoring them
  • Add database logic in domain entities (configuration handles mapping)
  • Leave migration history tables in default location (specify in Module.cs)

Next Steps