Chapter 03 · Model Building: Conventions, Data Annotations, Fluent API, and Metadata
Data Annotations vs Fluent Configuration: Precedence, Maintainability, and Team Conventions
Make convention, data-annotation, and Fluent configuration precedence observable, then establish a maintainable ServiceHub mapping source-of-truth policy.
Learning outcomes
The ServiceHub team now has explicit Fluent mappings, but another developer prefers attributes because they are “closer to the property.” Both approaches are supported. The failure mode is not choosing the “wrong religion”; it is mixing conventions, annotations, and Fluent API without understanding precedence or ownership. This lesson turns configuration-source precedence into observable metadata and a maintainable team rule.
Compare convention-based, data-annotation, relational mapping attribute, and Fluent configuration with concrete ServiceHub examples.
Explain normal precedence: explicit Fluent configuration overrides annotations, and annotations override convention results.
Identify mappings that are awkward or impossible to express cleanly with attributes alone.
Detect conflicting configuration by inspecting the finalized model and migration diff rather than reading source in isolation.
Define a team policy for keeping persistence concerns near domain types or in dedicated configuration classes without ambiguity.
1. Three configuration styles solve different maintenance problems
| Source | Example | Strength | Cost/risk |
|---|---|---|---|
| Convention | Id becomes PK |
Low ceremony, common defaults | Behavior can be less obvious; provider/version conventions matter |
| Data annotation | [MaxLength(120)] |
Local and visible beside the property | Persistence attributes couple model classes to mapping concerns; limited expressiveness |
| Relational mapping attribute | [Table], [Column] |
Concise database naming near class/property | Database concerns leak into domain type; still cannot express every mapping |
| Fluent API | builder.Property(...) |
Most complete, centralized, composable |
Can become a huge OnModelCreating monolith
without organization
|
2. Data annotations can express many simple constraints
using System.ComponentModel.DataAnnotations;using System.ComponentModel.DataAnnotations.Schema;[Table("work_orders")]public sealed class WorkOrder{ [Key] [Column("work_order_id")] public int Id { get; set; } [Required] [MaxLength(120)] [Column("customer_name")] public string CustomerName { get; set; } = string.Empty; [Required] [MaxLength(400)] [Column("summary")] public string Summary { get; set; } = string.Empty; public WorkOrderPriority Priority { get; set; } public DateTimeOffset OpenedUtc { get; set; }}
Attributes are effective for simple keys, requiredness, length, names, not-mapped members, and some other mapping facets. But they do not eliminate the need for relational reasoning. An attribute is configuration input; inspect the finalized model and generated DDL to verify the provider-specific result.
3. Fluent API wins normal precedence conflicts
public sealed class WorkOrder{ public int Id { get; set; } [MaxLength(80)] [Column("customer_from_attribute")] public string CustomerName { get; set; } = string.Empty;}protected override void OnModelCreating(ModelBuilder modelBuilder){ modelBuilder.Entity<WorkOrder>() .Property(x => x.CustomerName) .HasMaxLength(120) .HasColumnName("customer_from_fluent");}
The finalized property should report max length
120 and the Fluent column name. This is not “EF
randomly ignored the attribute”; it is documented precedence.
The danger is that a reader inspecting only the entity class
sees one contract while runtime metadata follows another.
using Microsoft.EntityFrameworkCore.Metadata;var entity = db.Model.FindEntityType(typeof(WorkOrder))!;var property = entity.FindProperty(nameof(WorkOrder.CustomerName))!;var table = StoreObjectIdentifier.Table( entity.GetTableName()!, entity.GetSchema());Console.WriteLine(property.GetMaxLength()); // 120Console.WriteLine(property.GetColumnName(table)); // customer_from_fluent
4. Deliberately wrong team pattern: “attributes for most things, Fluent when tests fail”
This pattern spreads ownership across files without a rule. An
annotation says MaxLength(80), an old configuration
class says 120, and a late
OnModelCreating call says 200. The
application may run because one source wins, but migrations can
change when ordering/configuration is refactored, and reviewers
cannot tell which declaration is authoritative.
Choose an ownership rule. A domain-centric team may permit simple validation/mapping attributes but reserve relationship/provider-specific/complex mapping for configuration classes. A persistence-isolated team may ban mapping attributes and keep all EF configuration in the Data project. Either can work if conflicts are tested and code review knows where the source of truth lives.
5. Some mapping concerns naturally belong in Fluent configuration
Complex relationships, owned/complex types, many-to-many join payloads, provider-specific indexes, query filters, backing-field access, value converters/comparers, inheritance strategy, table splitting, and many advanced relational behaviors are clearer or only available through Fluent APIs. Forcing everything into attributes can distort the domain model.
public sealed class WorkOrderConfiguration : IEntityTypeConfiguration<WorkOrder>{ public void Configure(EntityTypeBuilder<WorkOrder> builder) { builder.ToTable("work_orders"); builder.Property(x => x.CustomerName) .HasColumnName("customer_name") .HasMaxLength(120) .IsRequired(); }}
6. Attributes have two meanings that are easy to blur
System.ComponentModel.DataAnnotations contains
validation-oriented attributes such as Required and
MaxLength; EF also interprets many of them as
mapping metadata.
System.ComponentModel.DataAnnotations.Schema
contains mapping-oriented attributes such as Table,
Column, and NotMapped. EF Core also
provides some EF-specific attributes such as
Precision, Unicode, or configuration
attributes in its own namespaces.
Do not assume an ASP.NET validation message and a database constraint are the same mechanism just because they originate from the same attribute. UI/model validation, EF metadata, and database enforcement are distinct layers.
7. A clean-domain policy can keep persistence configuration outside entities
public sealed class WorkOrder{ public int Id { get; set; } public string CustomerName { get; set; } = string.Empty; public string Summary { get; set; } = string.Empty; public WorkOrderPriority Priority { get; set; } public DateTimeOffset OpenedUtc { get; set; }}
public sealed class WorkOrderConfiguration : IEntityTypeConfiguration<WorkOrder>{ public void Configure(EntityTypeBuilder<WorkOrder> builder) { builder.ToTable("work_orders"); builder.HasKey(x => x.Id); builder.Property(x => x.CustomerName).HasMaxLength(120).IsRequired(); builder.Property(x => x.Summary).HasMaxLength(400).IsRequired(); }}
This reduces persistence coupling in the domain class, but it does not make EF disappear. The mapping still must align with the domain's invariants and the real database. Architecture is about explicit dependency boundaries, not pretending persistence has no influence.
8. Test model policy, not just happy-path queries
var entity = db.Model.FindEntityType(typeof(WorkOrder)) ?? throw new Exception("WorkOrder missing from model");var customer = entity.FindProperty(nameof(WorkOrder.CustomerName))!;if (customer.GetMaxLength() != 120) throw new Exception("CustomerName max length drifted.");if (customer.IsNullable) throw new Exception("CustomerName became nullable.");
These are metadata contract tests, not a substitute for database integration tests. They catch accidental configuration drift quickly. Migration tests and production-provider tests later prove DDL and engine behavior.
9. Hands-on lab: create a conflict, prove precedence, then remove ambiguity
-
Add
[MaxLength(80)]toCustomerName. - Keep Fluent
HasMaxLength(120). - Inspect runtime metadata and prove the finalized value is 120.
- Generate a throwaway migration and verify whether the conflict causes any schema change relative to the existing Fluent configuration.
- Add an attribute column name that conflicts with Fluent mapping and prove the Fluent name wins.
- Choose the course policy: plain domain classes + dedicated Fluent configuration for persistence mapping.
- Remove the conflicting attributes and rerun metadata assertions.
Check your understanding
- Which source normally wins: convention, data annotation, or Fluent API?
- Why can a mixed configuration style be risky even if EF resolves it deterministically?
- Is RequiredAttribute only a database mapping concept?
- Name two concerns better handled with Fluent API.
- What does a metadata contract test prove?
Review the answers
Fluent API normally has the highest precedence, then data annotations, then conventions.
Humans may read different declarations as authoritative, causing review/migration drift and hidden ownership.
No. It is also used by validation systems; validation, EF metadata, and database enforcement are separate layers.
Examples include complex relationships, backing-field access, value converters, query filters, inheritance mapping, provider-specific indexes, table splitting, and many advanced mappings.
It proves the finalized EF model has expected metadata; it does not prove the live database schema or provider runtime behavior matches.
10. Production judgment and bridge
Do not choose configuration style by ideology. Choose a
maintainable source of truth and make precedence visible. For
ServiceHub, this course now moves toward plain domain types plus
dedicated
IEntityTypeConfiguration<T> classes.
Attributes may still appear later when they are the clearest
option, but conflicting duplicate configuration is treated as a
defect.
Lesson 4 explores a place where domain encapsulation and persistence mapping directly interact: ignored members, backing fields, field-only properties, and property access modes.
Authoritative references
- Creating and Configuring a Model — conventions, data annotations, Fluent API, configuration precedence, and grouping
- Entity Properties — property annotations/Fluent configuration and facets
- Entity Types — table mapping, ignoring types, and relational annotations
- IEntityTypeConfiguration<TEntity> — dedicated Fluent configuration contract
- ModelBuilder.ApplyConfiguration — explicit configuration-class application