Mastering Entity Framework Core: Advanced Features Uncovered with Real-Time scenarios

Entity Framework (EF) has been a cornerstone for .NET developers, providing a robust ORM (Object-Relational Mapping) framework to interact with databases using .NET objects. While many developers are familiar with the basics, there are several lesser-known tricks and best practices that can significantly enhance your EF experience. In this article, we will explore some of these hidden gems and also highlight new features introduced in the latest version of Entity Framework Core (EF Core 6.0 and EF Core 7.0).

Leveraging Global Query Filters

Real-Time Scenario

Imagine you are developing a multi-tenant application where each tenant should only see their own data. Implementing this manually in every query can be error-prone and cumbersome.

Feature Explanation

Global Query Filters, introduced in EF Core 2.0, allow you to define filters that apply to all queries for a given entity type. This is particularly useful for implementing multi-tenancy, soft deletes, or any scenario where you need to filter data globally.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .HasQueryFilter(c => !c.IsDeleted);
} 

Table Schema

SQL Script

CREATE TABLE Customers (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100),
    IsDeleted BIT
);

Table Format

Column NameData TypeDescription
IdINTPrimary key
NameNVARCHAR(100)Name of the customer
IsDeletedBITIndicates if the customer is deleted

Using Value Conversions

Real-Time Scenario

Suppose you have a custom type for representing monetary values in your domain model, but you need to store these values as decimals in the database.

Feature Explanation

Value Conversions, introduced in EF Core 2.1, enable you to map custom types to database types. This is useful when you have domain-specific types that need to be stored in a database-friendly format.

public class Money
{
    public decimal Amount { get; set; }
    public string Currency { get; set; }

    public override string ToString() => $"{Amount} {Currency}";
    public static Money Parse(string value)
    {
        var parts = value.Split(' ');
        return new Money { Amount = decimal.Parse(parts[0]), Currency = parts[1] };
    }
}

modelBuilder.Entity<Order>()
    .Property(o => o.TotalAmount)
    .HasConversion(
        v => v.ToString(), // Convert to string for storage
        v => Money.Parse(v) // Convert back to custom type
    );  

Table Schema

SQL Script

CREATE TABLE Orders (
    Id INT PRIMARY KEY,
    CustomerId INT,
    TotalAmount NVARCHAR(50),
    FOREIGN KEY (CustomerId) REFERENCES Customers(Id)
);
    

Table Format

Column NameData TypeDescription
IdINTPrimary key
CustomerIdINTForeign key to Customers table
TotalAmountNVARCHAR(50)Custom monetary value stored as string

Compiled Queries for Performance

Real-Time Scenario

You have a high-traffic application where certain queries are executed frequently, and you need to optimize performance to handle the load.

Feature Explanation

Compiled Queries, introduced in EF Core 2.0, can significantly improve the performance of frequently executed queries by pre-compiling the query plan.

private static readonly Func<YourDbContext, int, Customer> _compiledQuery =
    EF.CompileQuery((YourDbContext context, int id) =>
        context.Customers.Single(c => c.Id == id));

public Customer GetCustomerById(int id)
{
    return _compiledQuery(_context, id);
}
    

Table Schema

SQL Script

CREATE TABLE Customers (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100)
);   

Table Format

Column NameData TypeDescription
IdINTPrimary key
NameNVARCHAR(100)Name of the customer

Interceptors for Advanced Scenarios

Real-Time Scenario

You need to implement custom logging for all database commands executed by your application to comply with auditing requirements.

Feature Explanation

Interceptors, introduced in EF Core 3.0, allow you to hook into various stages of EF’s operation, such as command execution, saving changes, and more. This is useful for logging, auditing, or modifying behavior dynamically.

public class MyCommandInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<int> NonQueryExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
    {
        // Log or modify the command here
        return base.NonQueryExecuting(command, eventData, result);
    }
}

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.AddInterceptors(new MyCommandInterceptor());
}
    

Table Schema

No specific schema changes are required for interceptors.

Temporal Tables for Historical Data

