Uma restrição de unicidade (unique constraint) no EF Core é um índice único no nosso modelo: declaramos esse índice com o atributo [Index] ou com HasIndex().IsUnique(), e o banco de dados recusa a segunda linha que repete o valor.

Os índices permitem duplicatas até que digamos o contrário, e essa é a única coisa que vale a pena saber antes de escrever qualquer uma das duas formas. As mesmas duas opções servem para uma única propriedade e para uma combinação de propriedades, e as duas produzem o mesmo objeto no banco de dados.

Para baixar o código-fonte deste artigo, você pode acessar nosso repositório no GitHub.

Configuração da API mínima

Para este artigo, vamos criar uma miniversão do nosso sistema solar dentro de uma API mínima:

app.MapPost("/planets", async (Planet planet, SolarSystemDbContext context) =>
{
    try
    {
        context.Planets.Add(planet);
        await context.SaveChangesAsync();

        return Results.Created($"/planets/{planet.Id}", planet);
    }
    catch (DbUpdateException)
    {
        return Results.BadRequest();
    }
})
.WithName("AddPlanet");

Temos um endpoint POST que retorna 201 Created se o planeta for adicionado com sucesso ao banco de dados, ou 400 Bad Request se o banco de dados rejeitar o salvamento. Capturar DbUpdateException, em vez de qualquer exceção, mantém o endpoint respondendo à falha de que este artigo trata: um segundo planeta chegando com um nome que a tabela já tem. DbUpdateException fica no namespace Microsoft.EntityFrameworkCore, então o Program.cs precisa dessa diretiva using.

As APIs mínimas são uma ótima forma de reduzir projetos de API a uma configuração mínima. Se quiser saber mais sobre elas, confira nosso artigo APIs mínimas no .NET.

Nossa API usa o SQL Server como banco de dados por meio do pacote Microsoft.EntityFrameworkCore.SqlServer (dotnet add package Microsoft.EntityFrameworkCore.SqlServer), então também vamos usar o Docker para hospedá-lo:

docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=yourStrong(!)Password" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest

Com o comando docker run, iniciamos um novo contêiner do SQL Server. É também assim que nosso projeto de testes obtém um banco de dados. Há um passo a passo completo sobre como executar o SQL Server em um contêiner a partir dos nossos testes, se quisermos ir além. No bash ou no zsh, colocamos MSSQL_SA_PASSWORD=yourStrong(!)Password entre aspas simples, em vez de duplas, já que um ! dentro de aspas duplas pode iniciar uma expansão do histórico nesses shells e interromper o comando com o erro event not found.

Usamos um banco de dados relacional real porque o provedor em memória não cria índices no banco de dados e, portanto, não impõe um índice único. A Microsoft desaconselha usá-lo como dublê de teste justamente por esse tipo de diferença.

O endpoint obtém o SolarSystemDbContext por injeção de dependência, então o contexto recebe as opções pelo construtor e expõe os planetas como um DbSet:

using Microsoft.EntityFrameworkCore;

public class SolarSystemDbContext(DbContextOptions<SolarSystemDbContext> options)
    : DbContext(options)
{
    public DbSet<Planet> Planets { get; set; }
}

Registramos o contexto no Program.cs, antes de builder.Build(), e lemos a string de conexão na configuração:

builder.Services.AddDbContext<SolarSystemDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("SolarSystemDatabase")));

A string de conexão fica no appsettings.json e aponta para o nosso contêiner. TrustServerCertificate=True está ali porque o cliente do SQL Server usa criptografia por padrão e recusa um certificado de servidor em que nossa máquina não confia, como o do contêiner:

"ConnectionStrings": {
  "SolarSystemDatabase": "Server=127.0.0.1,1433;Database=SolarSystem;User Id=sa;Password=yourStrong(!)Password;TrustServerCertificate=True"
}

Vale conhecer o comportamento padrão antes de escrever qualquer uma das formas: a documentação do EF Core sobre a unicidade de índices diz que um índice permite valores duplicados até que o marquemos como único.

