Start Debugging

Как правильно использовать records с EF Core 11

Практическое руководство по сочетанию records C# и EF Core 11. Где records подходят, где они ломают change tracking, и как моделировать value objects, сущности и проекции, не воюя с фреймворком.

Короткий ответ: на EF Core 11 и C# 14 используйте record class для проекций, DTO и комплексных типов (value objects), а для отслеживаемых сущностей предпочитайте обычный class с init-only свойствами и связывающим конструктором. record struct нормален как комплексный тип, но никогда как отслеживаемая сущность. Трение, в которое попадают люди, почти всегда возникает из-за попыток использовать позиционные records как полноценные сущности и удивления, когда with-выражения, равенство по значению или read-only первичные ключи сталкиваются с identity tracking EF Core. Решение - не настройка, а понимание, какая форма record на каком кресле сидит.

Эта статья охватывает три кресла (сущность, комплексный тип, проекция), показывает правила связывания конструктора, реально присутствующие в EF Core 11, и проходит через специфические подводные камни, на которых спотыкаются: store-generated ключи, выражение with, навигационные свойства, ловушки равенства по значению и records, замапленные в JSON.

Почему у records и EF Core репутация конфликтующих

Records C# были спроектированы так, чтобы упростить неизменяемые типы данных с равенством по значению. Два экземпляра record Address(string City, string Zip) равны, когда равны их поля, а не когда это одна и та же ссылка. Это и есть правильная семантика для value object.

Change tracker EF Core построен на противоположном предположении. ChangeTracker хранит снапшот значений свойств каждой сущности при первом её прикреплении, а identity resolution утверждает, что в одном DbContext ровно один CLR-экземпляр на первичный ключ. Оба полагаются на ссылочную идентичность, а не на идентичность по значению. Если вы штампуете record первичным ключом, а потом мутируете его, создавая новый экземпляр через with, у вас две CLR-ссылки, которые сравниваются как равные, но не одна и та же отслеживаемая сущность. Change tracker либо бросает, потому что PK уже отслеживается, либо молча игнорирует ваши правки.

Официальная документация C# уже годами говорит, что «record-типы не подходят для использования в качестве entity types в Entity Framework Core». Это предупреждение - грубое резюме описанной выше ситуации, а не жёсткий запрет. Records можно использовать как сущности, и EF Core 11 по-прежнему поддерживает все механизмы для этого. Просто нужно выбрать непозиционную, init-only форму и играть по правилам связывания конструктора в документации по конструкторам EF Core.

Кресло 1: records как комплексные типы (sweet spot)

EF Core 8 ввёл ComplexProperty, а EF Core 11 сделал комплексные типы достаточно стабильными, чтобы рекомендовать их как замену owned entities по умолчанию в большинстве случаев. Комплексные типы - именно там, где records блистают: у комплексного типа нет собственной идентичности, его равенство по значению совпадает с семантикой базы, и он рассчитан на полную замену при изменении любого поля.

// .NET 11, C# 14, EF Core 11
public record Address(string Street, string City, string PostalCode);

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public Address ShippingAddress { get; set; } = new("", "", "");
    public Address BillingAddress { get; set; } = new("", "", "");
}

// OnModelCreating
modelBuilder.Entity<Customer>(b =>
{
    b.ComplexProperty(c => c.ShippingAddress);
    b.ComplexProperty(c => c.BillingAddress);
});

Что заставляет это работать:

Если нужен значимый тип, record struct тоже валиден для комплексного свойства и избегает дополнительной кучной аллокации на строку. Компромисс обычный: большие наборы полей дороги при копировании, и вы теряете возможность легко добавить безпараметровый конструктор для конвенций EF.

// .NET 11, C# 14
public readonly record struct Money(decimal Amount, string Currency);

Используйте record struct для маленьких значений с фиксированной формой (деньги, координаты, диапазоны дат). Для всего остального - record class.

Кресло 2: records как сущности (работает, но требует дисциплины)

Если хочется внешне неизменяемой сущности, форма, выживающая в change tracking, - это record class с непозиционными init-only свойствами и связывающим конструктором, который EF Core может вызвать при материализации.

// .NET 11, C# 14, EF Core 11
public record class BlogPost
{
    // EF binds to this ctor during materialization
    public BlogPost(int id, string title, DateTime publishedAt)
    {
        Id = id;
        Title = title;
        PublishedAt = publishedAt;
    }

    // Parameterless ctor lets EF (and serializers) create instances
    // before setting properties one at a time when needed.
    private BlogPost() { }

    public int Id { get; init; }
    public string Title { get; init; } = "";
    public DateTime PublishedAt { get; init; }

    // Navigation props cannot be bound via constructor.
    public List<Comment> Comments { get; init; } = new();
}

