Brian Makumi
Entity Framework Core Patterns I Actually Use in Production

Entity Framework Core Patterns I Actually Use in Production

April 26, 2026
·

Brian Makumi

There is a gap between knowing how Entity Framework Core works and knowing how to use it in a way that holds up under real conditions. The documentation will teach you the first thing. Production will teach you the second, usually by showing you what happens when you get it wrong.

This post is not a tutorial. It is not a comprehensive guide to EF Core features. It is a walkthrough of the specific patterns I apply consistently across every .NET project I build, with the reasoning behind each one. The patterns themselves are not exotic. The discipline of applying them deliberately is what separates a codebase that ages well from one that accumulates quiet performance problems and confusing data states.

If you are past the getting-started stage with EF Core and want to understand how experienced developers think about it in production, this is written for you.

Knowing how EF Core works and knowing how to use it without creating problems at scale are two different skills. Production teaches you the second one.

ASNOTRACKING: THE DEFAULT YOU SHOULD BE QUESTIONING

By default, every entity you query through EF Core gets attached to the change tracker. The change tracker watches that entity for modifications and uses those modifications to generate UPDATE statements when you call SaveChanges. This is the feature that makes EF Core convenient for write operations.

It is also overhead you are paying on every read operation, whether you need it or not.

For a read-only query, attaching entities to the change tracker gives you nothing. The entity is never going to be modified and saved. The tracking is pure cost: memory allocated to hold the snapshot, CPU cycles spent watching for changes, and a change tracker that grows with every query you execute in the same DbContext lifetime.

The pattern I apply is deliberate rather than default. Every query that exists purely to return data gets AsNoTracking. Every query that might lead to an update does not.

// Read-only: tracking adds no value here

var tasks = await _context.Tasks

.AsNoTracking()

.Where(t => t.AssignedUserId == userId)

.ToListAsync();

// Write path: tracking is needed

var task = await _context.Tasks

.FirstOrDefaultAsync(t => t.Id == id);

if (task != null)

{

task.StatusId = newStatusId;

await _context.SaveChangesAsync();

}

The practical impact becomes visible at scale. A dashboard that loads twenty entities without AsNoTracking is allocating tracking overhead for twenty objects the application will never modify. Multiply that across concurrent users and the cost is real.

The habit worth building is this: when you write a query, ask immediately whether the result will ever be written back. If the answer is no, add AsNoTracking before you move on. It is a two-second decision that compounds positively across the entire codebase.

THE N PLUS 1 PROBLEM: WHAT IT IS AND WHY IT KEEPS HAPPENING

The N plus 1 problem is one of the most common performance issues in applications built with ORMs, and it is easy to introduce without realising it because the code that causes it looks completely reasonable.

The pattern goes like this. You query a list of entities. Then, for each entity in that list, you access a navigation property that has not been loaded yet. EF Core executes a separate database query for each entity to load that property. One query to get the list, then one query per item in the list. If the list has fifty items, you have made fifty-one database round trips instead of one or two.

// This looks fine but is dangerous

var projects = await _context.Projects

.AsNoTracking()

.ToListAsync();

foreach (var project in projects)

{

// Each iteration triggers a separate query

var taskCount = project.Tasks.Count;

}

The fix is to load related data intentionally rather than letting EF Core load it lazily on demand. Include brings related entities into the initial query. Select projects only the fields you need. Both approaches consolidate the data retrieval into a single round trip.

// Explicit loading with Include

var projects = await _context.Projects

.AsNoTracking()

.Include(p => p.Tasks)

.ToListAsync();

// Projection with Select: more efficient when you need specific fields

var projectSummaries = await _context.Projects

.AsNoTracking()

.Select(p => new ProjectSummaryDto

{

Id = p.Id,

Name = p.Name,

TaskCount = p.Tasks.Count

})

.ToListAsync();

The projection approach with Select is generally preferable when you are building read models or DTOs, because it gives the database engine the information it needs to execute an optimal query rather than loading full entity graphs and discarding most of the data in application code.

The N plus 1 problem does not look like a bug. It looks like working code. That is why it keeps shipping to production.

The discipline that prevents N plus 1 problems is reviewing every query that accesses navigation properties and asking whether those properties will be loaded by the time they are accessed. If the answer is uncertain, make it certain by being explicit about what gets loaded and when.

SOFT DELETES WITH LOOKUP TABLES

Hard deletes, where a record is physically removed from the database, are rarely the right choice for production data. They break audit trails, they complicate recovery scenarios, and they create referential integrity problems when other records depend on the deleted entity.

Soft deletes, where a record is marked as inactive rather than removed, solve these problems. The record stays in the database. Its relationships remain intact. The deletion is reversible. And you have a complete history of every state the record has passed through.

My approach to soft deletes connects directly to the lookup table convention I use for all status fields. Rather than adding a boolean IsDeleted column, every entity that supports soft deletion has a StatusId that references a shared Statuses lookup table. A status of Active means the record is live. A status of Deleted means it has been soft deleted. A status of Archived means it has been intentionally retired without deletion.

// Statuses lookup table

public class Status

{

public int Id { get; set; }

public string Name { get; set; } = string.Empty;

}

// Entity with status-based soft delete

public class Task

{

public int Id { get; set; }

public string Title { get; set; } = string.Empty;

public int StatusId { get; set; }

public Status Status { get; set; } = null!;

}

The advantage over a boolean IsDeleted column is that the status field carries meaning beyond a binary active or inactive state. You can distinguish between a record that was deleted intentionally, one that was archived, one that is pending review, and one that is in draft, all within the same field, without adding columns or changing the schema.

Adding a new status is a data operation. You insert a row into the Statuses table. No migration required. No enum update. No redeployment. The application picks it up immediately because it is reading status values from the database rather than from a hardcoded list in code.

Filtering deleted records consistently

The operational discipline that makes soft deletes work is consistent filtering. Every query that should return active records needs to filter on status. If you add that filter in some places and forget it in others, deleted records surface unexpectedly and the soft delete pattern breaks down.

// Always filter by active status on read queries

var activeTasks = await _context.Tasks

.AsNoTracking()

.Where(t => t.StatusId == StatusIds.Active)

.ToListAsync();

// Soft delete: update status, do not remove the record

var task = await _context.Tasks

.FirstOrDefaultAsync(t => t.Id == id);

if (task != null)

{

task.StatusId = StatusIds.Deleted;

await _context.SaveChangesAsync();

}

EF Core global query filters are worth considering for applications where the filtered entity is accessed in many places. A global query filter defined on the DbContext applies automatically to every query for that entity type, which eliminates the risk of forgetting the filter in a particular repository method.

REPOSITORY METHODS THAT MATCH REAL USE CASES

The generic repository base covers the standard CRUD operations. In production, most of the interesting work happens in the specific repository interfaces that extend the generic base with query methods that match real application needs.

The principle I apply is that a repository method should represent a meaningful operation in the domain, not a thin wrapper around a LINQ query. The difference is in how the application code reads.

// Thin wrapper: the caller still needs to know too much

var tasks = await _taskRepository.GetAllAsync();

var userTasks = tasks.Where(t => t.AssignedUserId == userId

&& t.StatusId == StatusIds.Active).ToList();

// Domain-meaningful method: intention is clear at the call site

var userTasks = await _taskRepository

.GetActiveTasksByUserAsync(userId);

The second version is easier to read, easier to test, and easier to optimise. The filtering happens in the repository, close to the database, rather than in application code after a full table scan. And the method name communicates intent in a way that a chain of LINQ operators does not.

This matters more as a codebase grows. A repository with well-named methods becomes a vocabulary for talking about the domain. A repository that returns raw IQueryable and lets callers filter arbitrarily becomes a leaky abstraction that is difficult to reason about and impossible to optimise consistently.

MANAGING DBCONTEXT LIFETIME

DbContext is designed to be a unit of work. It is meant to be created, used for a set of related operations, and disposed. It is not meant to be a long-lived singleton shared across the application.

In an ASP.NET Core application, the standard pattern is to register DbContext as a scoped service, which means one instance per HTTP request. This is correct for most use cases. Each request gets its own context, its own change tracker, and its own transaction boundary. When the request completes, the context is disposed and any uncommitted changes are discarded.

// Program.cs: register DbContext as scoped

builder.Services.AddDbContext<AppDbContext>(options =>

options.UseSqlServer(connectionString));

The problems arise when DbContext escapes the scope it was designed for. Injecting it into a singleton service is the most common mistake. A singleton lives for the lifetime of the application. A scoped DbContext injected into a singleton effectively becomes a singleton itself, with a change tracker that accumulates state across every request the application handles until it is restarted.

If you need database access in a singleton or a background service, use IDbContextFactory to create a short-lived context for each operation, use it, and dispose it immediately. This gives you the database access you need without the lifetime mismatch that causes subtle and difficult-to-diagnose bugs.

THE MINDSET BEHIND THE PATTERNS

What these patterns have in common is that they require you to be deliberate rather than accepting defaults. EF Core defaults are designed to be convenient for getting started. They are not always the right choice for production, and the gap between convenient and correct widens as the application grows and the load increases.

The developers who use EF Core well in production are not the ones who know the most features. They are the ones who ask the right questions before writing each query: is this data going to be modified? What related data does this operation actually need? What happens to this record when it is deleted? How many database round trips is this operation making?

Those questions have good answers. Getting into the habit of asking them before writing the code, rather than after a performance problem surfaces, is what makes the difference.

CLOSING THOUGHT

EF Core is a powerful tool and a forgiving one. It will let you build something that works without understanding any of what is in this post. The cost of that forgiveness shows up later, in query logs, in memory profiles, and in data states that are difficult to explain. The patterns here are not about using EF Core correctly in an academic sense. They are about using it in a way you will not regret six months into a production system.