
Enforce Database Schema Standards Automatically With EF Core Conventions
Author - Abdul Rahman (Bhai)
EFCore
3 Articles
Table of Contents
What we gonna do?
Ask three developers on the same team to add a new entity to an EF Core model, and you'll often get three different table names, three different string column lengths, and at least one forgotten cascade-delete rule. EF Core conventions let you bake those decisions into the model-building pipeline itself, so every entity gets the same treatment whether or not the developer adding it remembers the team's standards.
A convention is a class that implements IConvention and runs automatically while EF Core builds your model. Instead of writing the same Fluent API configuration on every entity, you write the rule once and EF Core applies it everywhere it's relevant.
Why we gonna do?
By default, EF Core names a table after its DbSet property, leaves string columns at whatever the provider's maximum is, and does nothing special with enums or delete behavior unless you configure each entity individually. On a small model that's fine. On a model with sixty entities added by a rotating cast of contributors, it isn't.
A concrete example: a Product entity with a Name and Category property compiles perfectly well without any Fluent configuration. But without an explicit HasMaxLength, Name becomes an unbounded nvarchar(max) column, Category gets stored as a raw integer that means nothing when you query the database directly, and the foreign key to Order has whatever default delete behavior the provider picked. Multiply that by every entity added over two years by a team that never wrote it down anywhere, and your schema drifts entity by entity until nobody can describe the "standard" anymore.
Conventions turn those unwritten rules into code that runs for every entity, every time, with no code review required to catch the ones that slipped through.
How we gonna do?
How a convention plugs into model building
Most schema-wide rules implement IModelFinalizingConvention, which EF Core calls once the model is fully defined but before it's frozen. You register conventions in ConfigureConventions, and because that method wants a factory delegate rather than an instance, you typically loop over an injected collection and hand back a delegate for each one:
public class OrderContext(
DbContextOptions<OrderContext> options,
IEnumerable<IConvention> conventions) : DbContext(options)
{
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
foreach (var convention in conventions)
{
configurationBuilder.Conventions.Add(_ => convention);
}
}
}
You're not limited to adding conventions - you can also remove one of EF Core's built-in conventions if you want to replace its behavior entirely, for example configurationBuilder.Conventions.Remove(typeof(TableNameFromDbSetConvention)) if the naming convention below should be the only source of truth for table names.
Standardize table names and schema
This convention gives every entity a lowercase table name inside a dedicated schema, unless a developer has already configured one explicitly with the Fluent API or a data annotation - in which case their choice wins:
public class TableNamingConvention : IModelFinalizingConvention
{
private const string Schema = "ordering";
public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
{
foreach (var entityType in modelBuilder.Metadata.GetEntityTypes())
{
if (entityType.Builder.CanSetTable(entityType.Name))
{
var tableName = entityType.GetTableName() ?? entityType.Name;
var name = tableName.ToLowerInvariant();
entityType.Builder.ToTable(name, "cake");
}
}
}
}
Cap default string lengths
Any string property without an explicit HasMaxLength falls back to a sensible default instead of an unbounded column:
public class DefaultStringLengthConvention : IModelFinalizingConvention
{
private const int DefaultMaxLength = 200;
public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
{
foreach (var entityType in modelBuilder.Metadata.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
if (property.ClrType == typeof(string) && property.GetMaxLength() is null)
{
property.SetMaxLength(DefaultMaxLength);
}
}
}
}
}
Default every foreign key to cascade delete
Rather than relying on every developer to remember OnDelete on every relationship, an IForeignKeyAddedConvention sets the default the moment a relationship is added to the model:
public class CascadeDeleteConvention : IForeignKeyAddedConvention
{
public void ProcessForeignKeyAdded(
IConventionForeignKeyBuilder foreignKeyBuilder,
IConventionContext<IConventionForeignKeyBuilder> context)
{
foreignKeyBuilder.OnDelete(DeleteBehavior.Cascade);
}
}
Individual relationships can still override this with their own Fluent configuration - the convention only sets the default, it doesn't lock the choice.
Store enums as readable strings, not raw integers
Querying a database directly and seeing Category = 2 instead of Category = 'Electronics' makes ad-hoc debugging painful. A materialization convention converts every enum property to a string automatically:
public class EnumAsStringConvention : IModelFinalizingConvention
{
public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
{
foreach (var entityType in modelBuilder.Metadata.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
var underlyingType = Nullable.GetUnderlyingType(property.ClrType) ?? property.ClrType;
if (!underlyingType.IsEnum)
{
continue;
}
// Instead of 1 for OrderStatus.Processing, it will serialize it as "Processing".
property.Builder.HasConversion(typeof(string));
property.Builder.HasMaxLength(64);
}
}
}
}
Register the conventions and generate a migration
Register each convention the same way you registered interceptors, and add them all as scoped services alongside your context:
builder.Services.AddScoped<IConvention, TableNamingConvention>();
builder.Services.AddScoped<IConvention, DefaultStringLengthConvention>();
builder.Services.AddScoped<IConvention, CascadeDeleteConvention>();
builder.Services.AddScoped<IConvention, EnumAsStringConvention>();
Because these conventions change how the model is built, EF Core will report pending model changes the next time you run dotnet ef migrations has-pending-model-changes, even though you didn't touch a single entity class. Generate one migration to apply all four standards across the whole model at once:
dotnet ef migrations add ApplySchemaConventions
dotnet ef database update
Summary
Key takeaways from this article:
- Conventions implement IConvention and run automatically while EF Core builds the model, so schema standards apply to every entity without manual Fluent configuration
- IModelFinalizingConvention is the workhorse for schema-wide rules like table naming, string length defaults, and enum storage
- A dedicated convention interface like IForeignKeyAddedConvention lets you react the moment a specific kind of model element is added
- You can remove or replace any of EF Core's built-in conventions the same way you add your own
- Convention changes affect the model, so they need a migration just like any other schema change
What to read next: pair these schema-wide guardrails with Add Cross-Cutting Concerns to EF Core Without Touching Your Repositories for auditing and connection diagnostics, or go a level deeper with Catch Slow Queries and Rewrite Them on the Fly With EF Core Command Interceptors to see how EF Core's generated SQL can be inspected and rewritten at runtime.