Правила из документации по связыванию конструктора, применённые к records:

  1. Если EF Core находит конструктор, имена и типы параметров которого совпадают с замапленными свойствами, он использует этот конструктор при материализации. Свойства в Pascal-case могут совпадать с параметрами в camel-case.
  2. Навигационные свойства (коллекции, ссылки) нельзя связать через конструктор. Держите их вне первичного конструктора и инициализируйте дефолтом.
  3. Свойства без сеттера по конвенции не маппятся. init считается сеттером, поэтому init-only свойства маппятся. Свойство, объявленное как public string Title { get; } без сеттера вообще, считается вычислимым и пропускается.
  4. Store-generated ключи требуют записываемого ключа. init записываем во время инициализации объекта - это как раз то, когда EF Core ставит значение, поэтому int Id { get; init; } работает для store-generated identity-колонок.

Почему не использовать позиционный record для самой сущности? Две причины.

Во-первых, у позиционного record есть неявный сгенерированный компилятором набор свойств с init-сеттерами, но также защищённый метод <Clone>$ и копирующий конструктор, которым пользуются with-выражения. В момент post with { Title = "New title" } вы получаете совершенно новый экземпляр BlogPost с тем же первичным ключом, что и у отслеживаемого. Если попробовать context.Update(newPost), упадёт InvalidOperationException: The instance of entity type 'BlogPost' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked. Identity resolution делает свою работу: вы дали ей две ссылки на то, что она считает одной и той же строкой.

Во-вторых, позиционные records генерируют Equals и GetHashCode на основе значения. Change tracker EF Core, fixup отношений и DbSet.Find опираются на ссылочную идентичность. Равенство по значению не ломает их напрочь, но создаёт удивительные эффекты: две свежезагруженные сущности из разных запросов могут оказаться hash-равны, будучи разными отслеживаемыми экземплярами, и HashSet<BlogPost> их схлопнет. Держите равенство по значению подальше от того, у чего есть идентичность.

Record class с явными свойствами, как выше, обходит обе ловушки. Вы получаете неизменяемость и приятный ToString, и отказываетесь от мутаций через with (которые на отслеживаемой сущности и не нужны).

Обновление сущности в иммутабельном стиле

Поскольку сущность «неизменяемая», путь обновления не может быть «мутировал, потом SaveChanges». Два рабочих паттерна на EF Core 11:

// .NET 11, EF Core 11
// Pattern A: load, assign to a local with init setters cleared.
// Requires exposing init setters on the class.
var post = await db.BlogPosts.SingleAsync(p => p.Id == id);

// This mutates the tracked instance. Works because 'init' is
// a settable accessor from EF Core's point of view, and nothing
// stops you from assigning through reflection or source-gen.
// If you want real immutability, use Pattern B.
db.Entry(post).Property(p => p.Title).CurrentValue = "New title";
await db.SaveChangesAsync();

// Pattern B: detach the old, attach a freshly-constructed one,
// mark the touched columns modified. No 'with' expression.
var updated = new BlogPost(post.Id, "New title", post.PublishedAt);
db.Entry(post).State = EntityState.Detached;
db.Attach(updated);
db.Entry(updated).Property(p => p.Title).IsModified = true;
await db.SaveChangesAsync();

