Intermediate

C-Sharp Design Patterns Data Access Patterns

This course covers Data Access Patterns in C#. It is aimed at developers who want to learn the different data access patterns, how to implement them in C#, and how to apply them in their...

Table of Contents

  1. Course Overview
  2. Module 2: Repository Pattern in C#
  1. Module 3: Unit of Work Pattern in C#
  1. Module 4: Lazy Load Pattern in C#
  1. Architecture of the MyShop example application
  2. Domain models
  3. Summary of patterns and their advantages

1. Course Overview

This course covers Data Access Patterns in C#. It is aimed at developers who want to learn the different data access patterns, how to implement them in C#, and how to apply them in their applications.

Topics covered

  • Repository Pattern: encapsulate data access logic
  • Unit of Work: group transactions into a single operation
  • Lazy Loading: load data only when it is needed

Learning Objectives

  • Understand the characteristics of repository, unit of work and lazy loading
  • Understanding the benefits and tradeoffs of each pattern
  • Implement these patterns in new or existing solutions
  • Identify and exploit existing implementations

Prerequisites

  • Knowledge of C# syntax
  • Know how to compile and run .NET applications

2. Repository Pattern in C#

2.1 Introduction to the Repository Pattern

The problem without Repository Pattern

In a typical repository-less application, the controller (or consumer) communicates directly with the data access layer, which in turn queries the database.

Controller --> Couche d'acces aux donnees --> Base de donnees

This model poses several problems:

  1. Tight coupling: the controller knows the ORM (e.g. Entity Framework) used below
  2. Not testable: unit tests have side effects (creation/deletion in database)
  3. Code duplication: if we want to reuse the same access logic, we duplicate the code
  4. Lack of extensibility: impossible to intercept or enrich the entities before returning them to the consumer

The solution: the Repository Pattern

The repository introduces an abstraction layer that encapsulates all data access code. The consumer (controller) no longer knows whether we are using Entity Framework, NHibernate, files on disk, etc.

Controller --> IRepository<T> --> Repository concret --> Base de donnees

Advantages of the Repository Pattern:

  • Consumer is separated from the data access layer
  • The code becomes testable: we can inject a false repository into the tests
  • Entities can be modified and enriched inside the repository before being returned
  • Shareable abstraction: less code duplication
  • Improved maintainability

A repository is simply an abstraction that encapsulates your access to data, making the code testable, reusable and maintainable.


2.2 The example application

The demo application is an internal ordering system (MyShop) using ASP.NET Core with Entity Framework Core and SQLite.

Project structure

ProjectRole
MyShop.DomainDomain models (Customer, Order, Product, LineItem)
MyShop.InfrastructureAccess to data (repositories, ShoppingContext)
MyShop.WebWeb application (controllers, views, models)
MyShop.Web.TestsUnit Testing

The initial problem in OrderController (before refactoring)

// AVANT : le controller est directement couple au DbContext
public class OrderController : Controller
{
    private ShoppingContext context; // Connait EF Core directement !

    public IActionResult Index()
    {
        var orders = context.Orders
            .Include(o => o.LineItems)
            .ThenInclude(li => li.Product)
            .Where(o => o.OrderDate > DateTime.UtcNow.AddDays(-1))
            .ToList();
        return View(orders);
    }
}

2.3 Presentation of the Generic Repository

The IRepository<T> interface

The first step is to define the contract of the repository in the form of an interface. A repository is typically responsible for CRUD (Create, Read, Update, Delete) operations.

// MyShop.Infrastructure/Repositories/IRepository.cs
using System;
using System.Collections.Generic;
using System.Linq.Expressions;

namespace MyShop.Infrastructure.Repositories
{
    public interface IRepository<T>
    {
        T Add(T entity);
        T Update(T entity);
        T Get(Guid id);
        IEnumerable<T> All();
        IEnumerable<T> Find(Expression<Func<T, bool>> predicate);
        void SaveChanges();
    }
}

Explanation of methods:

  • Add(T entity): adds an entity and returns it
  • Update(T entity): updates an entity and returns it
  • Get(Guid id): retrieves an entity by its identifier
  • All(): returns all entities
  • Find(predicate): returns the entities corresponding to the predicate (lambda expression)
  • SaveChanges(): validates changes in the database

The generic implementation: GenericRepository<T>

Instead of duplicating the CRUD code in each concrete repository, we create a GenericRepository which provides a reusable base implementation.

// MyShop.Infrastructure/Repositories/GenericRepository.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;

