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:
builder.Ignore(e => e.PropertyName)- Hide read-only property from EFbuilder.Property(typeof(T), "_fieldName")- Map backing field assuming constructor useHasColumnName("ColumnName")- Override database column name.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 parentUsePropertyAccessMode(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
- Write Model & DbContext - Register configurations
- Read Models - Separate read projections
- Creating Migrations - Generate schema from configurations