Construção do Modelo no EF Core: Convenções, Annotations e Fluent API
Objective
O EF Core monta o modelo a partir de três fontes de configuração: convenções que
leem suas classes, data annotations (atributos) nelas e a Fluent API em
OnModelCreating. Quando duas discordam, uma vence, e a regra nem sempre é
óbvia. Este conceito mostra a precedência inspecionando o modelo e a migration
reais, como aplicar um default a muitas propriedades sem quebrar essa
precedência, como manter a configuração em uma classe por entidade, como HasData
difere do seeding em runtime na migration gerada e quando vale gerar um modelo
compilado.
Use Cases
- Decidir onde uma regra como "nomes têm no máximo 100 caracteres" deve morar: num
atributo, no
OnModelCreatingou como default para toda string. - Explicar por que um
[MaxLength(50)]parece ser ignorado. - Quebrar um
OnModelCreatingde 400 linhas em algo revisável. - Semear linhas de lookup para que viajem junto com as migrations.
- Reduzir a latência da primeira consulta de um app com um modelo muito grande.
Deep Dive
Convenção, annotation, Fluent API: quem vence
O EF Core registra como cada pedaço de configuração foi feito, como um
ConfigurationSource: Convention, DataAnnotation ou Explicit (Fluent API).
Uma fonte só pode ser sobrescrita por outra igual ou mais forte, então a ordem é
Fluent API sobre data annotation sobre convenção. Considere esta entidade:
csharppublic class Product
{
public int Id { get; set; } // convenção: chave primária
public string Plain { get; set; } = ""; // convenção: obrigatória (string não nula)
[MaxLength(50)]
public string Annotated { get; set; } = ""; // data annotation
[MaxLength(50)]
public string Both { get; set; } = ""; // annotation e Fluent API discordam
public string? Optional { get; set; } // convenção: reference type anulável, coluna anulável
}
// OnModelCreating: modelBuilder.Entity<Product>().Property(p => p.Both).HasMaxLength(100);Ler o modelo de design-time (IDesignTimeModel, com cast para IConventionProperty)
imprime o valor e a fonte de cada faceta:
plaintextproperty maxLength required maxLength set by Id - True - Plain - True - Annotated 50 True DataAnnotation Both 100 True Explicit Optional - False -
Both vale 100: a Fluent API venceu o atributo. A anotação de nulidade do tipo C#
(string?) decidiu Optional, que é a convenção em ação.
Um default para muitas propriedades, sem perder a precedência
Repetir HasMaxLength em toda string é ruído, então há duas formas de definir um
default. Elas se comportam de modo diferente, e a diferença é uma armadilha. A
configuração pré-convenção em ConfigureConventions roda antes das convenções,
mas conta como configuração explícita:
csharpprotected override void ConfigureConventions(ModelConfigurationBuilder b)
=> b.Properties<string>().HaveMaxLength(200);Rodando a mesma entidade com ela:
plaintextAnnotated 200 Explicit <- o [MaxLength(50)] perdeu Both 100 Explicit <- uma chamada Fluent posterior ainda vence Plain 200 Explicit Optional 200 Explicit
A página oficial diz isso diretamente: a configuração pré-convenção sobrescreve todas as convenções e data annotations. Se você quer um default que as annotations ainda possam sobrescrever, registre uma model finalizing convention, que usa o convention builder e por isso nunca sobrescreve um atributo nem uma chamada Fluent:
csharpclass DefaultLength : IModelFinalizingConvention
{
public void ProcessModelFinalizing(IConventionModelBuilder model, IConventionContext<IConventionModelBuilder> ctx)
{
foreach (var p in model.Metadata.GetEntityTypes()
.SelectMany(e => e.GetDeclaredProperties())
.Where(p => p.ClrType == typeof(string)))
p.Builder.HasMaxLength(200);
}
}
// ConfigureConventions: b.Conventions.Add(_ => new DefaultLength());plaintextAnnotated 50 DataAnnotation <- o atributo sobrevive Both 100 Explicit Plain 200 Convention Optional 200 Convention
Use Properties<T>() para uma regra que precisa valer em todo lugar (todo Money
usa este converter) e uma finalizing convention para um default que exceções
podem sobrescrever. Convenções customizadas chegaram no EF Core 7.
Uma armadilha prática ao experimentar: o EF Core monta o modelo uma vez por tipo
de contexto e o guarda em cache. Minha primeira execução, com uma única classe de
contexto alternada por um argumento de construtor, imprimiu resultados idênticos
em todos os modos. Cada variante precisou da sua própria subclasse de DbContext.
Uma classe de configuração por entidade
IEntityTypeConfiguration<T> tira as chamadas Fluent do OnModelCreating, e uma
única chamada acha todas:
csharppublic class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> b)
{
b.ToTable("products");
b.Property(p => p.Sku).HasMaxLength(20);
b.HasIndex(p => p.Sku).IsUnique();
b.Property(p => p.Price).HasColumnName("unit_price");
b.Property(p => p.Name).HasMaxLength(150); // Name também tem [MaxLength(100)]
}
}
protected override void OnModelCreating(ModelBuilder mb)
=> mb.ApplyConfigurationsFromAssembly(typeof(ShopContext).Assembly);Adicionar uma migration com dotnet ef migrations add Initial mostra o resultado
combinado das três fontes. As colunas abaixo saíram do arquivo gerado:
csharpmigrationBuilder.CreateTable(
name: "products", // Fluent: ToTable
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true), // convenção: chave int
Name = table.Column<string>(type: "TEXT", maxLength: 150, nullable: false), // Fluent venceu [MaxLength(100)]
Sku = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
unit_price = table.Column<decimal>(type: "TEXT", nullable: false),
CategoryId = table.Column<int>(type: "INTEGER", nullable: false), // convenção: FK obrigatória
Note = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_products", x => x.Id);
table.ForeignKey("FK_products_Categories_CategoryId", x => x.CategoryId,
principalTable: "Categories", principalColumn: "Id",
onDelete: ReferentialAction.Cascade); // convenção: relação obrigatória faz cascade
});Repare em dois fatos do provider. Um decimal é guardado como TEXT no SQLite, e
o maxLength: 80 que o modelo carrega para uma propriedade [MaxLength(80)] nunca
chega ao CREATE TABLE do SQLite, que diz apenas TEXT. O modelo guarda o
metadado; se o banco o impõe depende do provider.
HasData versus seeding em runtime, na migration
HasData faz parte do modelo, então cai na migration. UseSeeding é código que
roda depois do Migrate(), então não cai. Os dois foram configurados no mesmo
projeto:
csharp// CategoryConfiguration: b.HasData(new Category { Id = 1, Name = "Books" }, new Category { Id = 2, Name = "Games" });
// OnConfiguring: UseSeeding(... adiciona Category { Id = 3, Name = "Added at runtime" } se faltar ...)A migration contém InsertData só para as linhas 1 e 2. Depois do Migrate() a
tabela tinha 1 Books, 2 Games e 3 Added at runtime. O conceito de CRUD e
seeding cobre o trade-off entre os dois; o ângulo de construção do modelo é que
HasData exige valores de chave explícitos e transforma cada edição nos dados em
uma nova migration.
Modelos compilados
dotnet ef dbcontext optimize gera C# que monta o modelo diretamente (cinco
arquivos para o modelo de duas entidades daqui). Você ativa com
options.UseModel(Shop.CompiledModels.ShopContextModel.Instance). Com isso ligado,
minhas consultas rodaram e o OnModelCreating não foi chamado. A orientação
oficial é usar modelos compilados só quando o startup é lento, o que significa
modelos com centenas a milhares de entity types, e lista limitações: sem global
query filters, sem lazy loading nem proxies de change tracking, e o modelo precisa
ser regenerado à mão sempre que sua definição muda. No meu teste o Migrate()
ainda executou o OnModelCreating, porque precisa do modelo de design-time.
Trade-offs
- Defaults pré-convenção vencem annotations em silêncio. Alguém adiciona
[MaxLength(50)], a coluna continua no default global e nada avisa.Prefira uma finalizing convention quando os atributos devem continuar funcionando.csharpb.Properties<string>().HaveMaxLength(200); // [MaxLength(50)] numa propriedade deixa de valer - Annotations colocam regras de persistência na entidade.
[MaxLength]e[Column]ficam legíveis ao lado da propriedade, mas acoplam uma classe de domínio ao EF Core, o que pesa no estilo DDD do conceito de mapeamento. Mantenha modelos ricos limpos e configure-os emIEntityTypeConfiguration<T>. - Convenções mudam o banco sem uma linha de código visível. Um
int CategoryIdnão nulo tornou a relação obrigatória e o delete em cascade. Confira a migration gerada, não só o C#.csharppublic int? CategoryId { get; set; } // opcional: ClientSetNull, sem ON DELETE CASCADE no DDL - Vários lugares podem configurar a mesma faceta. A última chamada explícita
vence, então um
HasMaxLengthnoOnModelCreatingdepois doApplyConfigurationsFromAssemblypode sobrescrever a classe de configuração. Mantenha cada faceta em um só lugar. - O mapeamento varia por provider.
decimalvirouTEXTno SQLite, e lá os tamanhos não são impostos. Um teste no SQLite não prova o formato da coluna no SQL Server. - Modelos compilados ficam desatualizados. Mude o modelo e esqueça de rodar
dotnet ef dbcontext optimize, e o app roda com o formato antigo. Regenere no build, e pule o recurso para modelos pequenos.
Documentation Links
- Creating and configuring a model, EF Core, Microsoft Learn
- Model bulk configuration, ConfigureConventions and custom conventions, EF Core, Microsoft Learn
- Entity properties, EF Core, Microsoft Learn
- Data seeding, EF Core, Microsoft Learn
- Compiled models, advanced performance topics, EF Core, Microsoft Learn
- EF Core tools reference, dotnet ef, Microsoft Learn
- SQLite EF Core database provider limitations, Microsoft Learn