En EF Core, una restricción única es un índice único en el modelo: la declaramos con el atributo [Index] o con HasIndex().IsUnique(), y la base de datos rechaza la segunda fila que repite el valor.

Los índices admiten duplicados hasta que indicamos lo contrario, y eso es lo único que conviene saber antes de escribir cualquiera de las dos formas. Las mismas dos opciones sirven para una sola propiedad y para una combinación de propiedades, y ambas generan el mismo objeto en la base de datos.

Para descargar el código fuente de este artículo, puedes visitar nuestro repositorio de GitHub.

Configuración de la API mínima

Para este artículo, vamos a crear una versión en miniatura de nuestro sistema solar dentro de una 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");

Tenemos un endpoint POST que devolverá 201 Created si el planeta se añadió correctamente a la base de datos, o 400 Bad Request si la base de datos rechazó el guardado. Capturar DbUpdateException, y no cualquier excepción, hace que el endpoint responda justo ante el fallo del que trata este artículo: un segundo planeta que llega con un nombre que ya existe en la tabla. DbUpdateException está en el espacio de nombres Microsoft.EntityFrameworkCore, así que Program.cs necesita esa directiva using.

Las API mínimas son una forma excelente de reducir los proyectos de API a una configuración mínima. Si quieres saber más, consulta nuestro artículo sobre API mínimas en .NET.

Nuestra API usa SQL Server como base de datos a través del paquete Microsoft.EntityFrameworkCore.SqlServer (dotnet add package Microsoft.EntityFrameworkCore.SqlServer), así que también usaremos Docker para alojarla:

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

Con el comando docker run iniciamos un nuevo contenedor de SQL Server, que es también la forma en que nuestro proyecto de pruebas obtiene una base de datos. Si queremos profundizar, hay una guía completa sobre cómo ejecutar SQL Server en un contenedor desde nuestras pruebas. En bash o zsh, en cambio, ponemos MSSQL_SA_PASSWORD=yourStrong(!)Password entre comillas simples, porque ahí un signo ! entre comillas dobles puede iniciar una expansión del historial y detener el comando con event not found.

Usamos una base de datos relacional real porque el proveedor en memoria no crea índices de base de datos, así que en él no se aplica un índice único. Microsoft desaconseja usarlo como doble de prueba precisamente por este tipo de diferencias.

El endpoint obtiene SolarSystemDbContext mediante inyección de dependencias, así que el contexto recibe sus opciones a través del constructor y expone los planetas como un DbSet:

using Microsoft.EntityFrameworkCore;

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

Lo registramos en Program.cs, antes de builder.Build(), y leemos su cadena de conexión de la configuración:

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

La cadena de conexión va en appsettings.json y apunta a nuestro contenedor. TrustServerCertificate=True está ahí porque el cliente de SQL Server usa cifrado de forma predeterminada y rechaza un certificado de servidor en el que nuestra máquina no confía, como el del contenedor:

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

Conviene conocer el comportamiento predeterminado antes de escribir cualquiera de las dos formas: la documentación de EF Core sobre la unicidad de los índices indica que un índice admite valores duplicados hasta que lo marcamos como único.

A continuación, veremos cómo añadir restricciones únicas a la propiedad Name de nuestro planeta.

¿Qué es una restricción única en EF Core?

Una restricción única impide que dos filas tengan el mismo valor en una columna, y en EF Core la configuramos como un índice único en el modelo.

Un índice admite duplicados hasta que indicamos lo contrario. Microsoft Learn lo dice claramente: “De forma predeterminada, los índices no son únicos: se permite que varias filas tengan el mismo valor (o valores) en el conjunto de columnas del índice”. Marcar el índice como único es lo que lo convierte en una restricción.

Hay dos formas y ambas generan el mismo objeto de base de datos. El atributo [Index] se coloca en la clase de entidad con IsUnique = true. Con la Fluent API se llama a HasIndex() y después a IsUnique(), ya sea en una clase de configuración o directamente en OnModelCreating().

Es la base de datos la que hace cumplir la regla, no nuestro código. Una vez que existe el índice único, la segunda inserción con un valor duplicado falla cuando llamamos a SaveChangesAsync(), y por eso la garantía se mantiene aunque lleguen dos solicitudes en el mismo instante.

¿Cómo añadir una restricción única con el atributo Index?

El atributo [Index] se coloca en la clase de entidad y no en la propiedad, porque un índice puede abarcar varias propiedades.

Dentro de él, nameof(Name) indica la propiedad que se va a indexar y IsUnique = true convierte ese índice en una restricción. Usar nameof en lugar de un literal de cadena mantiene la configuración ligada a la propiedad, de modo que un cambio de nombre se detecta en tiempo de compilación y no cuando EF Core construye el modelo.

El atributo está en el espacio de nombres Microsoft.EntityFrameworkCore, no en System.ComponentModel.DataAnnotations, donde están [Key] y [Required], así que el archivo necesita esa directiva using.

The Web API Production Checklist, ebook gratuito en inglés

Ebook gratis

¿Tu Web API está lista para producción?

33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.

El ebook está en inglés.

Descarga la checklist gratis

PDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.

La clave primaria la dejamos como está. Una propiedad llamada Id ya es única por convención, y marcarla como required obliga a que el cuerpo de cada solicitud incluya un valor que precisamente debe generar la base de datos.

El atributo es la forma más corta y basta para la mayoría de los modelos. Entre lo que no puede expresar están los índices filtrados y las columnas incluidas, y ahí es donde la Fluent API se gana su lugar.

Vamos a decorar la clase de entidad con él:

[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; }
}

En concreto, usamos el atributo Index para decorar la clase Planet. Le pasamos dos cosas al atributo. La primera es el nombre de la propiedad para la que queremos crear un índice. Por su parte, la segunda asigna el valor true a la propiedad IsUnique del índice. De esta forma, nos aseguramos de que no haya más de un planeta con el mismo nombre.

¿Cómo añadir una restricción única con la Fluent API?

La Fluent API mantiene la configuración en una clase en lugar de en un atributo, lo que deja la entidad libre de detalles de persistencia.

PlanetConfiguration implementa IEntityTypeConfiguration<Planet>, y dentro de Configure() la llamada builder.HasIndex(p => p.Name).IsUnique() crea el mismo índice único que crea el atributo.

Nada surte efecto hasta que se descubre la configuración. ApplyConfigurationsFromAssembly(GetType().Assembly), dentro de OnModelCreating(), encuentra todas las implementaciones de IEntityTypeConfiguration<T> del ensamblado, así que una nueva clase de configuración no necesita un registro propio.

Escribir esas mismas dos llamadas directamente en OnModelCreating() funciona exactamente igual. Las clases separadas solo sirven para que el contexto siga siendo legible cuando el modelo tiene más de un puñado de entidades.

El índice todavía tiene que llegar a la base de datos. Un cambio en el modelo no es un cambio en el esquema: añadir y aplicar una migración después de esta modificación es lo que ejecuta la instrucción CREATE UNIQUE INDEX, y un modelo sin la migración correspondiente deja la restricción en nuestro código, pero no en la tabla.

Vamos a escribir esa configuración:

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();
    }
}

Primero, creamos una clase PlanetConfiguration que implementa la interfaz IEntityTypeConfiguration<TEntity>, que tiene un método Configure(). Dentro de él, usamos el método HasIndex(), con el que indicamos qué propiedad queremos indexar. Después, usamos el método IsUnique() para indicarle a EF Core que además queremos que el índice sea único.

El paso que lleva el índice a la base de datos es añadir una migración y aplicarla, algo que conviene hacer en cuanto la configuración esté lista, después del último paso, que vemos a continuación.

Para saber más sobre EF Core, no dejes de consultar nuestra serie sobre Entity Framework Core.

Nos queda una última cosa por hacer:

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

Aquí, en nuestra clase SolarSystemDbContext, sobrescribimos (override) el método OnModelCreating(). En él, usamos el método ApplyConfigurationsFromAssembly() de ModelBuilder para aplicar todas las configuraciones del ensamblado actual.

Cabe señalar que podemos tener la configuración dentro del método OnModelCreating() y no en una clase aparte, pero si extraemos las configuraciones a clases separadas, conseguimos una mejor legibilidad. EF Core ofrece la misma elección entre convenciones, atributos y la Fluent API para las relaciones.

¿Cómo hacer única una combinación de propiedades?

Un índice compuesto único hace única la combinación, no cada propiedad por separado. Dos planetas pueden compartir nombre y dos pueden compartir periodo orbital, pero no puede haber dos que compartan ambos.

El atributo recibe varios nombres de propiedad y la Fluent API recibe un tipo anónimo con las propiedades. Ambos añaden IsUnique y ambos generan un único índice sobre dos columnas.

El orden de las columnas importa. Un índice compuesto acelera las consultas que filtran por sus columnas y también las que filtran solo por las primeras columnas que abarca, así que la propiedad por la que filtramos con más frecuencia debe ir primero.

The Web API Production Checklist, ebook gratuito en inglés

Ebook gratis

¿Tu Web API está lista para producción?

33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.

El ebook está en inglés.

Descarga la checklist gratis

PDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.