Real-Time Scenario

Your application needs to maintain a history of changes to certain entities for auditing and compliance purposes.

Feature Explanation

Temporal Tables, supported by EF Core 6.0, allow you to track changes to your data over time. This is useful for auditing and historical analysis.

modelBuilder.Entity<Customer>()
    .ToTable("Customers", b => b.IsTemporal());
    

Table Schema

SQL Script

CREATE TABLE Customers (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100),
    ValidFrom DATETIME2 GENERATED ALWAYS AS ROW START,
    ValidTo DATETIME2 GENERATED ALWAYS AS ROW END,
    PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.CustomersHistory));
    

Table Format

Column NameData TypeDescription
IdINTPrimary key
NameNVARCHAR(100)Name of the customer
ValidFromDATETIME2Start of the validity period
ValidToDATETIME2End of the validity period

New Features in the Latest Entity Framework

a. Many-to-Many Relationships

Real-Time Scenario

You are developing a library management system where books can have multiple authors, and authors can write multiple books. Modeling this relationship manually can be tedious.

Feature Explanation

The latest version of EF Core (EF Core 5.0) introduces native support for many-to-many relationships without needing a join entity.

modelBuilder.Entity<Author>()
    .HasMany(a => a.Books)
    .WithMany(b => b.Authors);
    

Table Schema

SQL Script
CREATE TABLE Authors (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100)
);

CREATE TABLE Books (
    Id INT PRIMARY KEY,
    Title NVARCHAR(100)
);

CREATE TABLE AuthorBook (
    AuthorId INT,
    BookId INT,
    PRIMARY KEY (AuthorId, BookId),
    FOREIGN KEY (AuthorId) REFERENCES Authors(Id),
    FOREIGN KEY (BookId) REFERENCES Books(Id)
);
    
Table Format
Authors Table
Column NameData TypeDescription
IdINTPrimary key
NameNVARCHAR(100)Name of the author
Books Table
Column NameData TypeDescription
IdINTPrimary key
TitleNVARCHAR(100)Title of the book
AuthorBook Table
Column NameData TypeDescription
AuthorIdINTForeign key to Authors table
BookIdINTForeign key to Books table

b. Improved LINQ Translation

Real-Time Scenario

You have complex LINQ queries that need to be translated into efficient SQL to ensure optimal performance.

Feature Explanation

EF Core 5.0 and later versions have improved their LINQ translation capabilities, allowing for more complex queries to be translated into efficient SQL.

CustomerIdINTForeign key to Customers tableTotalAmountNVARCHAR(50)Custom monetary value stored as string

c. Split Queries for Related Data

Real-Time Scenario

You need to load large datasets with related data without running into performance issues caused by the N+1 query problem.

Feature Explanation

Split Queries, introduced in EF Core 5.0, allow you to load related data in multiple queries, reducing the risk of the N+1 query problem and improving performance for large result sets.

var data = context.Customers
    .Include(c => c.Orders)
    .AsSplitQuery()
    .ToList();
    

Table Schema

SQL Script
CREATE TABLE Customers (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100)
);

CREATE TABLE Orders (
    Id INT PRIMARY KEY,
    CustomerId INT,
    TotalAmount NVARCHAR(50),
    FOREIGN KEY (CustomerId) REFERENCES Customers(Id)
);
    
Table Format
Customers Table
Column NameData TypeDescription
IdINTPrimary key
NameNVARCHAR(100)Name of the customer
Orders Table
Column NameData TypeDescription
IdINTPrimary key
CustomerIdINTForeign key to Customers table
TotalAmountNVARCHAR(50)Custom monetary value stored as string

d. Savepoints for Transactions

Real-Time Scenario

You are performing a series of operations within a transaction and need to create intermediate points to roll back to in case of errors.

Feature Explanation

Savepoints, introduced in EF Core 7.0, allow you to create intermediate points within a transaction, providing more control over transaction management.

