Breaking Change To DbTransactionInterceptor Behavior In Entity Framework Core 7.0

Entity Framework Core or EFCore has an interceptor for various behavior and one such interceptor is DbTransactionInterceptor. This is a abstract class and to add custom functionality it can be inherited and extended as per application need. EF Core 7.0 has some performance improve feature and that indirectly affect the behavior of DbTransactionInterceptor.

Let's take an example and understand the scenario.

Scenario

Consider we have following entity and we want to insert into database.

Example entity

public class ExampleEntity
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Example DbContext

public class ExampleContext : 
    DbContext
{
    public static readonly ILoggerFactory ContextLoggerFactory
        = LoggerFactory.Create(builder => { builder.AddConsole(); });

    public ExampleContext()
    {

    }
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("Data Source=(localdb)\\ProjectModels;Initial Catalog=Example;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;")
            .EnableSensitiveDataLogging(true);
        optionsBuilder.UseLoggerFactory(ContextLoggerFactory);
        optionsBuilder.AddInterceptors(new ExampleTransactionInterceptor(ContextLoggerFactory));
        base.OnConfiguring(optionsBuilder);
    }
    public DbSet<ExampleEntity> Examples { get; init; }
}

Example interceptor

   public class ExampleTransactionInterceptor
         : DbTransactionInterceptor
    {
        private readonly ILoggerFactory loggerFactory;
        private readonly ILogger logger;

        public ExampleTransactionInterceptor(ILoggerFactory loggerFactory)
        {
            this.loggerFactory = loggerFactory;
            this.logger = loggerFactory.CreateLogger<ExampleTransactionInterceptor>();
        }

        public override ValueTask<DbTransaction> TransactionStartedAsync(DbConnection connection, TransactionEndEventData eventData, DbTransaction result, CancellationToken cancellationToken = default)
        {
            this.logger.LogInformation($"[{nameof(ExampleTransactionInterceptor)}] Transaction Started.");
            return base.TransactionStartedAsync(connection, eventData, result, cancellationToken);
        }
        public override Task TransactionCommittedAsync(DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default)
        {
            this.logger.LogInformation($"[{nameof(ExampleTransactionInterceptor)}] Transaction Committed.");
            return base.TransactionCommittedAsync(transaction, eventData, cancellationToken);
        }
    }

Above interceptor is already added in Dbcontext in OnConfiguring method.

Following code needs to run one time to create db.

using (var context = new ExampleContext())
{
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();    
}

Now following part is interesting but at the same time quite simple if you have already worked with EF core.

Insert entity into Db.

using (var context = new ExampleContext())
{
    var exampleEntity = new ExampleEntity()
    {
         Name = "Test",
    };

    context.Examples.Add(exampleEntity);
    await context.SaveChangesAsync();
}

Now if you are running above code EF Core 6 then you will see the following thing.

In above log, It is clearly visible that transaction started and then insert operation is performed and then transaction commit. Also log clearly from ExampleTransactionInterceptor.

Now if same code run in EF Core 7 then output is bit different.

Above is the result is due to the performance improve the feature of EF Core 7 and it disables the need for the transaction if there is only an operation that needs to perform using DBcontext. It disables the implicit transaction as well. ( Note: This mostly happens if you perform an operation against a single database table and no other table gets affected. This is an assumption but this is what I have seen.)

This is documented over here.What's New in EF Core 7.0 | Microsoft Learn

Now due to this if you have implemented anything in EF Core DbTransactionInterceptor then It required revisit. For such scenario It is good to use SaveChangesInterceptor.

Example code : jp1482/EfCoreDbTransactionInterceptorSample: Example EF Core DbTransactionInterceptor and Its behavior in EF Core 6 and EF Core 7 (github.com)