Entity Framework Core (EF Core) is an object-relational mapper (ORM), enabling us to work with a database using .NET objects, eliminating much of the data-access code we usually need to write. EF Core supports many databases, including SQL Server, SQLite, PostgreSQL, MySQL, and more, making it highly versatile. While EF Core provides many conveniences, following best practices to ensure our application remains robust, maintainable, and performant is crucial.

Check out our comprehensive Entity Framework Core Series for a deeper dive into EF Core.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!
To download the source code for this article, you can visit our GitHub repository.

In the upcoming sections, we’ll explore the best practices of the EF Core. Let’s start!

Modeling Data With Entity Framework Core

Modeling data effectively in EF Core is crucial for building scalable, maintainable, efficient applications. In this section, we’ll outline best practices for defining entity classes and handling relationships to ensure our data models are clear, descriptive, and optimized for performance.

Best Practices for Defining Entity Classes

Using clear and descriptive class names is key to maintaining readability in the codebase. Each class name should accurately represent the entity it holds, making its purpose clear. For example, instead of naming a class Tbl, we name it Product if it holds product data.

Moreover, EF Core provides two main ways to configure our data model: Data Annotations and Fluent API. Specifically, data annotations are attributes we apply directly to our classes and properties. They are simple to use and work well for straightforward configurations.

Let’s take a look at how to use Data Annotations:

public class Product
{
    public int Id { get; set; }

    [Required]
    [MaxLength(100)]
    public string? Name { get; set; }

    [Column(TypeName = "decimal(18,2)")]
    public decimal Price { get; set; }
}

Here, we create a class with a clear name, Product, and use Data Annotations to make the Name property required with a maximum length of 100 characters. Additionally, we specify the data type for the Price property. Misusing Data Annotations can result in issues like incorrect database schemas or validation errors.

On the other hand, Fluent API is more powerful and flexible, allowing for complex configurations and overrides. It is defined in the OnModelCreating() method of our DbContext class. Using Fluent API, we can configure entity properties and relationships with greater precision and control.

Now, let’s let’s configure the same using the Fluent API:

public class ApplicationDbContext : DbContext
{
    public DbSet<Product> Products { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(entity =>
        {
            entity.HasKey(e => e.Id);

            entity.Property(e => e.Name)
                  .IsRequired()
                  .HasMaxLength(100);
        });
    }
}

We use Fluent API to configure the primary key and set property constraints. Improper use can lead to missing constraints in the database schema, so reviewing the generated SQL scripts to ensure they align with the expected design is important.

As a best practice, instead of placing all the configurations inside the OnModelCreating() method, we can extract them into separate classes that implement the IEntityTypeConfiguration<T> interface. This approach helps keep the OnModelCreating() method clean and improves maintainability, especially when the number of entities grows.

For more details, refer to our article on best practices for EF Core Migrations and Seed Data.

Data Model Optimization

Optimizing the data model is essential to ensure performance and maintainability in an EF Core application. We can design models that minimize resource usage and improve query efficiency by following key practices.

It is crucial to choose the correct data types for entity properties. For example, we should avoid using nvarchar(max) or varchar(max) for string fields unless necessary. Instead, we must specify precise lengths to limit storage size and increase query speed.

We must index frequently queried columns like foreign keys or fields in WHERE clauses. Indexes significantly improve lookup performance, especially in large datasets. We should leverage data annotations or use Fluent API to fine-tune column definitions. For instance, we must use [Required] and [MaxLength] annotations to enforce constraints that prevent null or excessively large data from being stored.

We should avoid overusing nullable fields unless truly required. Defining fields as NOT NULL can optimize storage and prevent unnecessary database operations.

Let’s take a look at an optimized entity model:

public class Customer
{
    public int Id { get; set; }

    [Required]
    [MaxLength(100)]
    public string Name { get; set; }

    [Required]
    [MaxLength(150)]
    public string Email { get; set; }

    [Column(TypeName = "decimal(18, 2)")]
    public decimal CreditLimit { get; set; }

    [Required]
    [MaxLength(50)]
    public string Country { get; set; }

    [Required]
    public DateTime CreatedDate { get; set; }
}

