Eager Loading in Entity Framework Core

Eager Loading in Entity Framework Core

When building data-rich applications such as an E-Commerce System, we often need not just one entity but a complete picture of related information, an Order with its Customer, Products, Addresses, and Status. If each of these relationships were fetched separately, the application would send multiple queries to the database, resulting in excessive round-trips, degraded performance, and slower API responses. Please read our previous article on Lazy Loading in EF Core.

Eager Loading eliminates this inefficiency by allowing Entity Framework Core to fetch all required data in a single, optimized query. This approach ensures predictable performance, reduces database load, and provides consistency by retrieving the entire data graph upfront.

What Is Eager Loading in Entity Framework Core?

Eager Loading is a data retrieval technique in EF Core that loads the primary entity and its related entities in a single SQL query. It is implemented using Include() and ThenInclude() methods, which instruct EF Core to automatically join related tables during query execution. This ensures that when you access an entity (such as Order), all necessary relationships, including Customer, OrderItems, Product, and PaymentDetails, are already populated and ready for use.

This approach is especially valuable when you know in advance that related data will be needed immediately, such as when preparing an order summary, generating an invoice, or displaying a detailed dashboard that combines multiple entities.

Real-world Analogy

Imagine you are generating an invoice for an order. You don’t just need the order itself, you also need:

  • The customer’s details
  • The list of ordered products
  • The pricing and category information
  • The shipping and billing addresses

Instead of fetching each piece of information separately, Eager Loading brings everything in one go, like receiving a complete invoice packet instead of assembling it from multiple departments.
This results in a smoother, faster, and more predictable API performance.

Key Characteristics of Eager Loading
  • Single-Query Execution: All related data is fetched in a single database call, minimizing round-trips.
  • Prevents the N+1 Problem: By avoiding repeated queries for each related entity, Eager Loading prevents the notorious “N+1” performance issue common with Lazy Loading.
  • Predictable Performance: The query executes once, and response time remains consistent regardless of the number of related entities.
  • Ideal Use Case: Perfect for scenarios where related data is always needed, such as dashboards, detailed views, reporting pages, or invoice generation.
How to Implement Eager Loading in Entity Framework Core

To implement Eager Loading, EF Core provides two key methods:

  • .Include() → Loads related data for a single level of navigation (e.g., Order → Customer)
  • .ThenInclude() → Loads data for nested or deeper related entities (e.g., Order → Customer → Profile)

You can chain multiple .Include() and .ThenInclude() calls to load multiple layers of relationships, creating a complete object graph—for instance, Order → Customer → Profile → Addresses and Order → OrderItems → Product → Category.

Creating Response DTOs

In our E-Commerce Project, DTOs such as OrderResponseDTO, CustomerResponseDTO, and OrderItemResponseDTO ensure that only clean, structured data is returned to clients, avoiding serialization issues while keeping responses lightweight. Let us create the Response DTOs, which will be used to return Data to the client.

OrderItemResponseDTO

Create a class file named OrderItemResponseDTO.cs within the DTOs folder, then copy and paste the following code. It represents each ordered product in response payloads with product and category info.

namespace ECommerceApp.DTOs
{
    // Represents an item inside an order when returning data
    public class OrderItemResponseDTO
    {
        public string ProductName { get; set; } = null!;
        public string Category { get; set; } = null!;
        public int Quantity { get; set; }
        public decimal UnitPrice { get; set; }
    }
}
CustomerResponseDTO

Create a class file named CustomerResponseDTO.cs within the DTOs folder, then copy and paste the following code. It returns summarized customer data, including name, email, and addresses.

namespace ECommerceApp.DTOs
{
    // Summarized view of the customer associated with an order
    public class CustomerResponseDTO
    {
        public int CustomerId { get; set; }
        public string Name { get; set; } = null!;
        public string Email { get; set; } = null!;
        public string? Phone { get; set; }
        public string? DisplayName { get; set; }
        public string? Gender { get; set; }
        public string? DateOfBirth { get; set; }
    }
}
OrderResponseDTO

