Unit of Work with Repository Pattern in ASP.NET Core Web API

Unit of Work with Repository Pattern in ASP.NET Core Web API

In this article, I will discuss implementing the Unit of Work with the Repository Pattern in an ASP.NET Core Web API Application with Examples. In enterprise applications, especially those involving complex operations across multiple entities, it’s essential to coordinate data changes consistently. This is where the Unit of Work (UoW) Pattern comes into the picture. It acts as a transaction manager, ensuring that all repository operations belonging to a single business process either succeed or fail as a group.

What is the Unit of Work?

As your application grows, you may need to coordinate changes across multiple repositories within a single transaction. For example, placing an order might involve creating an order record, updating inventory, and logging an audit record, all in a single atomic operation. The Unit of Work (UoW) Pattern acts as a transaction manager, ensuring that multiple operations on various repositories are committed or rolled back as a single atomic unit.

Why Use Unit of Work?
Atomicity:

Ensures all data changes succeed or fail together to maintain data integrity. Atomicity is a fundamental principle of database transactions, meaning a group of operations either all succeed or all fail as one unit.

  • Without a Unit of Work, if you update multiple related entities through different repositories, some updates might succeed and others fail, leaving your data inconsistent.
  • The Unit of Work wraps these multiple operations inside a single transaction boundary. This means that if any one operation fails, the entire set of changes is rolled back, preserving the integrity and consistency of your data.

For example, when placing an order, both the order record and the related order items must be saved together. If saving any item fails, the whole order should not be saved to prevent orphaned or incomplete data.

Transaction Management:

Coordinates multiple repository operations in a single transaction. Real-world business operations often require multiple database actions that depend on each other. Each repository typically manages a single entity, but many use cases require updating multiple entities in a logical unit (like an order and its payments, inventory, shipment status).

  • The Unit of Work coordinates these multiple repository calls, ensuring they are executed within the same database transaction.
  • This coordination guarantees that all repository actions are committed as a single unit, or none at all, thus enforcing consistency across multiple related data changes.
DbContext Management:

Provides a single instance of DbContext for all repositories within the same UoW scope. In Entity Framework Core, the DbContext tracks all entity changes and is responsible for querying and persisting data. If each repository creates or uses its own separate DbContext instance, tracking changes across repositories becomes impossible, and transactional coordination is lost.

Unit of Work ensures that all repositories share the same DbContext instance during its lifetime. This shared context:

  • Tracks changes collectively for all entities involved.
  • Coordinates saving all changes in one call (SaveChanges or SaveChangesAsync).
  • Supports efficient caching and reduces resource consumption.

This centralized context management avoids problems like conflicting data states and partial commits due to separate contexts.

How to Implement Unit of Work (UoW) in ASP.NET Core with EF Core

In an ASP.NET Core application using Entity Framework Core, the Unit of Work pattern is commonly implemented by:

  • Wrapping Multiple Repositories: Instead of using separate repository instances directly, a Unit of Work class aggregates all repositories your application requires, exposing them as properties. This centralizes access to data repositories and ensures they share the same underlying DbContext instance.
  • Exposing Commit Methods: The Unit of Work exposes methods like Commit() or CommitAsync() that save all pending changes made via any repository to the database in a single atomic operation. These methods internally call DbContext.SaveChanges() or SaveChangesAsync().
  • Optionally Managing Transactions Explicitly: Advanced scenarios require explicit control over transactions, such as starting a transaction, committing, or rolling back manually. The Unit of Work can provide methods like BeginTransactionAsync(), CommitAsync(), and RollbackAsync() to manage these transaction boundaries explicitly.
Designing the IUnitOfWork Interface

The IUnitOfWork interface exposes properties for each repository and methods for saving changes. So, first create a folder named UoW in the project root directory. Then, inside the UoW folder, create an interface named IUnitOfWork and copy and paste the following code.

using ECommerceAPI.Repositories;
using Microsoft.EntityFrameworkCore.Storage;