using var transaction = context.Database.BeginTransaction();
try
{
    // Perform some operations
    var savepoint = transaction.CreateSavepoint("BeforeCriticalOperation");

    // Perform critical operation
    transaction.RollbackToSavepoint("BeforeCriticalOperation");

    transaction.Commit();
}
catch
{
    transaction.Rollback();
}
    

Table Schema

No specific schema changes are required for savepoints.

e. Primitive Collections

Real-Time Scenario

You need to store a list of primitive values, such as strings or integers, directly within an entity without creating a separate table.

Feature Explanation

Primitive Collections, introduced in EF Core 6.0, allow you to store collections of primitive types directly within an entity.

public class Product
{
    public int Id { get; set; }
    public List<string> Tags { get; set; }
}

modelBuilder.Entity<Product>()
    .Property(p => p.Tags)
    .HasConversion(
        v => string.Join(',', v), // Convert list to comma-separated string
        v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList() // Convert string back to list
    );
    

Table Schema

SQL Script
CREATE TABLE Products (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100),
    Tags NVARCHAR(MAX)
);
    
Table Format
Column NameData TypeDescription
IdINTPrimary key
NameNVARCHAR(100)Name of the product
TagsNVARCHAR(MAX)Comma-separated list of tags

f. HierarchyId Support

Real-Time Scenario

You are working with hierarchical data, such as organizational structures or file systems, and need to efficiently manage and query this data.

Feature Explanation

HierarchyId support, introduced in EF Core 7.0, allows you to work with hierarchical data types in SQL Server.

public class Category
{
    public int Id { get; set; }
    public HierarchyId HierarchyId { get; set; }
}

modelBuilder.Entity<Category>()
    .Property(c => c.HierarchyId)
    .HasConversion(
        v => v.ToString(), // Convert HierarchyId to string for storage
        v => HierarchyId.Parse(v) // Convert string back to HierarchyId
    );
    

Table Schema

SQL Script
CREATE TABLE Categories (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100),
    HierarchyId HIERARCHYID
);
    
Table Format
Column NameData TypeDescription
IdINTPrimary key
NameNVARCHAR(100)Name of the category
HierarchyIdHIERARCHYIDHierarchical data identifier

g. Efficient Bulk Operations

Real-Time Scenario

You need to perform bulk insert, update, or delete operations efficiently to handle large datasets.

Feature Explanation

Efficient Bulk Operations, supported by third-party libraries like EFCore.BulkExtensions, allow you to perform bulk operations with high performance.

context.BulkInsert(products);
context.BulkUpdate(products);
context.BulkDelete(products);
    

Table Schema

SQL Script
CREATE TABLE Products (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100)
);
    
Table Format
Column NameData TypeDescription
IdINTPrimary key
NameNVARCHAR(100)Name of the product

h. JSON Column Enhancements

Real-Time Scenario

You need to store and query JSON data within your database, leveraging the flexibility of JSON columns.

Feature Explanation

JSON Column Enhancements, introduced in EF Core 6.0, provide improved support for storing and querying JSON data.

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

var query = context.Customers
    .Where(c => EF.Functions.JsonContains(c.JsonData, "{\"key\":\"value\"}"))
    .ToList();
    

Table Schema

SQL Script
CREATE TABLE Customers (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100),
    JsonData NVARCHAR(MAX)
);
    
Table Format
Column NameData TypeDescription
IdINTPrimary key
NameNVARCHAR(100)Name of the customer
JsonDataNVARCHAR(MAX)JSON data stored as string
Sample JSON
{
    "key": "value",
    "nested": {
        "subkey": "subvalue"
    },
    "array": [1, 2, 3]
}
    

Conclusion

Entity Framework continues to evolve, offering powerful features and capabilities that can greatly enhance your data access layer. By leveraging these lesser-known tricks and best practices, you can write more efficient, maintainable, and robust code. Stay updated with the latest features and continuously explore the depths of EF to unlock its full potential.

A Journey of Code Transformation: From Custom Validators to .NET Core Data Annotations

Introduction