Паттерн A - то, к чему обычно приходит большинство команд: используют records ради эргономичного ToString, деконструкции и пофилдового равенства на чтении, и принимают, что путь записи идёт через change tracker, мутирующий init-свойства через метаданные EF Core. Это не нарушение неизменяемости на уровне языка, это просто способ EF Core связывать свойства. Есть давний issue в EF Core, отслеживающий первоклассную поддержку неизменяемых обновлений (efcore#11457), если хотите полную картину.

Кресло 3: records как проекции и DTO (всегда безопасно)

Каждый раз, когда record материализуется вне change tracker, ни одна из проблем выше не применима. Проекции на records - самый скучный и самый полезный паттерн:

// .NET 11, C# 14, EF Core 11
public record PostSummary(int Id, string Title, DateTime PublishedAt);

// No tracking, no identity, no ChangeTracker snapshot.
var summaries = await db.BlogPosts
    .AsNoTracking()
    .Select(p => new PostSummary(p.Id, p.Title, p.PublishedAt))
    .ToListAsync();

Пайплайн запросов EF Core 11 спокойно связывается с позиционными records в проекциях. Их можно отдавать прямо из веб-API через System.Text.Json, поддерживающий сериализацию records с .NET 5 и десериализацию позиционных records с .NET 7.

То же касается входных DTO для команд: примите позиционный record из контроллера, валидируйте, замапьте на форму сущности выше и дайте EF Core отслеживать сущность. Раздельные тип на проводе (record) и тип в персистенции (class с init) убирают всю категорию багов, которым посвящена эта статья.

Подробнее про records как формы возврата см. таблицу решений в конце статьи о множественных значениях.

Store-generated ключи и init-only свойства

Это самое частое место, где люди застревают. Если Id объявлен как public int Id { get; } без сеттера, EF Core его не замаппит, и миграции будут жаловаться на отсутствующий ключ. Если же public int Id { get; init; }, он замаплен и записываем во время инициализации объекта - это как раз когда EF Core ставит значение, прочитанное из базы.

Для inserts EF Core также нужно записать сгенерированное значение обратно в сущность после SaveChanges. Делается это через сеттер свойства, который для init-only свойств всё ещё работает, потому что EF Core использует метаданные доступа к свойству, а не публичный синтаксис C#. Подтверждено в EF Core 11; стабильно с EF Core 5.

Что не работает: public int Id { get; } = GetNextId(); с инициализатором поля и без сеттера. EF Core не видит сеттер, не маппит свойство, и вы получаете либо ошибку сборки про недостающий ключ, либо непредусмотренный shadow key.

Выражение with - выстрел в ногу для отслеживаемых сущностей

Когда сущность - record (позиционный или нет) с копированием, сгенерированным первичным конструктором, with производит клон, равный оригиналу, но другой CLR-ссылки. EF Core воспринимает это как «тот же ключ, другой экземпляр», что запускает identity resolution. Безопасное правило:

// .NET 11, EF Core 11
// BAD: creates a second instance with the same PK.
var edited = post with { Title = "New" };
db.Update(edited); // throws InvalidOperationException on SaveChanges

// GOOD: mutate the tracked instance.
post.Title = "New"; // via init (within EF) or a regular setter
await db.SaveChangesAsync();

Если вам действительно нужна семантика «detach, clone, re-attach», сначала пройдите через db.Entry(post).State = EntityState.Detached;, потом приатачьте клон и пометьте свойства как IsModified. Чаще всего вам это не нужно. Вам нужен Паттерн A из предыдущего раздела.

У комплексных типов такой проблемы нет. with на Address внутри Customer производит новое значение, вы присваиваете его обратно в customer.ShippingAddress, и EF Core сравнивает поле за полем со снапшотом. В этом и весь смысл комплексных типов.

Равенство по значению против идентичности на горячих путях

Если настаиваете на сущности-позиционном record, помните, что равенство по значению просачивается во все коллекции, опирающиеся на GetHashCode. HashSet<BlogPost> схлопнет «две разные сущности с одинаковыми данными». Словарь, ключ которого - сущность, ведёт себя непредсказуемо, если у двух разных PK совпадают payload. Стандартный обход - переопределить Equals и GetHashCode у record так, чтобы ключевать только по первичному ключу, что обнуляет всю причину выбора record.

Сам change tracker, начиная с EF Core 11, по-прежнему использует ссылочную идентичность внутри. Подробности можно посмотреть в исходниках change-tracking, но коротко: EF Core не «сливает» две сущности случайно лишь потому, что они равны по значению. Однако такое слияние всплывает через DbSet.Find, FirstOrDefault на отслеживаемом запросе и fixup отношений - именно поэтому команды видят странности, которые не могут сразу объяснить.

И снова: исправление - не спорить с рантаймом. Это держать равенство по значению на значимых типах (комплексных, DTO) и оставлять типы сущностей с дефолтным ссылочным равенством.

JSON-колонки и records

EF Core 7 добавил маппинг JSON-колонок, а EF Core 11 расширяет это трансляцией JSON_CONTAINS на SQL Server 2025 и комплексными типами внутри JSON-документов. Позиционные records эргономично подходят для owned JSON-типов:

// .NET 11, C# 14, EF Core 11
public record TagSet(List<string> Tags, DateTime UpdatedAt);

public class Article
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public TagSet Metadata { get; set; } = new(new(), DateTime.UtcNow);
}

// OnModelCreating
modelBuilder.Entity<Article>()
    .OwnsOne(a => a.Metadata, b => b.ToJson());

Record - комплексное свойство, хранимое как JSON. Заменяете его целиком через article.Metadata = article.Metadata with { Tags = [..article.Metadata.Tags, "net11"] };, и EF Core сериализует всё поддерево при SaveChanges. Никакого identity tracking, никаких споров with против мутации.

Складываем вместе

Реалистичный домен от и до:

// .NET 11, C# 14, EF Core 11
// Complex types (records)
public record Address(string Street, string City, string PostalCode);
public readonly record struct Money(decimal Amount, string Currency);

// Entity (class with init-only properties + binding ctor)
public class Order
{
    public Order(int id, string customerName, Money total, Address shipTo)
    {
        Id = id;
        CustomerName = customerName;
        Total = total;
        ShipTo = shipTo;
    }

    private Order() { } // EF fallback

    public int Id { get; init; }
    public string CustomerName { get; init; } = "";
    public Money Total { get; init; }
    public Address ShipTo { get; init; } = new("", "", "");

    public List<OrderLine> Lines { get; init; } = new();
}

// Projection/DTO (positional record)
public record OrderSummary(int Id, string CustomerName, decimal Total);

// Input command (positional record, validated before mapping)
public record CreateOrder(string CustomerName, Money Total, Address ShipTo);

Вот всё практическое правило: классы для вещей с идентичностью, records для вещей, определяемых своими данными. Связывание конструктора в EF Core 11, маппинг комплексных типов и маппинг JSON поддерживают это разделение без дополнительной конфигурации, кроме ComplexProperty или OwnsOne(..ToJson()) где уместно.

Связанное чтение

Источники

< Назад