namespace MyShop.Infrastructure.Repositories
{
    public abstract class GenericRepository<T>
        : IRepository<T> where T : class
    {
        protected ShoppingContext context;

        public GenericRepository(ShoppingContext context)
        {
            this.context = context;
        }

        public virtual T Add(T entity)
        {
            return context.Add(entity).Entity;
        }

        public virtual IEnumerable<T> Find(Expression<Func<T, bool>> predicate)
        {
            return context.Set<T>()
                .AsQueryable()
                .Where(predicate).ToList();
        }

        public virtual T Get(Guid id)
        {
            return context.Find<T>(id);
        }

        public virtual IEnumerable<T> All()
        {
            return context.Set<T>().ToList();
        }

        public virtual T Update(T entity)
        {
            return context.Update(entity).Entity;
        }

        public void SaveChanges()
        {
            context.SaveChanges();
        }
    }
}

Key points:

  • abstract class: we cannot directly instantiate the GenericRepository, only its subclasses
  • where T: class: generic type constraint (T must be a reference class)
  • All methods are virtual to allow subclasses to override them
  • context is protected to be accessible in subclasses

2.4 Generic Repository extension

Choice of repositories to create

By analyzing domain models, we determine which aggregates require a repository:

  • CustomerRepository: the customer is a root aggregate
  • OrderRepository: the order is a root aggregate
  • ProductRepository: the product is a root aggregate
  • LineItemRepository: not necessary - order lines are managed via the Order (root aggregate)

ProductRepository

// MyShop.Infrastructure/Repositories/ProductRepository.cs
using MyShop.Domain.Models;
using System.Linq;

namespace MyShop.Infrastructure.Repositories
{
    public class ProductRepository : GenericRepository<Product>
    {
        public ProductRepository(ShoppingContext context) : base(context)
        {
        }

        public override Product Update(Product entity)
        {
            var product = context.Products
                .Single(p => p.ProductId == entity.ProductId);

            product.Price = entity.Price;
            product.Name = entity.Name;

            return base.Update(product);
        }
    }
}

CustomerRepository

// MyShop.Infrastructure/Repositories/CustomerRepository.cs
using MyShop.Domain.Models;
using System.Linq;

namespace MyShop.Infrastructure.Repositories
{
    public class CustomerRepository : GenericRepository<Customer>
    {
        public CustomerRepository(ShoppingContext context) : base(context)
        {
        }

        public override Customer Update(Customer entity)
        {
            var customer = context.Customers
                .Single(c => c.CustomerId == entity.CustomerId);

            customer.Name = entity.Name;
            customer.City = entity.City;
            customer.PostalCode = entity.PostalCode;
            customer.ShippingAddress = entity.ShippingAddress;
            customer.Country = entity.Country;

            return base.Update(customer);
        }
    }
}

OrderRepository

The OrderRepository overrides the Find method to include associated entities (eager loading of LineItems and Products):

// MyShop.Infrastructure/Repositories/OrderRepository.cs
using Microsoft.EntityFrameworkCore;
using MyShop.Domain.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;

namespace MyShop.Infrastructure.Repositories
{
    public class OrderRepository : GenericRepository<Order>
    {
        public OrderRepository(ShoppingContext context) : base(context)
        {
        }

        public override IEnumerable<Order> Find(Expression<Func<Order, bool>> predicate)
        {
            return context.Orders
                .Include(order => order.LineItems)
                .ThenInclude(lineItem => lineItem.Product)
                .Where(predicate).ToList();
        }

        public override Order Update(Order entity)
        {
            var order = context.Orders
                .Include(o => o.LineItems)
                .ThenInclude(lineItem => lineItem.Product)
                .Single(o => o.OrderId == entity.OrderId);

            order.OrderDate = entity.OrderDate;
            order.LineItems = entity.LineItems;

            return base.Update(order);
        }
    }
}

Why override Find in OrderRepository? Because when we load an order, we always need its LineItems and the associated Product. Without this, these properties would be null when used.


2.5 Consume a Repository

Refactoring the OrderController

After introducing repositories, the controller only uses IRepository<T> (the interface), without any reference to ShoppingContext or Entity Framework:

// MyShop.Web/Controllers/OrderController.cs
using System;
using System.Diagnostics;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using MyShop.Domain.Models;
using MyShop.Infrastructure.Repositories;
using MyShop.Web.Models;

namespace MyShop.Web.Controllers
{
    public class OrderController : Controller
    {
        private readonly IRepository<Order> orderRepository;
        private readonly IRepository<Product> productRepository;

        // Injection de dependances via le constructeur
        public OrderController(IRepository<Order> orderRepository,
             IRepository<Product> productRepository)
        {
            this.orderRepository = orderRepository;
            this.productRepository = productRepository;
        }

        public IActionResult Index()
        {
            // Utilise le repository pour trouver les commandes recentes
            var orders = orderRepository.Find(
                order => order.OrderDate > DateTime.UtcNow.AddDays(-1));
            return View(orders);
        }

        public IActionResult Create()
        {
            var products = productRepository.All();
            return View(products);
        }