Here, we limit the Name and Email properties to 100 and 150 characters respectively, instead of nvarchar(max), optimizing storage. Then, we use the [Required] annotation to ensure that essential fields like Name, Email, Country, and CreatedDate cannot be null, promoting better data integrity.

Next, we set the CreditLimit and CreatedDate to non-nullable, optimizing storage and preventing unnecessary null checks. Finally, we specify decimal(18, 2) for CreditLimit, avoiding unnecessary large data types and reducing storage overhead.

Managing Migrations in Entity Framework Core

Effective migration management is essential for maintaining a consistent and up-to-date database schema. Migrations allow us to evolve our database schema over time while preserving the existing data.

To learn more about migrations, visit our article on Migrations and Seed Data in EF Core.

Best Practices for Versioning and Deploying Migrations

Maintaining migrations in version control is crucial for collaboration and consistency across development environments. By including migration files in our version control system, we ensure all team members work with the same database schema and can track changes over time.

Therefore, we must always commit the generated migration files to the version control system. This includes the migration class, designer file, and any snapshot files. Otherwise, ignoring migration files can lead to inconsistent database schemas across different environments, making troubleshooting difficult.

We should use clear and descriptive names for migrations to indicate their purpose. This will help us understand the changes introduced by each migration:

dotnet ef migrations add AddCustomerTable

Here, we use a descriptive name, “AddCustomerTable,” for the migration.

Before committing, we must review the generated migration code to ensure it accurately represents the intended schema changes. We should refactor the code if necessary to maintain clarity and consistency. Failing to review migrations can result in unexpected schema changes or data loss, especially if the generated code does not match the intended design.

Deploying Migrations in Different Environments

Deploying migrations across different environments (development, staging, production, etc.) requires careful planning and execution to avoid disruptions and ensure data integrity. For this reason, we should use environment-specific configuration files to manage connection strings and database settings. This ensures that we apply migrations to the correct database for each environment.

Now, let’s create environment-specific configuration files:

//appsettings.Development.json
{
  "ConnectionStrings": {
    "ProductDb": "Server=localhost;Database=DevDb;User Id=dev;Password=devpassword;"
  }
}

//appsettings.Production.json
{
  "ConnectionStrings": {
    "ProductDb": "Server=productiondbserver;Database=ProdDb;User Id=prod;Password=prodpassword;"
  }
}

Here, we create configuration files for different environments. Incorrect configuration can cause us to apply migrations to the wrong database, potentially leading to data corruption orĀ loss.

Querying Data With Entity Framework Core

Querying data efficiently in EF Core is essential for building high-performance applications. EF Core allows us to use Language Integrated Query (LINQ) to query databases in a strongly typed manner. For more detailed information on querying data with EF Core, visit our article Database Queries in Entity Framework Core.

Use Projections

Instead of retrieving entire entities, we must select only the necessary fields. This reduces the amount of data transferred from the database to the application. Now, let’s use projection to select the field:

var products = await applicationDbContext.Products
    .Select(p => new { p.Name, p.Id })
    .ToListAsync();

Here, we use the Select() extension method to project the Name and Id properties of the Product entity. This reduces the amount of data retrieved from the database, improving performance.

Filter Early

We should apply filtering conditions (Where clause) as early as possible in the query to minimize the data processed. Now, let’s apply filtering early in the query:

var products = await applicationDbContext.Products
    .Where(p => p.Price > 10)
    .Select(x => new { x.Name, x.Price})
    .ToListAsync();

Here, we filter products with a price greater than 10 before projecting the Name and Price properties. This ensures that we process and retrieve only the relevant data, enhancing query performance

Use Batching for Inserts

For bulkĀ operations, consider using EF Core’s AddRange() method for inserts:

var newProducts = new List<Product>
{
    new Product { Name = "Product1", Price = 20 },
    new Product { Name = "Product2", Price = 30 },
};
context.Products.AddRange(newProducts);
context.SaveChanges();

Here, we use the AddRange() method to add multiple Product entities in a single batch. This reduces the number of database operations, making the insert process more efficient.

Using AsNoTracking for Read-Only Queries

The AsNoTracking() method in EF Core instructs the context not to track the entities returned by the query. This reduces memory usage and improves query performance, especially for large result sets or frequently executed read-only queries.