Recently, I embarked on an intriguing journey of code analysis for a migration project, transitioning an application from .NET to .NET Core (.NET 8). As I delved into the codebase, I discovered a labyrinth of validation logic embedded within the model classes. A custom validator had been meticulously crafted to handle these validations, but it was clear that this approach had led to a bloated and complex codebase.

As I navigated through the code, a realization dawned upon me: many of these custom validators could be elegantly replaced with .NET’s in-built Data Annotations. This revelation was a game-changer. By leveraging these powerful attributes, we could simplify the validation logic, making it more readable and maintainable.

However, not all validations were straightforward. Some were intricate and required a level of customization that the standard Data Annotations couldn’t provide. This is where Custom Data Annotations came into play. By designing custom attributes tailored to our specific needs, we could handle even the most complex validation scenarios with ease.

The process of redesigning the application was both challenging and rewarding. As we refactored the code, we witnessed a significant reduction in the codebase. The validations became highly configurable, testable, and maintainable. The transformation was remarkable.

To illustrate the impact of this transformation, I have highlighted some of the key Data Annotations that played a pivotal role in our success. Additionally, I have showcased a few of the new annotations introduced in .NET 8 and .NET 9, which further enhanced our validation capabilities.

This journey not only improved the application’s architecture but also reinforced the importance of leveraging modern frameworks and tools to achieve cleaner, more efficient code. It was a testament to the power of .NET Core and the elegance of Data Annotations in creating robust and maintainable applications.

Intro to Data Annotations

Data Annotations in C# are a powerful way to add metadata to your classes and properties, enabling validation, formatting, and database schema generation. In this blog, we’ll explore various Data Annotations, including those newly introduced in .NET 8 and .NET 9, with real-world implementation scenarios and sample code.

Data Annotations are attributes that provide a declarative way to enforce validation rules, format data, and define database schema details. They are commonly used in ASP.NET Core MVC and Entity Framework Core.

Commonly Used Data Annotations

Required

The Required attribute ensures that a property is not null or empty.

Real-World Scenario: In a user registration form, the email field must be filled out to create an account.

Sample Code:

StringLength

The StringLength attribute specifies the minimum and maximum length of a string property.

Real-World Scenario: A product name should be between 5 and 100 characters long.

Sample Code:

public class Product
{
    [StringLength(100, MinimumLength = 5, ErrorMessage = "Product name must be between 5 and 100 characters.")]
    public string Name { get; set; }
}

Range

The Range attribute defines the minimum and maximum value for a numeric property.

Real-World Scenario: An employee’s age should be between 18 and 65.

Sample Code:

public class Employee
{
    [Range(18, 65, ErrorMessage = "Age must be between 18 and 65.")]
    public int Age { get; set; }
}

EmailAddress

The EmailAddress attribute validates that a property contains a valid email address.

Real-World Scenario: Ensuring that the contact email provided by a customer is valid.

Sample Code:

public class Contact
{
    [EmailAddress(ErrorMessage = "Invalid email address.")]
    public string Email { get; set; }
}

Compare

The Compare attribute compares two properties to ensure they match.

Real-World Scenario: Confirming that the password and confirm password fields match during user registration.

Sample Code:

public class UserRegistration

{
    [Required]
    public string Password { get; set; }

    [Compare("Password", ErrorMessage = "Passwords do not match.")]
    public string ConfirmPassword { get; set; }
}

RegularExpression

The RegularExpression attribute validates that a property matches a specified regular expression pattern.

Real-World Scenario: Validating that a username contains only alphanumeric characters.

Sample Code:

public class User
{
    [RegularExpression(@"^[a-zA-Z0-9]*$", ErrorMessage = "Username can only contain alphanumeric characters.")]
    public string Username { get; set; }
}

MaxLength

The MaxLength attribute specifies the maximum length of a string or array property.

Real-World Scenario: Limiting the length of a product description to 500 characters.

Sample Code:

public class Product
{
    [MaxLength(500, ErrorMessage = "Description cannot exceed 500 characters.")]
    public string Description { get; set; }
}

MinLength

The MinLength attribute specifies the minimum length of a string or array property.