Create a class file named OrderResponseDTO.cs within the DTOs folder, then copy and paste the following code. It wraps complete order details (customer info, order items, and addresses) for API responses.

namespace ECommerceApp.DTOs
{
    // Represents an order returned from API endpoints
    public class OrderResponseDTO
    {
        public int OrderId { get; set; }
        public string OrderDate { get; set; } = null!;
        public string Status { get; set; } = null!;
        public decimal TotalAmount { get; set; }
        public string ShippingAddress { get; set; } = null!;
        public string BillingAddress { get; set; } = null!;
        public CustomerResponseDTO Customer { get; set; } = null!;
        public List<OrderItemResponseDTO> Items { get; set; } = new();
    }
}
Modifying Order Controller:

Please modify the Order Controller as follows to demonstrate Eager Loading. The following code is self-explanatory; please read the comment lines for better understanding.

using ECommerceApp.Data;
using ECommerceApp.DTOs;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace ECommerceApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
private readonly AppDbContext _context;
public OrderController(AppDbContext context)
{
_context = context;
}
// =====================================================
// GET: api/order/all  
// PURPOSE:
//     Fetches all active orders with their full relational data graph.
//     Demonstrates how Eager Loading retrieves multiple related entities
//     (Customer, Profile, Addresses, OrderItems, Product, Category, etc.)
//     in a single optimized SQL query.
// =====================================================
[HttpGet("all")]
public async Task<ActionResult<IEnumerable<OrderResponseDTO>>> GetAllOrders()
{
try
{
// STEP 1: Query Orders and eagerly load all required relationships.
// .Include() loads one related entity.
// .ThenInclude() allows chaining into deeper levels of relationships.
// .AsNoTracking() improves performance for read-only data (no change tracking).
var orders = await _context.Orders
.AsNoTracking()
.Include(o => o.Customer).ThenInclude(c => c.Profile)                // Order → Customer → Profile
.Include(o => o.Customer).ThenInclude(c => c.Addresses)              // Order → Customer → Addresses
.Include(o => o.OrderItems).ThenInclude(oi => oi.Product)            // Order → OrderItems → Product
.ThenInclude(p => p.Category)                                        // Product → Category (multi-level)
.Include(o => o.OrderStatus)                                         // Order → OrderStatus
.Include(o => o.ShippingAddress)                                     // Order → ShippingAddress
.Include(o => o.BillingAddress)                                      // Order → BillingAddress
.Where(o => o.IsActive)                                              // Fetch only active orders
.ToListAsync();
// STEP 2: Map entities to DTOs.
// DTOs ensure that the API only exposes structured, safe, non-circular data.
var result = orders.Select(o => new OrderResponseDTO
{
OrderId = o.OrderId,
OrderDate = o.OrderDate.ToString("yyyy-MM-dd HH:mm:ss"),
Status = o.OrderStatus.Name,
TotalAmount = o.TotalAmount,
ShippingAddress = $"{o.ShippingAddress?.Line1}, {o.ShippingAddress?.City}, {o.ShippingAddress?.Country}",
BillingAddress = $"{o.BillingAddress?.Line1}, {o.BillingAddress?.City}, {o.BillingAddress?.Country}",
// Nested DTO representing Customer + Profile relationship.
Customer = new CustomerResponseDTO
{
CustomerId = o.Customer.CustomerId,
Name = o.Customer.Name,
Email = o.Customer.Email,
Phone = o.Customer.Phone,
DisplayName = o.Customer.Profile?.DisplayName,
Gender = o.Customer.Profile?.Gender,
DateOfBirth = o.Customer.Profile?.DateOfBirth.ToString("yyyy-MM-dd")
},
// Collection mapping for Order → OrderItems → Product → Category
Items = o.OrderItems?.Select(i => new OrderItemResponseDTO
{
ProductName = i.Product.Name,
Category = i.Product.Category.Name,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList() ?? new List<OrderItemResponseDTO>()
});
// STEP 3: Return the list of mapped DTOs.
// At this point, all related data has been loaded in a single query.
return Ok(result);
}
catch (Exception ex)
{
// Handles runtime errors gracefully and returns diagnostic details.
return StatusCode(500, new
{
Message = "An error occurred while fetching orders.",
ErrorMessage = ex.Message
});
}
}
// =====================================================
// GET: api/order/{id}
// PURPOSE:
//     Retrieves one specific order by ID, including all related data.
//     Demonstrates Eager Loading across one-to-one, one-to-many,
//     and many-to-many relationships in a single query.
// =====================================================
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderResponseDTO>> GetOrderById(int id)
{
try
{
// STEP 1: Query the target order by its ID, including all necessary navigation properties.
var o = await _context.Orders
.AsNoTracking()
.Include(o => o.Customer).ThenInclude(c => c.Profile)                // One-to-one: Customer → Profile
.Include(o => o.Customer).ThenInclude(c => c.Addresses)              // One-to-many: Customer → Addresses
.Include(o => o.OrderItems).ThenInclude(oi => oi.Product)            // One-to-many: Order → OrderItems
.ThenInclude(p => p.Category)                                        // Many-to-one: Product → Category
.Include(o => o.OrderStatus)
.Include(o => o.ShippingAddress)
.Include(o => o.BillingAddress)
.FirstOrDefaultAsync(o => o.OrderId == id && o.IsActive);
// STEP 2: Handle not found scenario.
if (o == null)
return NotFound($"Order with ID {id} not found.");
// STEP 3: Map the entity and its related data to a DTO.
// This keeps response shape predictable and serialization-safe.
var response = new OrderResponseDTO
{
OrderId = o.OrderId,
OrderDate = o.OrderDate.ToString("yyyy-MM-dd HH:mm:ss"),
Status = o.OrderStatus.Name,
TotalAmount = o.TotalAmount,
ShippingAddress = $"{o.ShippingAddress?.Line1}, {o.ShippingAddress?.City}, {o.ShippingAddress?.Country}",
BillingAddress = $"{o.BillingAddress?.Line1}, {o.BillingAddress?.City}, {o.BillingAddress?.Country}",
Customer = new CustomerResponseDTO
{
CustomerId = o.Customer.CustomerId,
Name = o.Customer.Name,
Email = o.Customer.Email,
Phone = o.Customer.Phone,
DisplayName = o.Customer.Profile?.DisplayName,
Gender = o.Customer.Profile?.Gender,
DateOfBirth = o.Customer.Profile?.DateOfBirth.ToString("yyyy-MM-dd")
},
Items = o.OrderItems?.Select(i => new OrderItemResponseDTO
{
ProductName = i.Product.Name,
Category = i.Product.Category.Name,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList() ?? new List<OrderItemResponseDTO>()
};
// STEP 4: Return a complete, fully loaded order response.
// Thanks to Eager Loading, all relationships are already available.
return Ok(response);
}
catch (Exception ex)
{
// Error handling with descriptive message.
return StatusCode(500, new
{
Message = "An error occurred while fetching orders.",
ErrorMessage = ex.Message
});
}
}
}
}
Testing Get All Orders Endpoint
  • Method: GET
  • URL: https://localhost:5001/api/Order/all
  • Description: Fetches all active orders from the database along with related entities (Customer → Profile, Addresses, Orders → Items, Products, Categories).
Testing Get Order by Id Endpoint
  • Method: GET
  • URL: https://localhost:5001/api/Order/1
  • Description: Fetches a single order with detailed relationship data (customer, addresses, items, etc.)
How Eager Loading Works Internally in EF Core

When EF Core executes a query with .Include() or .ThenInclude(), then under the hood, EF Core:

  1. Builds a composite SQL query that joins all required tables together using LEFT JOIN or INNER JOIN as needed.
  2. Execute the Query in a single database round-trip.
  3. It creates the main entity (Order) and automatically attaches the related entities (Customer, OrderItem, Product, etc.).
  4. The related data is cached in the ChangeTracker so that EF Core doesn’t re-fetch it again within the same context.
What Types of Joins EF Core Uses in Eager Loading

Entity Framework Core intelligently decides the type of SQL join to use during eager loading based on the relationship configuration between entities, particularly whether the foreign key is nullable (optional relationship) or non-nullable (required relationship).

  • LEFT JOIN (Optional Relationship): Used when a related entity might not exist. EF Core includes all records from the main entity and fills related data only where it exists. Example: Order → ShippingAddress, some orders might not have a shipping address yet.
  • INNER JOIN (Required Relationship): Used when every main entity must have a corresponding related entity. Only matching records from both tables are included. Example: Order → Customer, every order must belong to a customer.

EF Core automatically determines the correct join type during query translation, so developers don’t need to specify manual joins; the framework ensures that the generated SQL accurately reflects entity relationships defined in the model.

Real-World Scenario: Get Recent Active Orders with Filtered Products and Customers

Admin wants to fetch:

  • Orders placed in the last {days} days 
  • Minimum total amount {minAmount} 
  • Only active orders (IsActive = true)
  • Only active customers (Customer.IsActive = true)
  • Include:
      • Customer with Profile
      • OrderItems with only active Products
      • Shipping and Billing addresses
Controller Example: Filtered Eager Loading
using ECommerceApp.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace ECommerceApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
private readonly AppDbContext _context;
public OrderController(AppDbContext context)
{
_context = context;
}
// =================================================
// GET: api/order/filtered-orders?days=30&minAmount=1000
// PURPOSE:
//   Demonstrates EF Core 8 Filtered Include with multi-level relationships.
//   Only active Orders, Customers, and Products are retrieved.
// =================================================
[HttpGet("filtered-orders")]
public async Task<IActionResult> GetFilteredOrders([FromQuery] int days = 30, [FromQuery] decimal minAmount = 1000)
{
// STEP 1: Build root-level query with all base filters.
// - Filters on Orders and Customers are applied in SQL WHERE clause.
// - Filters on collections (OrderItems, Addresses) use Filtered Include.
var query = _context.Orders
.Where(o => o.IsActive &&
o.OrderDate >= DateTime.Now.AddDays(-days) &&
o.TotalAmount >= minAmount &&
o.Customer.IsActive)
.AsNoTracking()
// Customer and Profile (reference navigations, no filtering)
.Include(o => o.Customer)
.ThenInclude(c => c.Profile)
// Filtered Include — OrderItems with Active Products
.Include(o => o.OrderItems
.Where(i => i.Product.IsActive)
)
.ThenInclude(i => i.Product)
.ThenInclude(p => p.Category) // reference, so no .Where() allowed
// Include Billing and Shipping Addresses
.Include(o => o.BillingAddress)
.Include(o => o.ShippingAddress);
// STEP 2: Execute query
var orders = await query.ToListAsync();
if (!orders.Any())
{
return Ok(new
{
Message = "No matching orders found for the given criteria.",
TotalOrders = 0
});
}
// STEP 3: Build simplified projection
var result = orders.Select(o => new
{
o.OrderId,
o.OrderDate,
o.TotalAmount,
Customer = o.Customer.Name,
Profile = o.Customer.Profile?.DisplayName,
Cities = o.Customer.Addresses?.Select(a => a.City).ToList(),
ProductNames = o.OrderItems.Select(i => i.Product.Name).ToList(),
ItemCount = o.OrderItems.Count
});
return Ok(new
{
Message = $"Filtered eager loading executed successfully (last {days} days, min ₹{minAmount}).",
TotalOrders = result.Count(),
SampleResult = result.Take(3)
});
}
}
}
When to Use Eager Loading in EF Core