        [HttpPost]
        public IActionResult Create(CreateOrderModel model)
        {
            if (!model.LineItems.Any()) return BadRequest("Please submit line items");
            if (string.IsNullOrWhiteSpace(model.Customer.Name))
                return BadRequest("Customer needs a name");

            var customer = new Customer
            {
                Name = model.Customer.Name,
                ShippingAddress = model.Customer.ShippingAddress,
                City = model.Customer.City,
                PostalCode = model.Customer.PostalCode,
                Country = model.Customer.Country
            };

            var order = new Order
            {
                LineItems = model.LineItems
                    .Select(line => new LineItem
                    {
                        ProductId = line.ProductId,
                        Quantity = line.Quantity
                    })
                    .ToList(),
                Customer = customer
            };

            orderRepository.Add(order);
            orderRepository.SaveChanges();

            return Ok("Order Created");
        }
    }
}

Configuring Dependency Injection in Startup.cs

So that ASP.NET Core can inject repositories into controllers, we configure the DI container:

// MyShop.Web/Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

    // Enregistrement du DbContext
    services.AddTransient<ShoppingContext>();

    // Mapping interface -> implementation concrete
    services.AddTransient<IRepository<Customer>, CustomerRepository>();
    services.AddTransient<IRepository<Order>, OrderRepository>();
    services.AddTransient<IRepository<Product>, ProductRepository>();
}

How it works:

  • When ASP.NET creates an instance of OrderController, it examines the constructor
  • It sees that IRepository<Order> is requested and automatically injects an OrderRepository
  • The controller does not know that it is receiving an OrderRepository specifically, only that it has an IRepository<Order>

2.6 Test with a Fake Repository

One of the great advantages of the repository pattern is testability. You can inject a fake repository (mock) into the controller to test without side effects on the database.

Using Moq

The Moq library allows you to create “mock” objects which simulate the behavior of an interface.

// MyShop.Web.Tests/OrderControllerTests.cs
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using MyShop.Domain.Models;
using MyShop.Infrastructure.Repositories;
using MyShop.Web.Controllers;
using MyShop.Web.Models;
using System;

namespace MyShop.Web.Tests
{
    [TestClass]
    public class OrderControllerTests
    {
        [TestMethod]
        public void CanCreateOrderWithCorrectModel()
        {
            // ARRANGE - preparation des mocks et des donnees de test
            var orderRepository = new Mock<IRepository<Order>>();
            var productRepository = new Mock<IRepository<Product>>();

            var orderController = new OrderController(
                orderRepository.Object,
                productRepository.Object
            );

            var createOrderModel = new CreateOrderModel
            {
                Customer = new CustomerModel
                {
                    Name = "Filip Ekberg",
                    ShippingAddress = "Test address",
                    City = "Gothenburg",
                    PostalCode = "43317",
                    Country = "Sweden"
                },
                LineItems = new[]
                {
                    new LineItemModel { ProductId = Guid.NewGuid(), Quantity = 10 },
                    new LineItemModel { ProductId = Guid.NewGuid(), Quantity = 2 },
                }
            };

            // ACT - execution de l'action a tester
            orderController.Create(createOrderModel);

            // ASSERT - verification que le repository a bien ete appele
            orderRepository.Verify(
                r => r.Add(It.IsAny<Order>()),
                Times.AtLeastOnce()
            );
        }
    }
}

Important points about mocks:

  • new Mock<IRepository<Order>>() creates a fake object that implements the interface
  • orderRepository.Object returns the mock object which can be passed to the controller
  • Verify(...) allows you to verify that a method was called during the test
  • It.IsAny<Order>() means “it does not matter which Order is passed as a parameter”
  • Times.AtLeastOnce() means the method must be called at least once

Advantage: the test does not touch the real database, therefore no side effects.


2.7 Summary of module 2

This module covered:

  • The repository pattern shares data access code throughout the application (end of duplication)
  • Data access is encapsulated in the repository: the controller no longer knows Entity Framework
  • The code has become testable: we can inject a mock into the controller for unit tests
  • The repository can intercept and enrich entities before returning them to the consumer

3. Unit of Work Pattern in C#

3.1 Introduction to Unit of Work

The Unit of Work pattern is a natural complement to the Repository Pattern. It allows you to:

  • Group several operations (on different repositories) into a single transaction
  • Reduce the number of calls to the database
  • Make all repositories share the same data context

Working principle

UnitOfWork
├── CustomerRepository  ─┐
├── OrderRepository      ├─ Partagent le MEME ShoppingContext
└── ProductRepository   ─┘
         |
         └─> SaveChanges() --> Un seul COMMIT en base de donnees

Example flow:

  1. The application asks to create a client
  2. The application retrieves the products (via ProductRepository)
  3. The application creates an order (via OrderRepository)
  4. unitOfWork.SaveChanges() commits EVERYTHING in a single transaction

This significantly reduces the number of open connections to the database.


3.2 Unit of Work use case

The problem without Unit of Work

Without the pattern, if we try to update a client AND create an order with two different repositories, we get an error: the two repositories use two different instances of the ShoppingContext, which creates conflicts.

// PROBLEME : deux contextes differents, deux transactions separees
customerRepository.Update(customer); // Transaction 1 --> base de donnees
customerRepository.SaveChanges();     // Commit 1

orderRepository.Add(order);           // Transaction 2 --> base de donnees
orderRepository.SaveChanges();        // Commit 2 -- ERREUR possible

The error occurs because the customer updated in the first context is “unknown” in the second context when trying to reference it on the order.

The solution with Unit of Work

All repositories share the same context. We only commit once at the end.


3.3 Application of Unit of Work

The IUnitOfWork interface and the UnitOfWork class

// MyShop.Infrastructure/UnitOfWork.cs
using MyShop.Domain.Models;
using MyShop.Infrastructure.Repositories;

namespace MyShop.Infrastructure
{
    public interface IUnitOfWork
    {
        IRepository<Customer> CustomerRepository { get; }
        IRepository<Order> OrderRepository { get; }
        IRepository<Product> ProductRepository { get; }

        void SaveChanges();
    }

    public class UnitOfWork : IUnitOfWork
    {
        private ShoppingContext context;

        public UnitOfWork(ShoppingContext context)
        {
            this.context = context;
        }

        // Lazy initialization des repositories : crees seulement quand demandes
        private IRepository<Customer> customerRepository;
        public IRepository<Customer> CustomerRepository
        {
            get
            {
                if (customerRepository == null)
                {
                    customerRepository = new CustomerRepository(context);
                }
                return customerRepository;
            }
        }

        private IRepository<Order> orderRepository;
        public IRepository<Order> OrderRepository
        {
            get
            {
                if (orderRepository == null)
                {
                    orderRepository = new OrderRepository(context);
                }
                return orderRepository;
            }
        }

        private IRepository<Product> productRepository;
        public IRepository<Product> ProductRepository
        {
            get
            {
                if (productRepository == null)
                {
                    productRepository = new ProductRepository(context);
                }
                return productRepository;
            }
        }

        public void SaveChanges()
        {
            // Un seul SaveChanges pour tous les repositories !
            context.SaveChanges();
        }
    }
}

Key points:

  • The ShoppingContext is shared between all repositories via context
  • The repositories are initialized on demand (lazy initialization): pattern that we will see in detail in module 4
  • SaveChanges() calls context.SaveChanges() only once, which commits all pending operations

Refactoring OrderController with Unit of Work

// MyShop.Web/Controllers/OrderController.cs (avec Unit of Work)
using System;
using System.Diagnostics;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using MyShop.Domain.Models;
using MyShop.Infrastructure;
using MyShop.Web.Models;

namespace MyShop.Web.Controllers
{
    public class OrderController : Controller
    {
        private readonly IUnitOfWork unitOfWork;

        // Plus qu'un seul parametre : le Unit of Work
        public OrderController(IUnitOfWork unitOfWork)
        {
            this.unitOfWork = unitOfWork;
        }

        public IActionResult Index()
        {
            var orders = unitOfWork.OrderRepository
                .Find(order => order.OrderDate > DateTime.UtcNow.AddDays(-1));
            return View(orders);
        }

        public IActionResult Create()
        {
            var products = unitOfWork.ProductRepository.All();
            return View(products);
        }

        [HttpPost]
        public IActionResult Create(CreateOrderModel model)
        {
            if (!model.LineItems.Any()) return BadRequest("Please submit line items");
            if (string.IsNullOrWhiteSpace(model.Customer.Name))
                return BadRequest("Customer needs a name");

            // Chercher si le client existe deja
            var customer = unitOfWork.CustomerRepository
                .Find(c => c.Name == model.Customer.Name)
                .FirstOrDefault();

            if (customer != null)
            {
                // Client existant : on met a jour ses informations
                customer.ShippingAddress = model.Customer.ShippingAddress;
                customer.PostalCode = model.Customer.PostalCode;
                customer.City = model.Customer.City;
                customer.Country = model.Customer.Country;

                unitOfWork.CustomerRepository.Update(customer);
            }
            else
            {
                // Nouveau client
                customer = new Customer
                {
                    Name = model.Customer.Name,
                    ShippingAddress = model.Customer.ShippingAddress,
                    City = model.Customer.City,
                    PostalCode = model.Customer.PostalCode,
                    Country = model.Customer.Country
                };
            }

            var order = new Order
            {
                LineItems = model.LineItems
                    .Select(line => new LineItem
                    {
                        ProductId = line.ProductId,
                        Quantity = line.Quantity
                    })
                    .ToList(),
                Customer = customer
            };

            unitOfWork.OrderRepository.Add(order);

            // UN SEUL SaveChanges pour tout commiter en une transaction
            unitOfWork.SaveChanges();

            return Ok("Order Created");
        }
    }
}

What has changed:

  • We inject IUnitOfWork instead of two separate repositories
  • Repositories are accessed via unitOfWork.OrderRepository, unitOfWork.CustomerRepository, etc.
  • Only one unitOfWork.SaveChanges() at the end to commit all operations

DI Configuration for Unit of Work

// Startup.cs - ajout du Unit of Work
services.AddTransient<ShoppingContext>();
services.AddTransient<IUnitOfWork, UnitOfWork>();

Important note on granularity

The CustomerController, which only works with customers (one entity type), does not need the Unit of Work. Injecting IUnitOfWork everywhere would be bad practice because:

  • Consumer would not know which repositories are used
  • In a large solution, one can create several different Units of Work, each with repositories that make sense to be modified together

3.4 Summary of module 3

  • The Unit of Work groups several repositories into a coherent whole
  • All repositories share the same data context
  • A single SaveChanges() commits all operations in one transaction
  • Significant reduction in the number of database calls in high performance applications
  • Code changes are minimal: great added value for little effort

4. Lazy Load Pattern in C#

4.1 Introduction to Lazy Load Pattern

The goal of the Lazy Load Pattern is to load data only when it is actually requested. This helps optimize performance by avoiding loading rarely used data.

Concrete example: a customer’s profile photo is large and is only displayed in a specific view. It would be inefficient to load it every time you load a client.

The 4 variants of the Lazy Load Pattern

VariantDescription
Lazy InitializationInitializes support field only when requested
Virtual ProxiesDerived class that intercepts access to properties
Value HoldersObject that contains a loading function and hides the result
Ghost ObjectsEntity in partial state that loads completely on first request

4.2 Lazy Initialization

Principle

lazy initialization only loads data on the first access to a property, if the support field is null.

// Exemple conceptuel de lazy initialization
private byte[] _profilePicture;
public byte[] ProfilePicture
{
    get
    {
        if (_profilePicture == null)
        {
            // Charge la donnee uniquement si pas encore chargee
            _profilePicture = profilePictureService.GetFor(Name);
        }
        return _profilePicture;
    }
}

Customer model with ProfilePicture property (Lazy Initialization version)

// MyShop.Domain/Models/Customer.cs (version avec lazy initialization)
namespace MyShop.Domain.Models
{
    public class Customer
    {
        public Guid CustomerId { get; set; }
        public string Name { get; set; }
        public string ShippingAddress { get; set; }
        public string City { get; set; }
        public string PostalCode { get; set; }
        public string Country { get; set; }

        private byte[] _profilePicture;
        public byte[] ProfilePicture
        {
            get
            {
                if (_profilePicture == null)
                {
                    _profilePicture = profilePictureService.GetFor(Name);
                }
                return _profilePicture;
            }
            set { _profilePicture = value; }
        }

        public Customer()
        {
            CustomerId = Guid.NewGuid();
        }
    }
}

Ignore property in Entity Framework

If you do not want to store the profile photo in the database, you configure it in the ShoppingContext:

// MyShop.Infrastructure/ShoppingContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .Ignore(c => c.ProfilePicture);

    base.OnModelCreating(modelBuilder);
}

Direct Lazy Initialization problem

Direct lazy initialization in the entity creates an undesirable coupling: the Customer domain model must know the ProfilePictureService, which is an infrastructure concern. This violates the principles of separation of responsibilities.


4.3 Value Holder

Principle

The Value Holder moves the loading logic out of the entity. The entity exposes an IValueHolder<T> which is initialized by the repository before being returned to the consumer.

Advantage: the domain entity no longer knows how the data is loaded.

Value Holder interface and implementation

// MyShop.Domain/Lazy/ValueHolder.cs
using System;

namespace MyShop.Domain.Lazy
{
    public interface IValueHolder<T>
    {
        T GetValue(object parameter);
    }

    public class ValueHolder<T> : IValueHolder<T>
    {
        private readonly Func<object, T> getValue;
        private T value;

        public ValueHolder(Func<object, T> getValue)
        {
            this.getValue = getValue;
        }

        public T GetValue(object parameter)
        {
            if (value == null)
            {
                // Charge la valeur seulement a la premiere demande
                value = getValue(parameter);
            }
            return value;
        }
    }
}

Using the Lazy<T> built-in type

C# natively provides the Lazy<T> class which offers the same functionalities, with the addition of thread safety:

// Dans le CustomerRepository, on initialise le ValueHolder avec Lazy<T>
public override IEnumerable<Customer> All()
{
    return base.All().Select(customer =>
    {
        customer.ProfilePictureValueHolder = new Lazy<byte[]>(() =>
        {
            return ProfilePictureService.GetFor(customer.Name);
        });
        return customer;
    });
}

Difference between ValueHolder<T> and Lazy<T>:

  • ValueHolder<T>: manual implementation, not thread-safe
  • Lazy<T>: integrated into .NET, thread-safe, easier to use

Value Holder Flow

Repository.All()
    --> Charge les entites depuis la BDD
    --> Pour chaque Customer, attache un ValueHolder (avec la fonction de chargement)
    --> Retourne les Customer au controller

Controller utilise Customer.ProfilePicture
    --> Appel Customer.ProfilePictureValueHolder.GetValue(...)
    --> Si null : appelle ProfilePictureService.GetFor(name)
    --> Met en cache le resultat
    --> Retourne la valeur

4.4 Virtual Proxies

Principle

A Virtual Proxy is a class that inherits from the entity and overrides the behavior of certain properties. The repository returns an instance of the proxy instead of the base entity. The consumer does not know that he is working with a proxy.

This pattern is also used internally by Entity Framework for its automatic lazy loading.

Step 1: Make properties virtual in Customer

For the proxy to override properties, they must be marked virtual:

// MyShop.Domain/Models/Customer.cs (version finale avec virtual)
using System;

namespace MyShop.Domain.Models
{
    public class Customer
    {
        public Guid CustomerId { get; set; }

        // Virtual pour que les proxies puissent surcharger le comportement
        public virtual string Name { get; set; }
        public virtual string ShippingAddress { get; set; }
        public virtual string City { get; set; }
        public virtual string PostalCode { get; set; }
        public virtual string Country { get; set; }

        public virtual byte[] ProfilePicture { get; set; }

        public Customer()
        {
            CustomerId = Guid.NewGuid();
        }
    }
}

Step 2: create the CustomerProxy

// MyShop.Infrastructure/Lazy/Proxies/CustomerProxy.cs
using MyShop.Domain.Models;
using MyShop.Infrastructure.Services;

namespace MyShop.Infrastructure.Lazy.Proxies
{
    public class CustomerProxy : Customer
    {
        public override byte[] ProfilePicture
        {
            get
            {
                // Lazy initialization : charge uniquement si pas encore charge
                if (base.ProfilePicture == null)
                {
                    base.ProfilePicture = ProfilePictureService.GetFor(Name);
                }
                return base.ProfilePicture;
            }
        }
    }
}

Important: use base.ProfilePicture and not ProfilePicture in the getter, otherwise we create an infinite recursion (stack overflow).

Step 3: the repository returns proxies

// Dans CustomerRepository
public override IEnumerable<Customer> All()
{
    return base.All().Select(MapToProxy);
}

private CustomerProxy MapToProxy(Customer customer)
{
    return new CustomerProxy
    {
        CustomerId = customer.CustomerId,
        Name = customer.Name,
        ShippingAddress = customer.ShippingAddress,
        City = customer.City,
        PostalCode = customer.PostalCode,
        Country = customer.Country
    };
}

Since CustomerProxy inherits from Customer, wherever a Customer is expected, a CustomerProxy can be returned without the consumer knowing.

Entity Framework’s built-in lazy loading via UseLazyLoadingProxies

Entity Framework Core natively supports lazy loading via proxies:

// MyShop.Infrastructure/ShoppingContext.cs
public class ShoppingContext : DbContext
{
    public DbSet<Customer> Customers { get; set; }
    public DbSet<Order> Orders { get; set; }
    public DbSet<Product> Products { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseLazyLoadingProxies()   // Activation du lazy loading EF Core
            .UseSqlite("Data Source=orders.db");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Ignorer ProfilePicture pour EF Core
        modelBuilder.Entity<Customer>()
            .Ignore(c => c.ProfilePicture);

        base.OnModelCreating(modelBuilder);
    }
}

With UseLazyLoadingProxies(), EF Core automatically creates proxies for all navigation properties marked virtual. For example, Order.Customer and Order.LineItems will only be loaded when accessed.


4.5 Ghost Objects

Principle

The Ghost Object is an entity in partial state: initially, only the identifier is loaded from the database. When any other property is accessed, the entire entity is loaded.

The Ghost Object keeps track of its state via an enumeration:

GHOST --> (premier acces a une propriete) --> LOADING --> LOADED

LoadStatus enumeration

enum LoadStatus { GHOST, LOADING, LOADED }

GhostCustomer implementation

// MyShop.Infrastructure/Lazy/Ghosts/GhostCustomer.cs
using MyShop.Domain.Models;
using MyShop.Infrastructure.Lazy.Proxies;
using System;

