Skip to main content

🏗️ Unit of Work + Generic Repository Pattern in .NET (EF Core)

When working with Entity Framework Core, combining the Generic Repository Pattern with the Unit of Work Pattern helps keep data access clean, testable, and transactional.

This guide walks you step-by-step with code snippets.


🔑 Interfaces

We start by defining two interfaces:

  • IGenericRepository → Generic data access contract
  • IUnitOfWork → Manages repositories and transactions
public interface IGenericRepository<T> where T : class
{
    Task<T> GetByIdAsync(object id);
    Task<IEnumerable<T>> GetAllAsync();
    Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);

    Task AddAsync(T entity);
    void Update(T entity);
    void Remove(T entity);

    IQueryable<T> Query();
}

public interface IUnitOfWork : IDisposable
{
    IGenericRepository<T> Repository<T>() where T : class;

    Task<int> SaveChangesAsync();
    Task BeginTransactionAsync();
    Task CommitAsync();
    Task RollbackAsync();
}

⚙️ Implementations

🔹 Generic Repository

public class GenericRepository<T> : IGenericRepository<T> where T : class
{
    protected readonly DbContext _context;
    protected readonly DbSet<T> _dbSet;

    public GenericRepository(DbContext context)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _dbSet = _context.Set<T>();
    }

    public virtual async Task<T> GetByIdAsync(object id) =>
        await _dbSet.FindAsync(id);

    public virtual async Task<IEnumerable<T>> GetAllAsync() =>
        await _dbSet.ToListAsync();

    public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) =>
        await _dbSet.Where(predicate).ToListAsync();

    public virtual async Task AddAsync(T entity) =>
        await _dbSet.AddAsync(entity);

    public virtual void Update(T entity)
    {
        _dbSet.Attach(entity);
        _context.Entry(entity).State = EntityState.Modified;
    }

    public virtual void Remove(T entity) =>
        _dbSet.Remove(entity);

    public virtual IQueryable<T> Query() =>
        _dbSet.AsQueryable();
}

🔹 Unit of Work

public class UnitOfWork : IUnitOfWork
{
    private readonly DbContext _context;
    private readonly Dictionary<Type, object> _repositories;
    private IDbContextTransaction _transaction;
    private bool _disposed;

    public UnitOfWork(DbContext context)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _repositories = new Dictionary<Type, object>();
    }

    public IGenericRepository<T> Repository<T>() where T : class
    {
        var type = typeof(T);

        if (_repositories.ContainsKey(type))
            return (IGenericRepository<T>)_repositories[type];

        var repo = new GenericRepository<T>(_context);
        _repositories[type] = repo;
        return repo;
    }

    public async Task<int> SaveChangesAsync() =>
        await _context.SaveChangesAsync();

    public async Task BeginTransactionAsync()
    {
        if (_transaction == null)
            _transaction = await _context.Database.BeginTransactionAsync();
    }

    public async Task CommitAsync()
    {
        try
        {
            await _context.SaveChangesAsync();
            if (_transaction != null)
            {
                await _transaction.CommitAsync();
                await _transaction.DisposeAsync();
                _transaction = null;
            }
        }
        catch
        {
            await RollbackAsync();
            throw;
        }
    }

    public async Task RollbackAsync()
    {
        if (_transaction != null)
        {
            await _transaction.RollbackAsync();
            await _transaction.DisposeAsync();
            _transaction = null;
        }
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                _transaction?.Dispose();
                _context.Dispose();
            }
            _disposed = true;
        }
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
}

🗂️ Example DbContext & Entities

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Product> Products { get; set; }
    public DbSet<Order> Orders { get; set; }
}

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

public class Order
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
}

🔌 Registering with Dependency Injection

Add to Program.cs / Startup.cs:

services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));

services.AddScoped<IUnitOfWork, UnitOfWork>();

// Optional: If you want to expose repositories directly
// services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>));

🚀 Usage Example (Service Layer)

public class OrderService
{
    private readonly IUnitOfWork _uow;

    public OrderService(IUnitOfWork uow)
    {
        _uow = uow;
    }

    public async Task CreateOrderAsync(Order order, Product productToAdd)
    {
        await _uow.BeginTransactionAsync();

        try
        {
            var orderRepo = _uow.Repository<Order>();
            var productRepo = _uow.Repository<Product>();

            await productRepo.AddAsync(productToAdd);
            await orderRepo.AddAsync(order);

            await _uow.CommitAsync(); // Save + commit transaction
        }
        catch
        {
            await _uow.RollbackAsync();
            throw;
        }
    }
}