Eager Loading is best suited for Read-Heavy Operations where you know upfront that you will need both the main entity and its related data. It ensures that everything is fetched in a single, efficient database call, providing predictable performance and simpler code.

You should use Eager Loading when:

  • Related data is always required alongside the primary entity.
  • The data is being used for reporting, analytics, dashboards, or detailed views that combine multiple tables.
  • You want to avoid the N+1 query problem caused by Lazy Loading, which issues multiple small queries.
Real-Time Scenarios
  • Displaying full order details along with customer and product information on an admin page.
  • Generating invoices or financial reports that need order, customer, and payment data together.
  • Building analytics dashboards showing aggregated sales, customer segments, or product performance.

What is the Difference Between Eager Loading and Lazy Loading in EF Core?

Both Eager Loading and Lazy Loading are strategies for retrieving related data in Entity Framework Core, but they differ in when and how the related entities are fetched from the database.

Eager Loading

In Eager Loading, EF Core retrieves the main entity and all its specified related entities together as soon as the query is executed. It achieves this by using SQL joins (INNER JOIN or LEFT JOIN) to bring back the complete object graph in a single query.

How it works: When we query an Order and use .Include(o => o.Customer).ThenInclude(c => c.Profile), EF Core generates one SQL query joining the Orders, Customers, and Profiles tables.