namespace ECommerceAPI.UoW
{
    // Interface defining the Unit of Work pattern contract
    // IDisposable ensures proper resource cleanup, especially for DbContext disposal
    public interface IUnitOfWork : IDisposable
    {
        // Property to access Category repository for Category entity CRUD operations
        ICategoryRepository Categories { get; }

        // Property to access Product repository for Product entity CRUD operations
        IProductRepository Products { get; }

        // Property to access Customer repository for Customer entity CRUD operations
        ICustomerRepository Customers { get; }

        // Property to access Order repository for Order entity CRUD operations
        IOrderRepository Orders { get; }

        // Method to save all changes made in the current unit of work synchronously
        // Returns the number of state entries written to the database
        int SaveChanges();

        // Async version of SaveChanges to save changes without blocking the calling thread
        // Returns a Task wrapping the number of state entries written to the database
        Task<int> SaveChangesAsync();

        // Starts a new database transaction asynchronously
        // Returns a Task wrapping the transaction object (IDbContextTransaction)
        Task<IDbContextTransaction> BeginTransactionAsync();

        // Commits the current database transaction asynchronously
        // Ensures all operations within the transaction are permanently saved
        Task CommitAsync();

        // Rolls back the current database transaction asynchronously
        // Reverts all changes made within the transaction in case of errors
        Task RollbackAsync();
    }
}
Code Explanation:
  • Repository properties: These provide a clean and type-safe way to access repository methods for different entities (Categories, Products, etc.) through the Unit of Work. This enforces that all repository operations use the same DbContext instance, allowing coherent change tracking and atomic saves.
  • SaveChanges / SaveChangesAsync: These methods trigger the persistence of all changes tracked by the DbContext to the database. The async version enables better scalability by not blocking the calling thread during database IO.
  • BeginTransactionAsync: This method starts a new database transaction allowing you to group multiple repository operations in a single atomic unit, ensuring all-or-nothing data modification.
  • CommitAsync: This method commits the transaction, making all database changes permanent. If all operations succeed, this call finalizes the transaction.
  • RollbackAsync: This method cancels the current transaction, rolling back all changes made during the transaction. It is used to maintain data integrity in case of errors or exceptions.
  • IDisposable: The Unit of Work owns disposable resources such as the DbContext and active transaction objects. Implementing IDisposable guarantees that these resources are correctly released, avoiding memory leaks and connection pool exhaustion.
Implementing the UnitOfWork Class:

Create a class file named UnitOfWork.cs within the UoW folder, and copy and paste the following code. The UnitOfWork class is a concrete implementation of the IUnitOfWork interface. It acts as a coordinator that bundles multiple repositories and manages a single instance of EF Core’s DbContext. It also manages transactions and ensures all changes across repositories are committed or rolled back together.

using ECommerceAPI.Data;
using ECommerceAPI.Repositories.Implementations;
using ECommerceAPI.Repositories;
using Microsoft.EntityFrameworkCore.Storage;

namespace ECommerceAPI.UoW
{
    // Implements the IUnitOfWork interface to coordinate repositories and manage DbContext lifecycle & transactions
    public class UnitOfWork : IUnitOfWork
    {
        // EF Core DbContext instance shared by all repositories
        private readonly ECommerceDbContext _context;

        // Holds current active transaction, nullable
        private IDbContextTransaction? _transaction; 

        // Repository properties exposing data access interfaces
        public ICategoryRepository Categories { get; }
        public IProductRepository Products { get; }
        public ICustomerRepository Customers { get; }
        public IOrderRepository Orders { get; }

        // Constructor receives DbContext via Dependency Injection
        public UnitOfWork(ECommerceDbContext context)
        {
            _context = context; // Assign injected DbContext to local field

            // Initialize repositories with the shared DbContext instance
            Categories = new CategoryRepository(_context);
            Products = new ProductRepository(_context);
            Customers = new CustomerRepository(_context);
            Orders = new OrderRepository(_context);
        }

        // Synchronously save all changes tracked by the DbContext to the database
        public int SaveChanges()
        {
            return _context.SaveChanges();
        }

        // Asynchronously save all changes tracked by the DbContext to the database
        public async Task<int> SaveChangesAsync()
        {
            return await _context.SaveChangesAsync();
        }