✅ Notes & Best Practices

  1. Why Unit of Work + Generic Repository?

    • Centralizes SaveChanges in one place
    • Groups multiple repository operations into a single transaction
  2. Async-first → Use EF Core async APIs to avoid blocking

  3. Don’t over-abstract → For complex queries, create custom repositories (e.g., IProductRepository)

  4. Transactions → Use BeginTransactionAsync, CommitAsync, RollbackAsync for multi-step atomic operations

  5. Lifetime → Register Unit of Work as Scoped (per HTTP request)

  6. Testing → Swap DbContext with InMemory provider or mock repositories for unit tests


👉 With this setup, you get clean repository access, transaction control, and easy testing. Perfect for medium-to-large .NET applications using EF Core.


Here’s a blogger-friendly, structured article version of your Unit of Work + Generic Repository example 👇


🏗️ Unit of Work + Generic Repository Pattern in .NET (EF Core)

When working with Entity Framework Core, combining the Generic Repository Pattern with the Unit of Work Pattern helps keep data access clean, testable, and transactional.

This guide walks you step-by-step with code snippets.


🔑 Interfaces

We start by defining two interfaces:

  • IGenericRepository → Generic data access contract
  • IUnitOfWork → Manages repositories and transactions
public interface IGenericRepository<T> where T : class
{
    Task<T> GetByIdAsync(object id);
    Task<IEnumerable<T>> GetAllAsync();
    Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);

    Task AddAsync(T entity);
    void Update(T entity);
    void Remove(T entity);

    IQueryable<T> Query();
}

public interface IUnitOfWork : IDisposable
{
    IGenericRepository<T> Repository<T>() where T : class;

    Task<int> SaveChangesAsync();
    Task BeginTransactionAsync();
    Task CommitAsync();
    Task RollbackAsync();
}

⚙️ Implementations

🔹 Generic Repository

public class GenericRepository<T> : IGenericRepository<T> where T : class
{
    protected readonly DbContext _context;
    protected readonly DbSet<T> _dbSet;

    public GenericRepository(DbContext context)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _dbSet = _context.Set<T>();
    }

    public virtual async Task<T> GetByIdAsync(object id) =>
        await _dbSet.FindAsync(id);

    public virtual async Task<IEnumerable<T>> GetAllAsync() =>
        await _dbSet.ToListAsync();

    public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) =>
        await _dbSet.Where(predicate).ToListAsync();

    public virtual async Task AddAsync(T entity) =>
        await _dbSet.AddAsync(entity);

    public virtual void Update(T entity)
    {
        _dbSet.Attach(entity);
        _context.Entry(entity).State = EntityState.Modified;
    }

    public virtual void Remove(T entity) =>
        _dbSet.Remove(entity);

    public virtual IQueryable<T> Query() =>
        _dbSet.AsQueryable();
}

🔹 Unit of Work

public class UnitOfWork : IUnitOfWork
{
    private readonly DbContext _context;
    private readonly Dictionary<Type, object> _repositories;
    private IDbContextTransaction _transaction;
    private bool _disposed;

    public UnitOfWork(DbContext context)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _repositories = new Dictionary<Type, object>();
    }

    public IGenericRepository<T> Repository<T>() where T : class
    {
        var type = typeof(T);

        if (_repositories.ContainsKey(type))
            return (IGenericRepository<T>)_repositories[type];

        var repo = new GenericRepository<T>(_context);
        _repositories[type] = repo;
        return repo;
    }

    public async Task<int> SaveChangesAsync() =>
        await _context.SaveChangesAsync();

    public async Task BeginTransactionAsync()
    {
        if (_transaction == null)
            _transaction = await _context.Database.BeginTransactionAsync();
    }

    public async Task CommitAsync()
    {
        try
        {
            await _context.SaveChangesAsync();
            if (_transaction != null)
            {
                await _transaction.CommitAsync();
                await _transaction.DisposeAsync();
                _transaction = null;
            }
        }
        catch
        {
            await RollbackAsync();
            throw;
        }
    }

    public async Task RollbackAsync()
    {
        if (_transaction != null)
        {
            await _transaction.RollbackAsync();
            await _transaction.DisposeAsync();
            _transaction = null;
        }
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                _transaction?.Dispose();
                _context.Dispose();
            }
            _disposed = true;
        }
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
}

🗂️ Example DbContext & Entities

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Product> Products { get; set; }
    public DbSet<Order> Orders { get; set; }
}

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

public class Order
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
}