Em seguida, vamos ver como adicionar restrições de unicidade à propriedade Name do planeta.

O que é uma restrição de unicidade no EF Core?

Uma restrição de unicidade impede que duas linhas tenham o mesmo valor em uma coluna e, no EF Core, configuramos essa restrição como um índice único no modelo.

Um índice permite duplicatas até que digamos o contrário. O Microsoft Learn diz isso claramente: “Por padrão, os índices não são únicos: várias linhas podem ter o(s) mesmo(s) valor(es) no conjunto de colunas do índice.” O que transforma o índice em uma restrição é marcá-lo como único.

Existem duas formas, e elas produzem o mesmo objeto no banco de dados. O atributo [Index] vai na classe de entidade, com IsUnique = true. A Fluent API chama HasIndex() e depois IsUnique(), seja em uma classe de configuração, seja diretamente em OnModelCreating().

Quem impõe a regra é o banco de dados, e não o nosso código. Depois que o índice único passa a existir, a segunda inserção com um valor duplicado falha quando chamamos SaveChangesAsync(), e é por isso que a garantia continua valendo mesmo para duas requisições que chegam no mesmo instante.

Como adicionar uma restrição de unicidade com o atributo Index?

O atributo [Index] vai na classe de entidade, e não na propriedade, porque um índice pode abranger várias propriedades.

Dentro dele, nameof(Name) indica a propriedade a indexar, e IsUnique = true transforma esse índice em uma restrição. Usar nameof em vez de um literal de string mantém a configuração ligada à propriedade, de modo que uma renomeação é detectada em tempo de compilação, e não quando o EF Core constrói o modelo.

O atributo fica no namespace Microsoft.EntityFrameworkCore, e não em System.ComponentModel.DataAnnotations, no qual ficam [Key] e [Required], então o arquivo precisa dessa diretiva using.

The Web API Production Checklist, e-book gratuito em inglês

E-book gratuito

Sua Web API está pronta para produção?

33 itens para verificar antes de implantá-la, com a correção de cada um. Um PDF gratuito de 76 páginas para .NET 10.

O e-book está em inglês.

Baixe o checklist gratuito

PDF gratuito. Um único e-mail para enviá-lo. Cancele a inscrição quando quiser.

Deixamos a chave primária de lado. Uma propriedade chamada Id já é única por convenção, e marcá-la como required obriga todo corpo de requisição a trazer um valor que cabe ao banco de dados gerar.

O atributo é a forma mais curta e atende à maioria dos modelos. Entre o que ele não consegue expressar estão um índice filtrado e colunas incluídas, e é aí que a Fluent API mostra seu valor.

Vamos decorar a classe de entidade com ele:

[Index(nameof(Name), IsUnique = true)]
public class Planet
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required double Mass { get; set; }
    public required double Radius { get; set; }
    public required double OrbitalPeriod { get; set; }
}

Mais especificamente, usamos o atributo Index para decorar a classe Planet. Passamos duas coisas para o atributo. A primeira é o nome da propriedade para a qual queremos criar um índice. Já a segunda define a propriedade IsUnique do índice como true. Dessa forma, garantimos que não teremos mais de um planeta com o mesmo nome.

Como adicionar uma restrição de unicidade com a Fluent API?

A Fluent API mantém a configuração em uma classe, e não em um atributo, o que deixa a entidade livre de detalhes de persistência.

A classe PlanetConfiguration implementa IEntityTypeConfiguration<Planet> e, dentro de Configure(), a chamada builder.HasIndex(p => p.Name).IsUnique() cria o mesmo índice único que o atributo cria.

Nada tem efeito até que a configuração seja descoberta. O método ApplyConfigurationsFromAssembly(GetType().Assembly), chamado dentro de OnModelCreating(), encontra todas as implementações de IEntityTypeConfiguration<T> no assembly, então uma nova classe de configuração não precisa de nenhum registro próprio.

Escrever as mesmas duas chamadas diretamente em OnModelCreating() tem exatamente o mesmo comportamento. Classes separadas só servem para manter o contexto legível quando o modelo passa de um punhado de entidades.