Cada propiedad por separado puede seguir repitiéndose, y eso es lo que sorprende a mucha gente. Si Name también tiene que ser única por sí sola, hace falta un segundo índice único de una sola columna, y el compuesto no lo proporciona.

Creación de índices compuestos en EF Core con atributos

Los atributos son una herramienta potente que puede ayudarnos a definir índices compuestos:

[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; }
}

En el atributo Index, usamos el operador nameof para indicar qué propiedades deben incluirse en el índice. En nuestro caso, son Name y OrbitalPeriod. Así, con este enfoque nos aseguramos de que haya como máximo un planeta con los mismos valores en esas propiedades.

Creación de índices compuestos en EF Core con la Fluent API

También podemos conseguir el mismo resultado con la Fluent API:

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

Actualizamos el método Configure() de la clase PlanetConfiguration y creamos un nuevo tipo anónimo con las propiedades para las que queremos crear un índice. Aparte de añadir y aplicar una nueva migración, es lo único que tenemos que hacer para crear índices compuestos únicos.

Todas las opciones del atributo tienen un equivalente en la Fluent API, y algunas opciones de la Fluent API no tienen ningún atributo equivalente:

Lo que queremosAnotación de datosFluent API
Un índice sobre una propiedad[Index(nameof(Name))]HasIndex(p => p.Name)
Hacer único ese índice[Index(nameof(Name), IsUnique = true)]HasIndex(p => p.Name).IsUnique()
Un índice sobre varias propiedades[Index(nameof(Name), nameof(OrbitalPeriod))]HasIndex(p => new { p.Name, p.OrbitalPeriod })
Elegir el nombre del índice en la base de datos[Index(nameof(Name), Name = "Index_Name")]HasIndex(p => p.Name).HasDatabaseName("Index_Name")
Ordenar todas las columnas de forma descendente[Index(nameof(Name), nameof(Mass), AllDescending = true)]HasIndex(p => new { p.Name, p.Mass }).IsDescending()
Ordenar columna por columna[Index(nameof(Name), nameof(Mass), IsDescending = new[] { false, true })]HasIndex(p => new { p.Name, p.Mass }).IsDescending(false, true)
Indexar solo algunas filasno disponibleHasIndex(p => p.Name).HasFilter("[Name] IS NOT NULL")
Permitir o no que una columna que admite null repita nullno disponibleHasIndex(p => p.Name).IsUnique().HasFilter(null)
Incluir columnas adicionales en el índiceno disponibleHasIndex(p => p.Name).IncludeProperties(p => new { p.Mass })
Dos índices sobre las mismas propiedades[Index(nameof(Name), Name = "IX_Second")]HasIndex(p => new { p.Name }, "IX_Second")

¿Qué diferencia hay entre un índice único y una restricción única en EF Core?

En una base de datos relacional, ambos ofrecen la misma garantía, y EF Core modela esa garantía como un índice único. Microsoft Learn es directo sobre cuál usar: “Si solo quieres imponer la unicidad en una columna, define un índice único en lugar de una clave alternativa”.

HasAlternateKey() es la otra opción y existe para otra tarea. Una clave alternativa es un índice único con semántica añadida: EF la trata como de solo lectura y una clave foránea puede apuntar a ella. Recurrimos a ella cuando otra entidad hace referencia a la propiedad, no solo para impedir duplicados.

Con los valores null es donde fallan las expectativas. En SQL Server, EF añade un filtro IS NOT NULL a un índice único sobre una columna que admite null, así que varias filas pueden contener null sin entrar en conflicto. Pasar HasFilter(null) elimina ese filtro y hace que null se comporte como cualquier otro valor.

En el diagrama siguiente conviene fijarse en esto: los dos puntos de entrada convergen en un único objeto de base de datos, y todo lo que ocurre a partir de ahí es respuesta de la base de datos, no de nuestro código.

Tanto el atributo Index como HasIndex con IsUnique generan una migración, que crea un índice único en la tabla Planets, el cual acepta la primera inserción y rechaza el duplicado.

Cuál de las dos formas escribamos es una cuestión de estilo, y el resto de las prácticas que seguimos al configurar un modelo se aplica igual a ambas. Microsoft Learn prefiere el índice único en sus recomendaciones sobre claves alternativas.

Conclusión

En este artículo, hemos visto dos formas de aplicar restricciones únicas a propiedades en EF Core con el enfoque code-first. Tanto con atributos como con la Fluent API de EF, podemos garantizar la unicidad de las propiedades en nuestras bases de datos. Ya optemos por la sencillez de los atributos o por lo intuitivo de la Fluent API, estas técnicas nos ayudan a modelar nuestras clases de datos de forma eficaz.

Probado con .NET 10.0.10 y EF Core 10.0.12.