Skip to content

Repository files navigation

efcore-complexindexes-logo

nuget .NET Context7 License: MIT .NET 10

Index support for complex type properties in EF Core migrations — the missing piece for value object-driven architectures.

EF Core 8.0 introduced complex properties, but migration tooling doesn't automatically generate indexes for these nested value objects. This NuGet package bridges that gap with a clean, fluent API for defining single-column, composite, unique, and filtered indexes directly on complex type properties — and, on PostgreSQL, expression (functional) indexes.

Why it matters:

  • Value Object Indexing: Seamlessly add database indexes to properties buried inside complex types (e.g., Person.EmailAddress.Value)
  • DDD-Friendly: Supports the Domain-Driven Design pattern of encapsulating logic in value objects without sacrificing database performance
  • Migration-Aware: Automatically generates proper CREATE INDEX and DROP INDEX operations during EF Core migrations
  • Flexible Filtering: Supports SQL WHERE clauses for filtered indexes (e.g., soft deletes)
  • Composite Indexes: Define multi-column indexes spanning both scalar and nested properties with a single, intuitive expression — with per-column ASC/DESC ordering via DbOrder.Asc/DbOrder.Desc
  • Expression Indexes (PostgreSQL): Index arbitrary SQL expressions such as lower(email) or to_tsvector('english', body) — including on plain, non-complex entities
  • Typed Expression Indexes (PostgreSQL): Write HasExpressionIndex(x => x.Email.ToLower()) and let the package translate it — property paths resolve to real columns at migration time
  • JSON Member Indexes (PostgreSQL): Index members of complex properties mapped with ToJson() — the same HasComplexIndex declaration becomes a (col ->> 'Member') expression index automatically
  • Temporal Constraints (PostgreSQL 18): Declare UNIQUE … WITHOUT OVERLAPS constraints to guarantee no two rows occupy overlapping time periods — the database enforces scheduling integrity for you
  • Exclusion Constraints (PostgreSQL): Declare EXCLUDE USING gist (… WITH =, … WITH &&) WHERE (…) constraints — filtered overlap protection (e.g. ignore soft-deleted rows), on any supported PostgreSQL version
  • SQL Server Options (SQL Server): Clustered, covering (INCLUDE), online-built, fill-factor, and data-compression index options on complex-property indexes — rendered by the stock SQL Server generator, no runtime wiring
Package NuGet Description
EFCore.ComplexIndexes nuget Core library — single-column, composite, unique, and filtered indexes on complex type properties. Works with any EF Core relational provider.
EFCore.ComplexIndexes.PostgreSQL nuget PostgreSQL extensions via Npgsql — adds GIN, GiST, BRIN, SP-GiST, and Hash index methods, operator classes, covering indexes (INCLUDE), concurrent creation, nulls-distinct control, per-column collation, storage parameters, NULLS FIRST/LAST, expression (functional) indexes (raw SQL and typed LINQ), JSON member indexes, temporal UNIQUE constraints (WITHOUT OVERLAPS), and exclusion constraints (EXCLUDE).
EFCore.ComplexIndexes.SqlServer nuget SQL Server extensions — clustered/nonclustered control, covering indexes (INCLUDE), online index builds, fill factor, sort-in-tempdb, and data compression on complex-property indexes. Rendered by the stock SQL Server generator; no runtime wiring.

Which package do I need? Install only the core package if you use SQLite or any provider where the default B-tree index type is sufficient. Add the PostgreSQL package for PostgreSQL-specific index types, expression/JSON indexes, or temporal/exclusion constraints; add the SQL Server package for clustered/covering/online/fill-factor/compression options. Both include the core automatically.


Getting started

Install and go

Everything is wired up automatically through EF Core's design-time tooling. Install the package, configure your indexes in OnModelCreating, and run dotnet ef migrations addzero additional ceremony.

Runtime wiring — the two features that need it

Almost everything is rendered into the migration at design time and applies through your provider's stock SQL generator. Two PostgreSQL features cannot be: they have no slot on EF Core's native index operation, so they are rendered when migrations are applied, by a SQL generator you opt into once.

Feature Needs UseNpgsqlComplexIndexes()
Complex-property, composite, and filtered indexes no
DbOrder.Asc/Desc sort direction no
PostgreSQL index methods (GIN, GiST, BRIN, …), operator classes, INCLUDE, concurrent creation, nulls-distinct no
Temporal UNIQUE … WITHOUT OVERLAPS constraints and temporal foreign keys no (since 5.0.2)
Exclusion (EXCLUDE) constraints no
SQL Server index options no
Expression indexesHasExpressionIndex, including typed LINQ and JSON member indexes yes
DbOrder.NullsFirst/NullsLast null ordering yes
services.AddDbContext<AppDbContext>(options =>
    options
        .UseNpgsql(connectionString)
        .UseNpgsqlComplexIndexes());   // ← expression indexes and NULLS ordering