O índice ainda precisa chegar ao banco de dados. Uma mudança no modelo não é uma mudança no esquema: o que executa a instrução CREATE UNIQUE INDEX é adicionar e aplicar uma migração depois dessa alteração, e um modelo sem a migração correspondente deixa a restrição no nosso código e fora da tabela.

Vamos escrever essa configuração:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public class PlanetConfiguration : IEntityTypeConfiguration<Planet>
{
    public void Configure(EntityTypeBuilder<Planet> builder)
    {
        builder.HasIndex(p => p.Name)
            .IsUnique();
    }
}

Primeiro, criamos uma classe PlanetConfiguration que implementa a interface IEntityTypeConfiguration<TEntity>, que tem um método Configure(). Dentro dele, usamos o método HasIndex(), com o qual especificamos qual propriedade queremos indexar. Em seguida, usamos o método IsUnique() para informar ao EF Core que também queremos que o índice seja único.

O passo que coloca o índice no banco de dados é adicionar uma migração e aplicá-la, e vale a pena fazer isso assim que a configuração estiver pronta, depois do último passo abaixo.

Para saber mais sobre o EF Core, não deixe de conferir nossa série sobre Entity Framework Core.

Falta uma última coisa:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
}

Aqui, na classe SolarSystemDbContext, sobrescrevemos (override) o método OnModelCreating(). Nesse método, usamos o método ApplyConfigurationsFromAssembly() do ModelBuilder para aplicar todas as configurações do assembly atual.

Observe que podemos colocar a configuração dentro do método OnModelCreating(), e não em uma classe separada, mas, ao extrair as configurações para classes separadas, ganhamos legibilidade. O EF Core oferece a mesma escolha entre convenções, atributos e a Fluent API para relacionamentos.

Como tornar única uma combinação de propriedades?

Um índice composto único garante a unicidade da combinação, e não de cada propriedade isoladamente. Dois planetas podem ter o mesmo nome e dois podem ter o mesmo período orbital, mas nenhum par pode ter os dois valores iguais.

O atributo recebe vários nomes de propriedades, e a Fluent API recebe um tipo anônimo com as propriedades. As duas formas adicionam IsUnique, e as duas produzem um único índice sobre duas colunas.

A ordem das colunas importa. Um índice composto acelera as consultas que filtram pelas suas colunas e também as que filtram apenas pelas primeiras colunas que ele cobre, então a propriedade pela qual filtramos com mais frequência deve vir primeiro.

The Web API Production Checklist, e-book gratuito em inglês

E-book gratuito

Sua Web API está pronta para produção?

33 itens para verificar antes de implantá-la, com a correção de cada um. Um PDF gratuito de 76 páginas para .NET 10.

O e-book está em inglês.

Baixe o checklist gratuito

PDF gratuito. Um único e-mail para enviá-lo. Cancele a inscrição quando quiser.

Cada propriedade isoladamente ainda pode se repetir, e é essa parte que pega as pessoas de surpresa. Se a propriedade Name também precisar ser única por si só, isso exige um segundo índice único, de uma só coluna, que o índice composto não fornece.

Criação de índices compostos no EF Core com atributos

Os atributos são uma ferramenta poderosa que pode nos ajudar a definir índices compostos:

[Index(nameof(Name), nameof(OrbitalPeriod), IsUnique = true)]
public class Planet
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required double Mass { get; set; }
    public required double Radius { get; set; }
    public required double OrbitalPeriod { get; set; }
}

Com o atributo Index, usamos o operador nameof para especificar quais propriedades devem fazer parte do índice. No nosso caso, são Name e OrbitalPeriod. Consequentemente, com essa abordagem garantimos que haverá no máximo um planeta com os mesmos valores nessas propriedades.

Criação de índices compostos no EF Core com a Fluent API

Também podemos obter o mesmo resultado com a Fluent API:

public class PlanetConfiguration : IEntityTypeConfiguration<Planet>
{
    public void Configure(EntityTypeBuilder<Planet> builder)
    {
        builder.HasIndex(p => new
            {
                p.Name,
                p.OrbitalPeriod
            })
            .IsUnique();
    }
}