Real-World Scenario: Ensuring that a password is at least 8 characters long.

Sample Code:

public class User
{
    [MinLength(8, ErrorMessage = "Password must be at least 8 characters long.")]
    public string Password { get; set; }
}

CreditCard

The CreditCard attribute validates that a property contains a valid credit card number.

Real-World Scenario: Validating the credit card number provided during an online purchase.

Sample Code:

public class Payment

{

    [CreditCard(ErrorMessage = "Invalid credit card number.")]

    public string CardNumber { get; set; }

}

Url

The Url attribute validates that a property contains a valid URL.

Real-World Scenario: Ensuring that the website URL provided by a business is valid.

Sample Code:

public class Business
{
    [Url(ErrorMessage = "Invalid URL.")]
    public string Website { get; set; }
}

Phone

The Phone attribute validates that a property contains a valid phone number.

Real-World Scenario: Validating the phone number provided during user registration.

Sample Code:

public class User
{

    [Phone(ErrorMessage = "Invalid phone number.")]
    public string Phone { get; set; }

}

Custom Validation

The CustomValidation attribute allows for custom validation logic.

Real-World Scenario: Validating that a user’s age is at least 18 years old using a custom validation method.

Sample Code:

public class User
{

    [CustomValidation(typeof(UserValidator), "ValidateAge")]
    public int Age { get; set; }

}

public static class UserValidator
{
    public static ValidationResult ValidateAge(int age, ValidationContext context)
    {
        if (age < 18)
        {
            return new ValidationResult("User must be at least 18 years old.");
        }

        return ValidationResult.Success;
    }
}

New Data Annotations in .NET 8 and .NET 9

PhoneNumber (Introduced in .NET 8)

The PhoneNumber attribute validates that a property contains a valid phone number.

Real-World Scenario: Validating the phone number provided during user registration.

Sample Code:

public class User
{

    [PhoneNumber(ErrorMessage = "Invalid phone number.")]
    public string Phone { get; set; }

}

Url (Introduced in .NET 9)

The Url attribute validates that a property contains a valid URL.

Real-World Scenario: Ensuring that the website URL provided by a business is valid.

Sample Code:

public class Business
{

    [Url(ErrorMessage = "Invalid URL.")]
    public string Website { get; set; }

}

CreditCard (Introduced in .NET 9)

The CreditCard attribute validates that a property contains a valid credit card number.

Real-World Scenario: Validating the credit card number provided during an online purchase.

Sample Code:

public class Payment
{

    [CreditCard(ErrorMessage = "Invalid credit card number.")]
    public string CardNumber { get; set; }

}

Real-World Implementation Scenarios

User Registration Form

In a user registration form, it’s crucial to validate the user’s input to ensure data integrity and security. Using Data Annotations, we can enforce rules such as required fields, valid email addresses, and phone numbers.

Product Management System

In a product management system, we need to ensure that product names and descriptions meet specific length requirements. Data Annotations help us enforce these rules declaratively.

Employee Management System

In an employee management system, we need to validate employee details such as age and email address. Data Annotations provide a simple way to enforce these validation rules.

Sample Code

Here’s a complete example of a user registration form using various Data Annotations:

public class UserRegistration
{

    [Required(ErrorMessage = "Username is required.")]
    public string Username { get; set; }

    [Required(ErrorMessage = "Email is required.")]
    [EmailAddress(ErrorMessage = "Invalid email address.")]
    public string Email { get; set; }

    [Required(ErrorMessage = "Password is required.")]
    [StringLength(100, MinimumLength = 6, ErrorMessage = "Password must be at least 6 characters long.")]
    public string Password { get; set; }

    [Phone(ErrorMessage = "Invalid phone number.")]
    public string Phone { get; set; }

}

Conclusion

Data Annotations in C# provide a powerful and declarative way to enforce validation rules, format data, and define database schema details. With the introduction of new annotations in .NET 8 and .NET 9, developers have even more tools at their disposal to ensure data integrity and improve user experience.

References