Advantages:
  • Only one database round trip, and everything you need is fetched at once.
  • Predictable and efficient for views or operations that always require related data.
  • Eliminates the N+1 query problem.

Disadvantages:

  • It can cause over-fetching, and unnecessary related data may be retrieved.
  • Larger result sets may consume more memory and increase query execution time if many unnecessary relationships are loaded.

Example Use Case: When displaying a complete order summary page or generating invoices, where all related entities (Customer, Items, Product, Address) are always needed.

Lazy Loading

In Lazy Loading, EF Core retrieves only the main entity initially. Related entities are loaded on demand, that is, when their navigation properties are accessed for the first time.

How it works: When we query an Order, only the Orders table is fetched. If our code later accesses order.Customer, EF Core silently issues a new SQL query to retrieve that customer.

Advantages:
  • Loads only what is needed, reducing initial query size.
  • Efficient when related data is rarely used or conditionally accessed.
Disadvantages:
  • May cause multiple round-trips to the database (the N+1 problem).
  • It can severely degrade performance when iterating over many entities.
  • May lead to serialization loops if not handled carefully in APIs.

Example Use Case: When a user profile page loads basic order information first, and related details (like order items or addresses) are only fetched when the user expands a section in the UI.

Which Is Better – Eager Loading or Lazy Loading?

There is no universal “better” choice; it depends entirely on the application’s context and performance goals.

Use Eager Loading when:

  • You know in advance that related data is always needed.
  • You want predictable performance and fewer database round-trips.
  • You’re generating reports, invoices, or performing batch data processing.

Use Lazy Loading when:

  • Related data is optional or infrequently accessed.
  • You want to keep initial queries lightweight.
  • Data access depends on user interaction or conditional business logic.

Ultimately, the choice should align with the data access pattern of our application. For example, in our E-Commerce Project:

  • The Order module (which always needs related Customer, Items, and Addresses) is a strong candidate for Eager Loading.
  • The Customer module, where we only fetch related Orders when the user opens the “Order History” tab, benefits from Lazy Loading.

Both approaches are valuable tools in EF Core. Eager Loading prioritizes completeness and consistency by fetching the full data graph upfront, while Lazy Loading emphasizes flexibility and efficiency by fetching data only when necessary.

The key is understanding the use case. Use Eager Loading for stable, well-defined relationships that are always needed, and Lazy Loading for dynamic, conditional scenarios where you want full control over what’s loaded and when. In the next article, I will discuss Explicit Loading in EF Core.

Leave a Reply

Your email address will not be published. Required fields are marked *