From d74e5b4cd0f1f99fe4ff19d08c138549ffa61b55 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:01:34 +0200 Subject: [PATCH] Document EF Core breaking changes and SaveChanges interceptor outcomes (#5485) * Document EF Core 9 API and query breaking changes Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> * Validate interceptor and breaking change documentation Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> * Remove interceptor version qualifiers Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> * Revise breaking changes for EF Core 9.0 API Updated breaking changes documentation for EF Core 9.0 to reflect API changes from EF Core 8. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> Co-authored-by: Andriy Svyryd Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../interceptors.md | 21 ++++-- .../ef-core-9.0/breaking-changes.md | 67 ++++++++++++++++++- .../AuditingInterceptor.cs | 57 ++++++++++++---- 3 files changed, 127 insertions(+), 18 deletions(-) diff --git a/entity-framework/core/logging-events-diagnostics/interceptors.md b/entity-framework/core/logging-events-diagnostics/interceptors.md index 46bc091a44..b90b20a44e 100644 --- a/entity-framework/core/logging-events-diagnostics/interceptors.md +++ b/entity-framework/core/logging-events-diagnostics/interceptors.md @@ -2,7 +2,7 @@ title: Interceptors - EF Core description: Interception for database operations and other events author: SamMonoRT -ms.date: 02/26/2026 +ms.date: 08/19/2026 uid: core/logging-events-diagnostics/interceptors ms.custom: sfi-ropc-nochange --- @@ -24,7 +24,7 @@ The following table shows the available interceptor interfaces: | | Creating commands
Executing commands
Command failures
Disposing the command's DbDataReader | No | | | Opening and closing connections
Creating connections
Connection failures | No | | | Creating transactions
Using existing transactions
Committing transactions
Rolling back transactions
Creating and using savepoints
Transaction failures | No | -| | SavingChanges/SavedChanges
SaveChangesFailed
Optimistic concurrency handling | No | +| | SavingChanges/SavedChanges
SaveChangesFailed/SaveChangesCanceled
Optimistic concurrency handling | No | | | Creating, initializing, and finalizing entity instances from query results | Yes | | | Modifying the LINQ expression tree before a query is compiled | Yes | | | Resolving identity conflicts when tracking entities | Yes | @@ -705,7 +705,7 @@ The general idea for auditing with the interceptor is: * An audit message is created at the beginning of SaveChanges and is written to the auditing database * SaveChanges is allowed to continue * If SaveChanges succeeds, then the audit message is updated to indicate success -* If SaveChanges fails, then the audit message is updated to indicate the failure +* If SaveChanges fails or is canceled, then the audit message is updated to indicate the outcome The first stage is handled before any changes are sent to the database using overrides of and . @@ -841,9 +841,12 @@ The audit entity is stored on the interceptor so that it can be accessed again o The audit entity is attached to the audit context, since it already exists in the database and needs to be updated. We then set `Succeeded` and `EndTime`, which marks these properties as modified so SaveChanges will send an update to the audit database. -#### Detecting failure +#### Detecting failure, cancellation, and concurrency conflicts -Failure is handled in much the same way as success, but in the or method. The event data contains the exception that was thrown. +Ordinary failures are handled in much the same way as success, but in the or method. The event data contains the exception that was thrown. + +> [!NOTE] +> `SaveChangesFailed` and `SaveChangesFailedAsync` are not called for every exception. A canceled operation calls or . A calls or before the exception is thrown. It does not call `SaveChangesFailed` or `SaveChangesFailedAsync`. [!code-csharp[SaveChangesFailed](../../../samples/core/Miscellaneous/SaveChangesInterception/AuditingInterceptor.cs?name=SaveChangesFailed)] +The auditing interceptor also handles cancellation separately. The asynchronous callback does not pass the canceled token to the audit operation, so that the outcome can still be recorded. + +[!code-csharp[SaveChangesCanceled](../../../samples/core/Miscellaneous/SaveChangesInterception/AuditingInterceptor.cs?name=SaveChangesCanceled)] + +Concurrency interception occurs immediately before EF Core throws the exception and allows the exception to be suppressed. This interceptor records the conflict and returns the existing result without suppressing it. + +[!code-csharp[ThrowingConcurrencyException](../../../samples/core/Miscellaneous/SaveChangesInterception/AuditingInterceptor.cs?name=ThrowingConcurrencyException)] + #### Demonstration The [auditing sample](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Miscellaneous/SaveChangesInterception) contains a simple console application that makes changes to the blogging database and then shows the auditing that was created. diff --git a/entity-framework/core/what-is-new/ef-core-9.0/breaking-changes.md b/entity-framework/core/what-is-new/ef-core-9.0/breaking-changes.md index 28032a1a6b..6bc314d42e 100644 --- a/entity-framework/core/what-is-new/ef-core-9.0/breaking-changes.md +++ b/entity-framework/core/what-is-new/ef-core-9.0/breaking-changes.md @@ -2,7 +2,7 @@ title: Breaking changes in EF Core 9 (EF9) - EF Core description: List of breaking changes introduced in Entity Framework Core 9 (EF9) author: SamMonoRT -ms.date: 01/17/2025 +ms.date: 08/19/2026 uid: core/what-is-new/ef-core-9.0/breaking-changes --- @@ -37,6 +37,8 @@ EF Core 9 targets .NET 8. This means that existing applications that target .NET | [`EF.Constant()` and `EF.Parameter()` no longer work inside compiled queries](#ef-constant-compiled) | Low | | [Some `NoTrackingWithIdentityResolution` queries are now prohibited for JSON collections](#no-tracking-json) | Low | | [All pending migrations are applied in a single transaction](#migrations-single-transaction) | Low | +| [Primitive collection APIs were replaced](#primitive-collection-apis) | Low | +| [SQL Server `IndexOf` now propagates null values](#indexof-null) | Low | ## High-impact changes @@ -233,6 +235,69 @@ var binaryData = await context.Blogs.Select(b => EF.Functions.Unhex(b.HexString) Otherwise, add runtime checks for null on the return value of Unhex(). + + +### Primitive collection APIs were replaced + +[Tracking Issue #33456](https://github.com/dotnet/efcore/issues/33456) + +#### Old behavior + +In EF Core 8, the following APIs were used for comparing and JSON serialization of primitive collections: + +- `ListComparer` +- `NullableValueTypeListComparer` +- `ObjectListComparer` +- `JsonCollectionReaderWriter` +- `JsonNullableStructCollectionReaderWriter` + +#### New behavior + +Starting with EF Core 9.0, these APIs have been replaced by APIs specialized for the collection element type: + +| **EF Core 8 API** | **EF Core 9 API** | +|:------------------|:------------------| +| `ListComparer` | `ListOfValueTypesComparer` for non-nullable value types, or `ListOfReferenceTypesComparer` for reference types | +| `NullableValueTypeListComparer` | `ListOfNullableValueTypesComparer` | +| `ObjectListComparer` | `ListOfReferenceTypesComparer` | +| `JsonCollectionReaderWriter` | `JsonCollectionOfStructsReaderWriter` or `JsonCollectionOfReferencesReaderWriter` | +| `JsonNullableStructCollectionReaderWriter` | `JsonCollectionOfNullableStructsReaderWriter` | + +#### Why + +Primitive collection infrastructure was consolidated across relational and Azure Cosmos DB providers. The new APIs use the exact concrete collection type when creating snapshots and distinguish between non-nullable value types, nullable value types, and reference types. This enables support for nested and read-only collections. + +#### Mitigations + +Code that directly uses one of the removed APIs must use the replacement appropriate for its element type. Supply the concrete collection type, such as `List`, as `TConcreteList` or `TConcreteCollection`, and the element type as `TElement`. + + + +### SQL Server `IndexOf` now propagates null values + +[Tracking Issue #18773](https://github.com/dotnet/efcore/issues/18773) + +#### Old behavior + +Previously, when SQL Server translated `IndexOf` with an empty search string, it returned `0` even if the string being searched was `null`. + +#### New behavior + +Starting with EF Core 9.0, SQL Server returns `null` for `IndexOf` when the string being searched is `null`. This matches the translation used by SQLite. + +#### Why + +The new translation follows standard null propagation and no longer treats a null string as containing an empty string at index zero. + +#### Mitigations + +To retain the previous behavior, handle null explicitly in the query: + +```csharp +var indexes = context.Entities + .Select(e => e.NullableString == null ? 0 : e.NullableString.IndexOf(string.Empty)); +``` + ### Compiled models now reference value converter methods directly diff --git a/samples/core/Miscellaneous/SaveChangesInterception/AuditingInterceptor.cs b/samples/core/Miscellaneous/SaveChangesInterception/AuditingInterceptor.cs index f7c7c81267..d7a14d729b 100644 --- a/samples/core/Miscellaneous/SaveChangesInterception/AuditingInterceptor.cs +++ b/samples/core/Miscellaneous/SaveChangesInterception/AuditingInterceptor.cs @@ -79,31 +79,64 @@ public async ValueTask SavedChangesAsync( #region SaveChangesFailed public void SaveChangesFailed(DbContextErrorEventData eventData) + => UpdateAudit(eventData.Exception.Message); + + public Task SaveChangesFailedAsync( + DbContextErrorEventData eventData, + CancellationToken cancellationToken = default) + => UpdateAuditAsync(eventData.Exception.Message, cancellationToken); + #endregion + + #region SaveChangesCanceled + public void SaveChangesCanceled(DbContextEventData eventData) + => UpdateAudit("SaveChanges was canceled."); + + public Task SaveChangesCanceledAsync( + DbContextEventData eventData, + CancellationToken cancellationToken = default) + => UpdateAuditAsync("SaveChanges was canceled.", CancellationToken.None); + #endregion + + #region ThrowingConcurrencyException + public InterceptionResult ThrowingConcurrencyException( + ConcurrencyExceptionEventData eventData, + InterceptionResult result) { - using var auditContext = new AuditContext(_connectionString); + UpdateAudit(eventData.Exception.Message); + return result; + } - auditContext.Attach(_audit); - _audit.Succeeded = false; - _audit.EndTime = DateTime.UtcNow; - _audit.ErrorMessage = eventData.Exception.Message; + public async ValueTask ThrowingConcurrencyExceptionAsync( + ConcurrencyExceptionEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + await UpdateAuditAsync(eventData.Exception.Message, cancellationToken); + return result; + } + #endregion + private void UpdateAudit(string errorMessage) + { + using var auditContext = new AuditContext(_connectionString); + UpdateAudit(errorMessage, auditContext); auditContext.SaveChanges(); } - public async Task SaveChangesFailedAsync( - DbContextErrorEventData eventData, - CancellationToken cancellationToken = default) + private async Task UpdateAuditAsync(string errorMessage, CancellationToken cancellationToken) { using var auditContext = new AuditContext(_connectionString); + UpdateAudit(errorMessage, auditContext); + await auditContext.SaveChangesAsync(cancellationToken); + } + private void UpdateAudit(string errorMessage, AuditContext auditContext) + { auditContext.Attach(_audit); _audit.Succeeded = false; _audit.EndTime = DateTime.UtcNow; - _audit.ErrorMessage = eventData.Exception.InnerException?.Message; - - await auditContext.SaveChangesAsync(cancellationToken); + _audit.ErrorMessage = errorMessage; } - #endregion #region CreateAudit private static SaveChangesAudit CreateAudit(DbContext context)