Atualizamos o método Configure() na classe PlanetConfiguration, criando um novo tipo anônimo que contém as propriedades para as quais queremos criar um índice. Além de adicionar e aplicar uma nova migração, é tudo o que precisamos fazer para criar índices compostos únicos.

Toda opção do atributo tem um equivalente na Fluent API, e algumas opções da Fluent API não têm atributo nenhum:

O que queremosAnotação de dadosFluent API
Um índice em uma propriedade[Index(nameof(Name))]HasIndex(p => p.Name)
Tornar esse índice único[Index(nameof(Name), IsUnique = true)]HasIndex(p => p.Name).IsUnique()
Um índice sobre várias propriedades[Index(nameof(Name), nameof(OrbitalPeriod))]HasIndex(p => new { p.Name, p.OrbitalPeriod })
Escolher o nome do índice no banco de dados[Index(nameof(Name), Name = "Index_Name")]HasIndex(p => p.Name).HasDatabaseName("Index_Name")
Ordenar todas as colunas em ordem decrescente[Index(nameof(Name), nameof(Mass), AllDescending = true)]HasIndex(p => new { p.Name, p.Mass }).IsDescending()
Definir a ordem coluna por coluna[Index(nameof(Name), nameof(Mass), IsDescending = new[] { false, true })]HasIndex(p => new { p.Name, p.Mass }).IsDescending(false, true)
Indexar apenas algumas linhasnão disponívelHasIndex(p => p.Name).HasFilter("[Name] IS NOT NULL")
Permitir ou não que uma coluna anulável repita nullnão disponívelHasIndex(p => p.Name).IsUnique().HasFilter(null)
Incluir colunas extras no índicenão disponívelHasIndex(p => p.Name).IncludeProperties(p => new { p.Mass })
Dois índices sobre as mesmas propriedades[Index(nameof(Name), Name = "IX_Second")]HasIndex(p => new { p.Name }, "IX_Second")

Qual é a diferença entre um índice único e uma restrição de unicidade no EF Core?

Em um banco de dados relacional, os dois dão a mesma garantia, e o EF Core modela essa garantia como um índice único. O Microsoft Learn é direto sobre qual usar: “Se você só quer impor a unicidade em uma coluna, defina um índice único em vez de uma chave alternativa.”

HasAlternateKey() é a outra opção, e ela existe para outra finalidade. Uma chave alternativa é um índice único com semântica adicional: o EF a trata como somente leitura, e uma chave estrangeira pode apontar para ela. Recorremos a ela quando outra entidade referencia a propriedade, e não apenas para proibir duplicatas.

É nos valores null que as expectativas falham. No SQL Server, o EF adiciona um filtro IS NOT NULL a um índice único sobre uma coluna anulável, então várias linhas podem conter null sem conflito. Passar HasFilter(null) remove esse filtro e faz o null se comportar como qualquer outro valor.

Vale acompanhar no diagrama abaixo: os dois pontos de entrada convergem para um único objeto no banco de dados, e tudo o que vem depois desse ponto é resposta do banco de dados, e não do nosso código.

Tanto o atributo Index quanto HasIndex com IsUnique produzem uma única migração, que cria na tabela Planets um índice único que aceita a primeira inserção e rejeita a duplicata.

Qual das duas formas escrevemos é uma questão de estilo, e o restante das práticas que seguimos ao configurar um modelo se aplica às duas da mesma forma. Nas suas orientações sobre chaves alternativas, o Microsoft Learn dá preferência ao índice único.

Conclusão

Neste artigo, vimos duas formas de aplicar restrições de unicidade a propriedades no EF Core com a abordagem code-first. Usando atributos ou a Fluent API do EF, podemos garantir a unicidade das propriedades nos nossos bancos de dados. Quer optemos pela simplicidade dos atributos, quer pelo caráter intuitivo da Fluent API, essas técnicas ajudam a modelar nossas classes de dados de forma eficaz.

Testado com .NET 10.0.10 e EF Core 10.0.12.