We should use AsNoTracking() when we do not need to update the retrieved entities. For example, when performing read-only operations, such as displaying data in a UI or generating reports, AsNoTracking() can be beneficial. Additionally, using AsNoTracking() helps improve performance by reducing the overhead of change tracking, making it ideal for scenarios where data modifications are unnecessary.

Using AsNoTracking() method inappropriately can lead to issues if we later try to update the entities, as the context will not track them.

Let’s see how to use AsNoTracking() method in a repository method:

var product = await applicationDbContext.Products
    .Where(p => p.Id == productId)
    .AsNoTracking()
    .FirstOrDefaultAsync();

Here, we use the AsNoTracking() method to fetch products by category without tracking the entities, which makes the query more efficient for read-only purposes. This reduces the overhead of change tracking and improves performance.

Use Skip() and Take() Methods

Handling large data sets is crucial to maintaining application performance and user experience. Pagination is a common strategy to manage large data sets by retrieving data in smaller chunks. We commonly use Skip() and Take() methods in LINQ for pagination. Skip() method specifies the number of records to bypass while Take() methods specifies the number of records to retrieve.

Now, let’s use Skip() and Take() methods for pagination:

var pageNumber = 1;
var pageSize = 10;

var pagedProducts = await applicationDbContext.Products
    .OrderBy(p => p.Name)
    .Skip((pageNumber - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

Here, we implement pagination by skipping a specified number of records and taking the next set of records. This allows us to retrieve data in smaller, manageable chunks, improving performance and user experience. Fetching large data sets without pagination can lead to performance issues, high memory usage, and slow response times. We should always use pagination strategies to manage large data sets efficiently.

Performance Optimization With Entity Framework Core

Optimizing performance in EF Core is crucial for building responsive and efficient applications. This involves using techniques such as eager loading to minimize the number of database queries, lazy loading to delay data retrieval until it’s needed, and compiled queries to reduce the overhead of query compilation.

Lazy Loading vs Eager Loading

Lazy and Eager Loading are two strategies for loading related data in EF Core. Choosing the right strategy can significantly impact performance and resource utilization. Lazy loading delays the loading of associated data until we explicitly access it. This approach can help reduce the initial load time and memory usage if we don’t always need the related data.

Now, let’s see how to configure lazy loading:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseLazyLoadingProxies();
}

public class Product
{
    public virtual Category? Category { get; set; }
}

Here, we override the OnConfiguring() method of the DbContext class inside the ApplicationDbContext class. Then, we enable lazy loading by configuring EF Core to use lazy loading proxies.

At the same time, we modify the Product class to add the Category navigation property as virtual. This setup allows related Category entity to be loaded only when they are accessed.

Eager Loading loads related data as part of the initial query. We can improve performance by reducing the number of database round-trips, especially when we always need the related data

Now, let’s see how to configure Eager Loading:

var categoriesWithProducts = await applicationDbContext.Suppliers
    .Include(c => c.Orders)
    .Select(x => new
    {
        x.Name,
        Products = x.Orders!.Select(p => new
        {
            p.ProductName,
        })
    })
    .ToListAsync();

Here, we use the Include() method to eagerly load the Order entities along with Supplier entities in a single query. This ensures the related data is available immediately, reducing the need for additional database queries.

Using Split Queries

UsingĀ Eager Loading with large datasets or complex relationshipsĀ can result in a Cartesian explosion, leading to performance issues. EF Core allows for Split Queries, which splits the loading of related entities into separate queries, reducing this risk.

Now, let’s configure Split Queries:

var suppliers = await applicationDbContext.Suppliers
    .Include(s => s.Orders)
    .AsSplitQuery()
    .Select(x => new
    {
        x.Name
    })
    .ToListAsync();

Here, we use the AsSplitQuery() method to load related Orders in separate SQL queries, reducing the potential performance issues caused by large joins. Both Lazy Loading and Eager Loading have their use cases. Using split queries where necessary can help optimize performance, especially when dealing with large or complex relationships.

For more details on single and split queries, please check out our article on EF Core Single and Split Queries.

Using Compiled Queries in Entity Framework Core

Pre-compiled versions of LINQ queries, known as compiled queries, improve performance by reducing the overhead of query compilation and allowing reuseĀ for frequently executed queries. We can reuse compiled queries across different parts of the application, ensuring consistency and performance benefits.

Now, let’s create and use a compiled query:

public static class CompiledQueries
{
    public static readonly Func<ApplicationDbContext, decimal, IEnumerable<Product>> GetExpensiveProducts
        = EF.CompileQuery((ApplicationDbContext context, decimal price) =>
            context.Products.Where(p => p.Price > price));
}

CompiledQueries.GetExpensiveProducts(applicationDbContext, 30).ToList();

We define a compiled query GetExpensiveProducts to retrieve products with a price greater than a specified value. We then use the compiled query to fetch the data efficiently. Compiled queries particularly benefit complex queries or scenarios where we execute the same query multiple times with different parameters.

Async Calls

Asynchronous programming is essential for building responsive and scalable applications. EF Core provides asynchronous methods for database operations to avoid blocking the main thread during I/O operations.

Asynchronous APIs improve application responsiveness by preventing us from blocking the main thread during database operations. Asynchronous operations allow the application to handle more concurrent requests by freeing up threads for other tasks.

Now, let’s use the SaveChangesAsync() method:

var newProducts = new List<Product>
{
    new Product { Name = "Product5", Price = 20 },
    new Product { Name = "Product6", Price = 30 },
};

await applicationDbContext.Products.AddRangeAsync(newProducts);
await applicationDbContext.SaveChangesAsync();

Here, we add a new Product entity to the database and saves the changes asynchronously. This approach ensures that the main thread remains unblocked during database operations, enhancing application performance and scalability.

Security Considerations in Entity Framework Core

Security is a paramount concern when developing applications that interact with databases. EF Core provides several mechanisms to help protect our application from common security threats such as SQL injection and insecure handling of connection strings.

Preventing SQL Injection

SQL injection is a common and severe security vulnerability where an attacker can execute arbitrary SQL code by injecting malicious input into a query. EF Core helps mitigate this risk by using parameterized queries, but we must still follow best practices to ensure their applications are secure.

We must always use parameterized queries because they automatically handle escaping special characters and prevent the execution of injected SQL code. Moreover, we should never concatenate user input directly into SQL queries. Otherwise, dynamic SQL that includes user input can easily lead to SQL injection vulnerabilities.

Whenever possible, use LINQ to construct queries in EF Core. EF Core translates LINQ queries into parameterized SQL queries, providing additional protection against SQL injection. We must always validate and sanitize user input before using it in our queries. This reduces the likelihood of malicious input making its way into our queries.

Now, let’s see an example that incorporates all these best practices:

if (producId <= 0 || string.IsNullOrWhiteSpace(searchTerm))
{
    throw new ArgumentException("Invalid input parameters.");
}

var products =  await applicationDbContext.Products
    .Where(p => p.Id == producId && p.Name!.Contains(searchTerm))
    .ToListAsync();

Here, before executing any queries, we validate the productId and searchTerm inputs to ensure they are valid. This prevents the application from processing harmful or unexpected inputs.

Next, we use LINQ to construct a query that safely filters products by productId and searches for the Name property that contains the searchTerm. LINQ automatically parameterizes the inputs, protecting against SQL injection. Finally, we show how to use raw SQL queries with parameterized input safely. This allows us to safely include user inputs in the SQL query by automatically parameterizing them.

We must always prefer using LINQ for queries in EF Core, as it provides built-in protection against SQL injection. If we need to use raw SQL queries, ensure that we use parameterized queries to maintain security.

Conclusion

In conclusion, adhering to best practices in EF Core ensures our applications’ reliability and maintainability and significantly boosts performance and scalability.

We can create robust applications that handle data efficiently by effectively managing relationships, optimizing queries, and utilizing asynchronous operations. Moreover, understanding when to apply techniques like lazy loading, eager loading, and compiled queries can substantially affect application responsiveness.

Remember that best practices may evolve, so stay engaged with the C# community to stay up-to-date with the latest recommendations.

If you have something to add to the list, we invite you to contribute by sharing it in the comment section.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!