namespace MyShop.Infrastructure.Lazy.Ghosts
{
    public class GhostCustomer : CustomerProxy
    {
        private LoadStatus status;
        private readonly Func<Customer> load;

        public bool IsGhost => status == LoadStatus.GHOST;
        public bool IsLoaded => status == LoadStatus.LOADED;

        public GhostCustomer(Func<Customer> load) : base()
        {
            this.load = load;
            status = LoadStatus.GHOST;
        }

        // Override de toutes les proprietes pour declencher le chargement
        public override string Name
        {
            get { Load(); return base.Name; }
            set { Load(); base.Name = value; }
        }

        public override string ShippingAddress
        {
            get { Load(); return base.ShippingAddress; }
            set { Load(); base.ShippingAddress = value; }
        }

        public override string City
        {
            get { Load(); return base.City; }
            set { Load(); base.City = value; }
        }

        public override string PostalCode
        {
            get { Load(); return base.PostalCode; }
            set { Load(); base.PostalCode = value; }
        }

        public override string Country
        {
            get { Load(); return base.Country; }
            set { Load(); base.Country = value; }
        }

        // Methode centrale de chargement
        public void Load()
        {
            if (IsGhost)
            {
                status = LoadStatus.LOADING;

                // Charge les donnees completes depuis la BDD
                var customer = load();
                base.Name = customer.Name;
                base.ShippingAddress = customer.ShippingAddress;
                base.City = customer.City;
                base.PostalCode = customer.PostalCode;
                base.Country = customer.Country;

                status = LoadStatus.LOADED;
            }
        }
    }

    enum LoadStatus { GHOST, LOADING, LOADED }
}

Why does GhostCustomer inherit from CustomerProxy? To combine two patterns: the ghost manages the loading state, and the proxy manages the lazy loading of the profile photo. You can combine several lazy loading patterns.

The CustomerRepository returns a GhostCustomer

// CustomerRepository.cs (version finale avec Ghost)
public override Customer Get(Guid id)
{
    // Charge SEULEMENT l'identifiant depuis la BDD (etat ghost)
    var customerId = context.Customers
        .Where(c => c.CustomerId == id)
        .Select(c => c.CustomerId)
        .Single();

    // Cree un ghost avec la fonction de chargement complet
    return new GhostCustomer(() => base.Get(id))
    {
        CustomerId = customerId
    };
}

public override IEnumerable<Customer> All()
{
    return base.All().Select(MapToProxy);
}

public override Customer Update(Customer entity)
{
    var customer = context.Customers
        .Single(c => c.CustomerId == entity.CustomerId);

    customer.Name = entity.Name;
    customer.City = entity.City;
    customer.PostalCode = entity.PostalCode;
    customer.ShippingAddress = entity.ShippingAddress;
    customer.Country = entity.Country;

    return base.Update(customer);
}

private CustomerProxy MapToProxy(Customer customer)
{
    return new CustomerProxy
    {
        CustomerId = customer.CustomerId,
        Name = customer.Name,
        ShippingAddress = customer.ShippingAddress,
        City = customer.City,
        PostalCode = customer.PostalCode,
        Country = customer.Country
    };
}

Comparison of Lazy Loading variants

VariantAdvantagesDisadvantages
Lazy InitializationSimple to implementThe entity must know the loading service (coupling)
Value HolderDecouples loading from the entityMore complex; Built-in Lazy<T> is preferred
Virtual ProxiesTransparent for the consumer; supported by EFNeed for virtual properties
Ghost ObjectsLoad only the ID at first; very flexibleMore complex; may be too “talkative” by property

4.6 General conclusion

The course covered the three fundamental Data Access Patterns:

  1. Repository Pattern: encapsulate access to data, make the code testable and maintainable
  2. Unit of Work: group operations into a single transaction
  3. Lazy Loading (in 4 variants): optimize performance by loading data on demand

The majority of ORMs like Entity Framework and NHibernate implement these patterns natively. Understanding these patterns allows you not only to implement them yourself, but also to identify where they are used in existing libraries.


5. Architecture of the MyShop example application

Project structure

MyShop (Solution)
├── MyShop.Domain
│   ├── Models/
│   │   ├── Customer.cs
│   │   ├── Order.cs
│   │   ├── LineItem.cs
│   │   └── Product.cs
│   └── Lazy/
│       └── ValueHolder.cs
│
├── MyShop.Infrastructure
│   ├── ShoppingContext.cs          (DbContext EF Core)
│   ├── UnitOfWork.cs               (IUnitOfWork + UnitOfWork)
│   ├── Repositories/
│   │   ├── IRepository.cs          (interface generique)
│   │   ├── GenericRepository.cs    (implementation de base)
│   │   ├── CustomerRepository.cs
│   │   ├── OrderRepository.cs
│   │   └── ProductRepository.cs
│   ├── Lazy/
│   │   ├── Proxies/
│   │   │   └── CustomerProxy.cs
│   │   └── Ghosts/
│   │       └── GhostCustomer.cs
│   └── Services/
│       └── ProfilePictureService.cs
│
├── MyShop.Web
│   ├── Controllers/
│   │   ├── OrderController.cs
│   │   └── CustomerController.cs
│   ├── Models/
│   │   ├── CreateOrderModel.cs
│   │   ├── CustomerModel.cs
│   │   └── LineItemModel.cs
│   ├── Views/
│   ├── Startup.cs
│   └── Program.cs
│
└── MyShop.Web.Tests
    └── OrderControllerTests.cs