        // Starts a new transaction asynchronously if none exists; returns current transaction if already active
        public async Task<IDbContextTransaction> BeginTransactionAsync()
        {
            if (_transaction != null) // If transaction already active
                return _transaction;  // Return existing transaction

            // Begin a new transaction and assign it to _transaction field
            _transaction = await _context.Database.BeginTransactionAsync();
            return _transaction;
        }

        // Commits the current transaction asynchronously
        public async Task CommitAsync()
        {
            if (_transaction == null)
                throw new InvalidOperationException("No active transaction to commit.");

            // First, save any pending changes within the transaction scope
            await _context.SaveChangesAsync();

            // Commit the database transaction permanently
            await _transaction.CommitAsync();

            // Dispose transaction resources and clear _transaction field
            await DisposeTransactionAsync();
        }

        // Rolls back the current transaction asynchronously in case of error
        public async Task RollbackAsync()
        {
            if (_transaction == null)
                throw new InvalidOperationException("No active transaction to rollback.");

            // Undo all changes made within the transaction scope
            await _transaction.RollbackAsync();

            // Dispose transaction resources and clear _transaction field
            await DisposeTransactionAsync();
        }

        // Helper method to asynchronously dispose the transaction and clear reference
        private async Task DisposeTransactionAsync()
        {
            if (_transaction != null)
            {
                await _transaction.DisposeAsync();
                _transaction = null;
            }
        }

        // Dispose method to clean up managed resources
        public void Dispose()
        {
            // Dispose transaction if active
            _transaction?.Dispose();

            // Dispose DbContext
            _context.Dispose();
        }
    }
}
Registering a Unit of Work in Dependency Injection

In the Program.cs, register the UnitOfWork service. Here, we register the UnitOfWork class as the implementation for the IUnitOfWork interface. AddScoped means one UnitOfWork instance per HTTP request. This allows us to inject IUnitOfWork anywhere in your application (controllers, services) using constructor injection.

builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();

How UoW Works:

To understand how UoW works, please have a look at the following image.

Unit of Work with Repository Pattern in ASP.NET Core Web API

Controllers/Services Layer

This is the entry point where the application handles requests. Controllers or service classes initiate business operations like creating, updating, or fetching data. Instead of directly working with data access logic or database commands, they communicate with the UnitOfWork.

UnitOfWork Layer

The UnitOfWork acts as a central coordinator that manages multiple repositories. It exposes repositories like CategoryRepository, ProductRepository, and CustomerRepository as properties. All repositories within the UnitOfWork share the same DbContext instance, ensuring consistency and coordinated change tracking.

The UnitOfWork collects all changes from these repositories during a business operation. When the controller/service calls Commit() or CommitAsync(), the UnitOfWork tells the DbContext to save all pending changes as a single transaction.

Repositories Layer (Category, Product, Customer)

Each repository provides data access methods specialized for a particular entity. For example:

  • CategoryRepository handles CRUD operations on Category data.
  • ProductRepository manages Product-related queries.
  • CustomerRepository handles Customer data.

These repositories do not save changes independently; they rely on the shared DbContext within the UnitOfWork. This way, we can make changes to multiple entities via different repositories, but all changes will be saved atomically.

DbContext Layer

The DbContext is EF Core’s core object that tracks entity states and handles database operations. It holds all entity changes tracked across multiple repositories during the UnitOfWork’s lifetime. When the UnitOfWork commits, DbContext converts all changes into database commands. It executes these commands in one atomic transaction, ensuring either all succeed or none do.

Database Layer

This is the physical database where the data is stored. The DbContext sends the aggregated commands to the database, which executes these commands and maintains data integrity. Because the operations are in a transaction, the database commits all changes or rolls them back if there is a failure.

Using a Unit of Work in a Controller

Let us see how to use the Unit of Work in OrdersController.cs to place a new order involving Orders and Products. So, please modify the OrdersController as follows. The controller declares a dependency on IUnitOfWork. ASP.NET Core’s dependency injection (DI) system injects an instance automatically. This gives the controller centralized access to all repositories and transaction management methods.