🔌 Registering with Dependency Injection

Add to Program.cs / Startup.cs:

services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));

services.AddScoped<IUnitOfWork, UnitOfWork>();

// Optional: If you want to expose repositories directly
// services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>));

🚀 Usage Example (Service Layer)

public class OrderService
{
    private readonly IUnitOfWork _uow;

    public OrderService(IUnitOfWork uow)
    {
        _uow = uow;
    }

    public async Task CreateOrderAsync(Order order, Product productToAdd)
    {
        await _uow.BeginTransactionAsync();

        try
        {
            var orderRepo = _uow.Repository<Order>();
            var productRepo = _uow.Repository<Product>();

            await productRepo.AddAsync(productToAdd);
            await orderRepo.AddAsync(order);

            await _uow.CommitAsync(); // Save + commit transaction
        }
        catch
        {
            await _uow.RollbackAsync();
            throw;
        }
    }
}

✅ Notes & Best Practices

  1. Why Unit of Work + Generic Repository?

    • Centralizes SaveChanges in one place
    • Groups multiple repository operations into a single transaction
  2. Async-first → Use EF Core async APIs to avoid blocking

  3. Don’t over-abstract → For complex queries, create custom repositories (e.g., IProductRepository)

  4. Transactions → Use BeginTransactionAsync, CommitAsync, RollbackAsync for multi-step atomic operations

  5. Lifetime → Register Unit of Work as Scoped (per HTTP request)

  6. Testing → Swap DbContext with InMemory provider or mock repositories for unit tests


👉 With this setup, you get clean repository access, transaction control, and easy testing. Perfect for medium-to-large .NET applications using EF Core.



Comments

Popular posts from this blog

🏗️ Deep Dive: Understanding Every Concept in Microsoft Entra API Onboarding for .NET Developers

When working with Microsoft Entra (formerly Azure Active Directory), you’ll hear terms like App Registration, Tenant, Client ID, Audience, Scopes, Roles, Tokens, OBO flow , and more. If you’re new, it can feel overwhelming. This guide breaks down every key term and concept , with definitions, examples, and how they connect when you onboard and consume a new API. 🔹 1. Tenant Definition : A tenant in Entra ID is your organization’s dedicated, isolated instance of Microsoft Entra. Think of it like : Your company’s identity directory. Example : contoso.onmicrosoft.com is a tenant for Contoso Ltd. 🔹 2. App Registration Definition : The process of registering an application in Entra to give it an identity and permission to use Microsoft identity platform. Why needed : Without registration, Entra doesn’t know about your app. What it creates : Application (Client) ID – unique identifier for your app Directory (Tenant) ID – your organization’s ID Types of apps : Web ...

🗑️ Garbage Collection & Resource Management in .NET (C#) — Beginner Friendly Guide

When you start working with .NET and C#, one of the biggest advantages is that you don’t need to manually manage memory like in C or C++. The Garbage Collector (GC) does most of the work for you. But here’s the catch — not everything is managed automatically. Some resources like files, database connections, sockets, and native memory still need special handling. This blog will help you understand: ✔ How the GC works ✔ What are managed vs unmanaged resources ✔ The difference between Dispose , Finalize , and using ✔ The Dispose pattern with examples ✔ Best practices every C# developer should know 1) How Garbage Collection Works in .NET Managed resources → Normal .NET objects (string, List, etc.). GC frees them automatically. Unmanaged resources → External resources like file handles, database connections, sockets, native memory. GC cannot clean them up — you must do it. 👉 GC uses a Generational Model for performance: Gen 0 : Short-lived objects (local variables, t...

☁️ Azure Key vault Short Notes

🟢 What is Azure Key Vault? A cloud service for securely storing and accessing secrets, keys, and certificates . Removes the need to keep secrets (like connection strings, passwords, API keys) inside code or config files. Provides centralized secret management, encryption, and access control . 👉 Think of it like a secure password manager but for your applications. 🟢 Key Features Secrets → store text values (e.g., DB connection string, API key). Keys → store cryptographic keys (RSA, EC) for encryption, signing. Certificates → store/manage SSL/TLS certificates. Access Control → Access Policies (older model). Azure RBAC (modern, preferred). Integration → works with App Service, Functions, AKS, VMs, SQL DB, etc. Logging → audit who accessed secrets via Azure Monitor / Diagnostic Logs. 🟢 Why Use Key Vault? Security → secrets are encrypted with HSM (Hardware Security Modules). Compliance → meet industry standards (PCI-DSS, ISO, GDPR). Automation → aut...