ShoppingContext (DbContext)

// MyShop.Infrastructure/ShoppingContext.cs
using Microsoft.EntityFrameworkCore;
using MyShop.Domain.Models;

namespace MyShop.Infrastructure
{
    public class ShoppingContext : DbContext
    {
        public DbSet<Customer> Customers { get; set; }
        public DbSet<Order> Orders { get; set; }
        public DbSet<Product> Products { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder
                .UseLazyLoadingProxies()
                .UseSqlite("Data Source=orders.db");
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Customer>()
                .Ignore(c => c.ProfilePicture);

            base.OnModelCreating(modelBuilder);
        }
    }
}

6. Domain models

Customer

// MyShop.Domain/Models/Customer.cs (version finale)
using System;

namespace MyShop.Domain.Models
{
    public class Customer
    {
        public Guid CustomerId { get; set; }

        public virtual string Name { get; set; }
        public virtual string ShippingAddress { get; set; }
        public virtual string City { get; set; }
        public virtual string PostalCode { get; set; }
        public virtual string Country { get; set; }

        // Propriete ignoree par EF Core, chargee en lazy via proxy/ghost
        public virtual byte[] ProfilePicture { get; set; }

        public Customer()
        {
            CustomerId = Guid.NewGuid();
        }
    }
}

Order

// MyShop.Domain/Models/Order.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace MyShop.Domain.Models
{
    public class Order
    {
        public Guid OrderId { get; private set; }

        // Virtual pour le lazy loading EF Core
        public virtual ICollection<LineItem> LineItems { get; set; }
        public virtual Customer Customer { get; set; }
        public Guid CustomerId { get; set; }

        public DateTime OrderDate { get; set; }

        // Propriete calculee : total de la commande
        public decimal OrderTotal =>
            LineItems.Sum(item => item.Product.Price * item.Quantity);

        public Order()
        {
            OrderId = Guid.NewGuid();
            OrderDate = DateTime.UtcNow;
        }
    }
}

7. Summary of patterns and their advantages

Repository Pattern

When to use: always, as a basis for encapsulating data access.

What it brings:

  • Separation of controller and data access layer
  • Testability (mock injection)
  • Possibility of enriching entities before returning them
  • Reusable and maintainable code

Key structure:

IRepository<T> (interface) <-- Controller
       ^
       |
GenericRepository<T> (implementation de base, abstract)
       ^
       |
OrderRepository, CustomerRepository, ProductRepository (implementations concretes)

Unit of Work Pattern

When to use: when multiple repositories need to be modified in the same operation and committed together.

What it brings:

  • Atomic transactions (all or nothing)
  • Reducing database calls
  • Sharing the same context between repositories

Key structure:

IUnitOfWork
├── CustomerRepository (meme contexte)
├── OrderRepository    (meme contexte)
├── ProductRepository  (meme contexte)
└── SaveChanges()      --> UN SEUL COMMIT

Lazy Load Pattern

When to use: when some data is large or rarely used.

Variants and use cases:

PatternTypical use case
Lazy InitializationSimple calculated properties in the same class
Value Holder / Lazy<T>Loading from an external service configured in the repository
Virtual ProxyTransparent interception, often used with EF Core
Ghost ObjectEntity referenced by ID but rarely viewed in detail

Summary: evolution of the application over the modules

ModulePattern introducedMain change
2RepositoryController -> IRepository instead of direct DbContext
3Unit of WorkIUnitOfWork groups repositories, a single SaveChanges()
4Lazy LoadingExpensive properties are loaded on demand (proxy, ghost, value holder)

These three patterns complement each other to produce applications:

  • Maintainable: centralized and abstract data access
  • Testable: injectable interfaces, possible mocks
  • Performant: lazy loading of heavy or rarely used data
  • Robust: atomic transactions via Unit of Work

Search Terms

c-sharp · design · patterns · data · access · testing · architecture · c# · .net · development · pattern · lazy · repository · unit · principle · application · load · customer · generic · holder · initialization · interface · ordercontroller · refactoring

Interested in this course?

Contact us to book it or get a custom training plan for your team.