using ECommerceAPI.DTOs;
using ECommerceAPI.Models;
using ECommerceAPI.UoW;
using Microsoft.AspNetCore.Mvc;
namespace ECommerceAPI.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
// Declare UnitOfWork dependency
private readonly IUnitOfWork _unitOfWork;  
// Constructor for dependency injection of UnitOfWork
public OrdersController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
// GET: api/orders
[HttpGet]
public async Task<IActionResult> GetAllOrders()
{
// Fetch all orders including related details asynchronously
var orders = await _unitOfWork.Orders.GetAllOrdersWithDetailsAsync();
// Map each Order entity to OrderDTO for API response
var dtos = orders.Select(o => new OrderDTO
{
OrderId = o.OrderId,
OrderDate = o.OrderDate,
CustomerId = o.CustomerId,
CustomerName = o.Customer?.FullName, // Null-safe navigation
OrderAmount = o.OrderAmount,
// Map nested OrderItems collection
OrderItems = o.OrderItems.Select(oi => new OrderItemDTO
{
OrderItemId = oi.OrderItemId,
ProductId = oi.ProductId,
ProductName = oi.Product?.Name, // Null-safe navigation
Quantity = oi.Quantity,
UnitPrice = oi.UnitPrice
}).ToList()
});
// Return 200 OK with the list of OrderDTOs as JSON
return Ok(dtos);
}
// GET: api/orders/{id}
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id)
{
// Fetch single order by id asynchronously
var order = await _unitOfWork.Orders.GetByIdAsync(id);
// Return 404 if order not found
if (order == null)
return NotFound();
// Map order entity to DTO
var dto = new OrderDTO
{
OrderId = order.OrderId,
OrderDate = order.OrderDate,
CustomerId = order.CustomerId,
CustomerName = order.Customer?.FullName,
OrderAmount = order.OrderAmount,
OrderItems = order.OrderItems.Select(oi => new OrderItemDTO
{
OrderItemId = oi.OrderItemId,
ProductId = oi.ProductId,
ProductName = oi.Product?.Name,
Quantity = oi.Quantity,
UnitPrice = oi.UnitPrice
}).ToList()
};
// Return 200 OK with the single OrderDTO
return Ok(dto);
}
// GET: api/orders/bycustomer/{customerId}
[HttpGet("bycustomer/{customerId}")]
public async Task<IActionResult> GetOrdersByCustomer(int customerId)
{
// Fetch orders filtered by customerId asynchronously
var orders = await _unitOfWork.Orders.GetOrdersByCustomerAsync(customerId);
// Map entities to DTOs
var dtos = orders.Select(o => new OrderDTO
{
OrderId = o.OrderId,
OrderDate = o.OrderDate,
CustomerId = o.CustomerId,
CustomerName = o.Customer?.FullName,
OrderAmount = o.OrderAmount,
OrderItems = o.OrderItems.Select(oi => new OrderItemDTO
{
OrderItemId = oi.OrderItemId,
ProductId = oi.ProductId,
ProductName = oi.Product?.Name,
Quantity = oi.Quantity,
UnitPrice = oi.UnitPrice
}).ToList()
});
// Return 200 OK with filtered orders
return Ok(dtos);
}
// GET: api/orders/bydaterange?startDate=yyyy-MM-dd&endDate=yyyy-MM-dd
[HttpGet("bydaterange")]
public async Task<IActionResult> GetOrdersByDateRange([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
{
// Fetch orders filtered by date range asynchronously
var orders = await _unitOfWork.Orders.GetOrdersByDateRangeAsync(startDate, endDate);
// Map entities to DTOs
var dtos = orders.Select(o => new OrderDTO
{
OrderId = o.OrderId,
OrderDate = o.OrderDate,
CustomerId = o.CustomerId,
CustomerName = o.Customer?.FullName,
OrderAmount = o.OrderAmount,
OrderItems = o.OrderItems.Select(oi => new OrderItemDTO
{
OrderItemId = oi.OrderItemId,
ProductId = oi.ProductId,
ProductName = oi.Product?.Name,
Quantity = oi.Quantity,
UnitPrice = oi.UnitPrice
}).ToList()
});
// Return 200 OK with filtered orders
return Ok(dtos);
}
// POST: api/orders
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] OrderDTO orderDto)
{
// Validate incoming request body against DTO validation attributes
if (!ModelState.IsValid)
return BadRequest(ModelState);
// Start a database transaction via UnitOfWork
await _unitOfWork.BeginTransactionAsync();
try
{
// Map OrderDTO to Order entity
var order = new Order
{
CustomerId = orderDto.CustomerId,
OrderDate = orderDto.OrderDate,
OrderAmount = orderDto.OrderAmount,
OrderItems = new List<OrderItem>() // Initialize collection
};
// Map each OrderItemDTO to OrderItem entity and add to order
foreach (var itemDto in orderDto.OrderItems)
{
var orderItem = new OrderItem
{
ProductId = itemDto.ProductId,
Quantity = itemDto.Quantity,
UnitPrice = itemDto.UnitPrice
};
order.OrderItems.Add(orderItem);
}
// Add order entity asynchronously through repository
await _unitOfWork.Orders.AddAsync(order);
// Commit the transaction (save changes + commit)
await _unitOfWork.CommitAsync();
// Return 200 OK with created order entity (can map to DTO if preferred)
return Ok(order);
}
catch (Exception)
{
// Rollback transaction if any error occurs
await _unitOfWork.RollbackAsync();
// Log error here as needed
// Return 500 Internal Server Error with message
return StatusCode(500, "An error occurred while placing the order.");
}
}
// PUT api/orders/{id}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateOrder(int id, [FromBody] OrderDTO orderDto)
{
// Validate input and check if route id matches DTO id
if (!ModelState.IsValid || id != orderDto.OrderId)
return BadRequest();
// Fetch existing order entity by id
var existingOrder = await _unitOfWork.Orders.GetByIdAsync(id);
if (existingOrder == null)
return NotFound();
// Start transaction
await _unitOfWork.BeginTransactionAsync();
try
{
// Update properties of existing order
existingOrder.OrderDate = orderDto.OrderDate;
existingOrder.OrderAmount = orderDto.OrderAmount;
// Note: Updating OrderItems is omitted for simplicity here
// Mark order as updated in repository
_unitOfWork.Orders.Update(existingOrder);
// Commit changes
await _unitOfWork.CommitAsync();
// Return 204 No Content for successful update
return NoContent();
}
catch (Exception)
{
// Rollback on error
await _unitOfWork.RollbackAsync();
return StatusCode(500, "An error occurred while updating the order.");
}
}
// DELETE api/orders/{id}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteOrder(int id)
{
// Fetch order entity to delete
var existingOrder = await _unitOfWork.Orders.GetByIdAsync(id);
if (existingOrder == null)
return NotFound();
// Start transaction
await _unitOfWork.BeginTransactionAsync();
try
{
// Delete order entity
_unitOfWork.Orders.Delete(existingOrder);
// Commit deletion
await _unitOfWork.CommitAsync();
// Return 204 No Content for successful deletion
return NoContent();
}
catch (Exception)
{
// Rollback on error
await _unitOfWork.RollbackAsync();
return StatusCode(500, "An error occurred while deleting the order.");
}
}
}
}

The Repository and Unit of Work patterns together provide a robust and maintainable approach to data access in modern application development. The Repository pattern abstracts and encapsulates data access logic, promoting loose coupling and enabling cleaner, more testable code by providing a simple, consistent interface for CRUD operations.

Meanwhile, the Unit of Work pattern coordinates multiple repositories under a single transaction scope, ensuring atomicity and consistency across complex operations involving multiple entities. By managing the DbContext lifecycle centrally, Unit of Work minimizes resource contention and enhances performance.

In the next article, I will discuss e-commerce real-time Application Development using ASP.NET Core Web API. In this article, I explain how to implement a Unit of Work with a Repository Pattern in ASP.NET Core Web API with Examples. I hope you enjoy this article.

Leave a Reply

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