Forgot the wiring? You will not get a silently wrong index. Indexes that need the custom generator carry a sentinel entry __requires_UseNpgsqlComplexIndexes__ in the scaffolded column list: the custom generator ignores it, and the stock generator fails loudly with that name in the error message.

Using a custom Internal Service Provider? If your application builds its own IServiceProvider and passes it to .UseInternalServiceProvider(...), EF Core prevents .UseNpgsqlComplexIndexes() from modifying services. Instead, register the generator directly on your IServiceCollection:

var provider = new ServiceCollection()
.AddEntityFrameworkNpgsql()
.AddNpgsqlComplexIndexes() // ← Add this for expression indexes
.BuildServiceProvider();

EnsureCreated, GenerateCreateScript, and the pending-changes check

Migrations go through the design-time differ, which the packages wire up automatically. Three things use the runtime differ instead and never see that wiring: Database.EnsureCreated(), Database.GenerateCreateScript(), and the pending-model-changes check Migrate() performs. Without a runtime registration they run EF's stock differ, which cannot see this package's declarations — EnsureCreated() creates the tables and silently none of the indexes, and Migrate() does not warn about a complex index that was never scaffolded. Register the differ once, next to the provider:

Provider Call
PostgreSQL UseNpgsqlComplexIndexes() — the same call as above; since 5.1.0 it registers the differ too
SQL Server UseSqlServerComplexIndexes()
Any other provider (SQLite, …) UseComplexIndexes() from the core package

With a satellite installed, call only the satellite's method: the core differ would give EnsureCreated() a schema without the satellite's features, such as exclusion constraints. Each call has a counterpart for a custom internal service provider: AddComplexIndexes(), AddNpgsqlComplexIndexes() and AddSqlServerComplexIndexes().


Core usage — any relational provider

Single-column index on a complex property

builder.ComplexProperty(x => x.EmailAddress, c =>
    c.Property(x => x.Value)
     .HasComplexIndex(isUnique: true, filter: "deleted_at IS NULL")
);

The same overloads exist on the non-generic builder, so a property configured by name works too: c.Property("Value").HasComplexIndex().

A filter may name properties instead of columns: {Property.Path} placeholders resolve to the mapped column at migrations addHasColumnName, complex members and (on PostgreSQL) ToJson() members included — so the filter and the column mapping cannot drift apart:

c.Property(x => x.Value).HasComplexIndex(isUnique: true, filter: "{DeletedAt} IS NULL");
// WHERE "deleted_at" IS NULL   (PostgreSQL)   WHERE [deleted_at] IS NULL   (SQL Server)

The resolved text is baked into the migration, so no runtime wiring is involved. Only a brace pair holding a dotted identifier path, outside a single-quoted literal, is a placeholder — '{urgent}' and '{"a": 1}' stay what they are — and one that names no property fails loudly.

A property-level declaration holds one index per property. To give the same column several differently-filtered indexes (the classic soft-delete pattern), declare them at the entity level — the selector reaches into complex properties, and each index needs its own explicit name:

builder.HasComplexIndex(x => x.EmailAddress.Value,
    isUnique: true, filter: "deleted_at IS NULL", indexName: "ux_person_email_active");
builder.HasComplexIndex(x => x.EmailAddress.Value,
    indexName: "ix_person_email_all");

Selectors also see through a value converter: for a value object mapped as one column (HasConversion(e => e.Value, v => new(v))), x => x.Email.Value resolves to that column, provided the member's type is the converter's provider type. x => x.CreatedAt.Year does not resolve, and says so.

Index names must be unique per table, and the package enforces it rather than letting the database reject the migration: reusing a name throws at the declaration, and two declarations that resolve to the same name — including a property-level and an entity-level index over one column, which share a default name — throw during dotnet ef migrations add. So does a name longer than the provider's identifier limit: PostgreSQL would otherwise truncate it to 63 bytes with a NOTICE and apply the migration cleanly, leaving the index under a name that no declaration and no constraint-violation error ever reports. Default names are checked too, since this package never truncates them.

Composite index across scalar and nested properties

builder.HasComplexCompositeIndex(
    x => new { x.Name, x.EmailAddress.Value },
    isUnique: true);

Per-column sort direction

Wrap any member in DbOrder.Desc(...) (or DbOrder.Asc(...), the default) to control its sort order. Because a wrapped member is a method call, C# requires you to name it in the anonymous type:

builder.HasComplexCompositeIndex(
    c => new { c.HybridDateTime.DateTime, Counter = DbOrder.Desc(c.HybridDateTime.Counter), c.Id },
    indexName: "IX_Commits_DateTime_Counter_Id");
// CREATE INDEX "IX_Commits_DateTime_Counter_Id" ON ... ("DateTime", "Counter" DESC, "Id");

Direction maps to EF Core's native CreateIndexOperation.IsDescending, so it is rendered by every relational provider (SQL Server, SQLite, PostgreSQL) — no extra wiring required. Re-declaring an index over the same columns updates its direction.

Markers of different kinds compose in any order; markers of the same kind do not — DbOrder.Asc(DbOrder.Desc(x.A)) is a contradiction and throws. To control where nulls sort, see null ordering (PostgreSQL only).

Reading declarations back

Every index declared through this package can be read back from the model — the finalized context.Model, or the mutable one inside OnModelCreating — so an application can enforce its own conventions instead of trusting each configuration to remember them:

// "Every unique index on a withdrawable aggregate is filtered to live rows."
var unfiltered = modelBuilder.Model.GetEntityTypes()
    .Where(IsWithdrawable)
    .SelectMany(e => e.GetComplexIndexes())
    .Where(ix => ix.IsUnique && ix.Filter is null)
    .ToList();

var byName = modelBuilder.Model.FindComplexIndex("ux_person_email_active");

GetComplexIndexes() unifies property-level, entity-level, composite and expression indexes as ComplexIndexDeclarations: parts as property paths, IsUnique, Filter, the explicit Name (null when the differ derives the default from resolved column names — those are not matched by FindComplexIndex), and for entity-level declarations the provider options. It reports what was declared; column names are resolved by the differ only. GetDeclaredComplexIndexes() leaves inherited declarations to the type that declares them. The differ reads the model through the same code, so the read model and the migration cannot disagree. PostgreSQL exclusion constraints have the same surface — see reading constraints back.

Amending declarations

The same convention can be installed rather than checked. On the mutable model, AddComplexIndexFilter ANDs a predicate onto the filter of every selected declaration — property-level and entity-level alike — and AddComplexIndex adds an entity-level declaration with the fluent API's identity rules:

// At the end of OnModelCreating, after the configurations have been applied:
foreach (var entityType in modelBuilder.Model.GetEntityTypes().Where(IsWithdrawable))
    entityType.AddComplexIndexFilter("{RevokedAt} IS NULL", ix => ix.IsUnique);

An unfiltered index gets the predicate; a filtered one gets (existing) AND (predicate); one that already carries it is left alone, so the call is safe to repeat. It amends what is declared at the time of the call, which is why it belongs after the configurations — and why the read model is still worth a check for what a later declaration might add. Exclusion constraints have AddExclusionConstraintFilter — see amending constraints.


Documentation

Provider-specific features live in their own pages:

Page Covers
PostgreSQL — indexes Index methods (GIN, GiST, BRIN, SP-GiST, Hash), operator classes, INCLUDE, expression (functional) indexes in raw SQL and typed LINQ, typed filter predicates, JSON member indexes, NULLS FIRST/LAST
PostgreSQL — temporal and exclusion constraints UNIQUE … WITHOUT OVERLAPS, temporal foreign keys (PERIOD), EXCLUDE constraints with WHERE predicates, reading and amending them, the btree_gist extension
SQL Server Clustered/nonclustered, covering (INCLUDE), online builds, fill factor, sort-in-tempdb, data compression — and the declarations SQL Server rejects outright

Working on the package itself: CONTRIBUTING.md covers the setup and the quality bar, and CLAUDE.md is the architectural record — which seam a feature must use, why the annotation flow is a whitelist, why operation ordering is load-bearing.


Changelog

CHANGELOG.md covers all three packages. Each package also carries its own, so NuGet shows package-specific history: core, PostgreSQL, SQL Server.


Contributing and project practices

Bug reports and pull requests are welcome — CONTRIBUTING.md covers the setup and the quality bar this package holds itself to. Security reports go privately through SECURITY.md.

A substantial portion of this codebase was written with AI assistance, under maintainer direction and review. CONTRIBUTING.md explains what that means in practice, and how every change is verified before it ships.


The package integrates seamlessly with EF Core's design-time tooling. Apart from the one-time UseNpgsqlComplexIndexes() call required by expression indexes and NULLS FIRST/LAST, there is no additional ceremony — just configure and migrate.

About

Index support for complex type properties in EF Core migrations — the missing piece for value object-driven architectures.

Topics

Resources

Contributing

Security policy

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages