Implementing Non-Generic Repository Pattern in ASP.NET Core Web API

Non-Generic Repository Pattern in ASP.NET Core Web API

In this article, we will discuss implementing the Non-Generic Repository Pattern in an ASP.NET Core Web API Application. This is also a continuation of our previous article, where we discussed the challenges of directly using EF Core’s DbContext in API Controllers, including tight coupling, code duplication, difficult testing, and maintenance issues.

To overcome these issues, the Repository Pattern introduces an abstraction layer between our application’s business logic and data access technology. This layer provides a clean, centralized, and testable way to manage database operations. Now, we will focus on the Non-Generic Repository Pattern, which implements a dedicated repository class per aggregate or entity type.

What is the Repository Pattern in ASP.NET Core Web API?

The Repository Pattern is a software design pattern used to abstract and encapsulate data access logic for an application. It acts as a mediator between the application’s business logic and the data source (such as a database), providing a clean, simple API for data operations. So, it helps us encapsulate all the logic needed to access data sources (like databases) and expose only high-level methods to the rest of the application. In Simple Terms:

  • Without Repository Pattern: Your controllers or business logic code directly interact with the database using tools like Entity Framework Core’s DbContext, writing queries everywhere.
  • With Repository Pattern: All database operations go through repository classes, which expose methods like GetAll(), Add(), Delete(), Update(), etc. This means your business logic never knows how the data is stored or retrieved—it just asks the repository for what it needs.

For a better understanding, please have a look at the following diagram.

What is the Repository Pattern in ASP.NET Core Web API?

Example: Library Book Management

Imagine you run a library.

  • The library system needs to add books, find books, update book info, or remove old books.
  • Instead of every librarian directly searching the big, messy warehouse themselves, you have a specialized “Book Manager” who knows exactly how to find and manage books efficiently.
  • The librarian (business logic) asks the Book Manager (repository) to perform these operations—add, search, update, and delete books, without worrying about how or where the books are stored.
  • If you move from physical books to digital ebooks tomorrow, only the Book Manager’s internals will change, but the librarian’s way of asking will stay the same.
What is the Non-Generic Repository Pattern?

A Non-Generic Repository Pattern means we create a separate repository interface and class for each entity. For example, in an E-Commerce application, we might have:

  • IProductRepository and ProductRepository for Product data operations.
  • ICategoryRepository and CategoryRepository for Category data operations.
  • ICustomerRepository and CustomerRepository for Customer data operations.
  • And so on.

Each repository can expose custom methods for its entity. For instance, IProductRepository might have GetProductsByCategory(int categoryId), which wouldn’t make sense in ICustomerRepository.

Why DTOs Matter in Web APIs?

Before implementing the Non-Generic Repository Pattern, let’s first understand Data Transfer Objects (DTOs) and why they are crucial in Restful Service development.

DTOs (Data Transfer Objects) define the shape of data transfer between layers (usually between the backend and frontend). Entities represent our database structure, but DTOs are designed for API contracts (input/output models).

  • DTOs are simplified versions of our domain models designed specifically to transfer data between layers (API clients and servers).
  • They decouple our internal entity models from external exposure, enhancing security, control, and API contract stability.
  • DTOs allow custom shaping of response data, hiding unnecessary fields, avoiding serialization issues like circular references, and improving performance.

For example, consider an Order entity with navigation properties and audit fields. Exposing the entire entity can leak unnecessary details. Instead, define OrderDTO with only OrderId, OrderDate, OrderAmount, and a nested list of OrderItemDTO objects.

Designing DTOs

Data Transfer Objects (DTOs) define the precise data structure used to transfer information between the API and clients, separating internal database entities from external API contracts. They simplify, secure, and optimize data exchange by exposing only necessary fields, reducing payload size, and avoiding issues like circular references or overexposure of sensitive data. First, create a folder named DTOs at the project root directory where we will create all our DTOs.

CategoryDTO

Create a class file named CategoryDTO.cs within the DTOs folder and then copy and paste the following code. The CategoryDTO represents the data structure for category data exchanged through the API, containing properties like CategoryId, Name, and Description with validation attributes to ensure client input correctness while hiding unnecessary internal details.

using System.ComponentModel.DataAnnotations;
namespace ECommerceAPI.DTOs
{
    public class CategoryDTO
    {
        public int CategoryId { get; set; }

        [Required(ErrorMessage = "Category Name is required.")]
        [StringLength(100, ErrorMessage = "Category Name cannot exceed 100 characters.")]
        public string Name { get; set; } = null!;

        [StringLength(500, ErrorMessage = "Description cannot exceed 500 characters.")]
        public string? Description { get; set; }
    }
}
ProductDTO

Create a class file named ProductDTO.cs within the DTOs folder and then copy and paste the following code. The ProductDTO defines the API-facing data shape for products, including ProductId, Name, Description, Price, CategoryId, and optionally CategoryName, with validations to ensure proper data integrity and limit exposure to only relevant fields.

using System.ComponentModel.DataAnnotations;
namespace ECommerceAPI.DTOs
{
    public class ProductDTO
    {
        public int ProductId { get; set; }

        [Required(ErrorMessage = "Product Name is required.")]
        [StringLength(150, ErrorMessage = "Product Name cannot exceed 150 characters.")]
        public string Name { get; set; } = null!;

        [StringLength(1000, ErrorMessage = "Description cannot exceed 1000 characters.")]
        public string? Description { get; set; }

        [Required(ErrorMessage = "Price is required.")]
        [Range(0.01, 999999.99, ErrorMessage = "Price must be between 0.01 and 999,999.99.")]
        public decimal Price { get; set; }

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

        public string? CategoryName { get; set; }  // For convenience in responses
    }
}
CustomerDTO

Create a class file named CustomerDTO.cs within the DTOs folder and then copy and paste the following code. The CustomerDTO specifies customer-related data sent and received via the API, with key fields like CustomerId, FullName, and Email, including validation rules for mandatory fields and correct email formatting.

using System.ComponentModel.DataAnnotations;
namespace ECommerceAPI.DTOs
{
    public class CustomerDTO
    {
        public int CustomerId { get; set; }

        [Required(ErrorMessage = "Full Name is required.")]
        [StringLength(200, ErrorMessage = "Full Name cannot exceed 200 characters.")]
        public string FullName { get; set; } = null!;

        [Required(ErrorMessage = "Email is required.")]
        [EmailAddress(ErrorMessage = "Invalid Email Address format.")]
        public string Email { get; set; } = null!;
    }
}
OrderItemDTO

Create a class file named OrderItemDTO.cs within the DTOs folder and then copy and paste the following code. The OrderItemDTO encapsulates order item details within an order, such as OrderItemId, ProductId, ProductName, Quantity, and UnitPrice, providing a clear contract for nested order item data transferred through the API.

using System.ComponentModel.DataAnnotations;
namespace ECommerceAPI.DTOs
{
    public class OrderItemDTO
    {
        public int? OrderItemId { get; set; }

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

        public string? ProductName { get; set; }

        [Required(ErrorMessage = "Quantity is required.")]
        [Range(1, 10000, ErrorMessage = "Quantity must be at least 1.")]
        public int Quantity { get; set; }

        [Required(ErrorMessage = "Unit Price is required.")]
        [Range(0.01, 999999.99, ErrorMessage = "Unit Price must be between 0.01 and 999,999.99.")]
        public decimal UnitPrice { get; set; }
    }
}
OrderDTO

Create a class file named OrderDTO.cs within the DTOs folder and then copy and paste the following code. The OrderDTO aggregates order information including OrderId, OrderDate, CustomerId, CustomerName, OrderAmount, and a list of OrderItemDTO objects, designed to transfer comprehensive order data between client and server while maintaining input validations.

using System.ComponentModel.DataAnnotations;
namespace ECommerceAPI.DTOs
{
    public class OrderDTO
    {
        public int? OrderId { get; set; }

        [Required(ErrorMessage = "Order Date is required.")]
        public DateTime OrderDate { get; set; }

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

        public string? CustomerName { get; set; }

        [Required(ErrorMessage = "Order Amount is required.")]
        [Range(0.0, 99999999.99, ErrorMessage = "Order Amount must be positive.")]
        public decimal OrderAmount { get; set; }

        [Required(ErrorMessage = "Order Items are required.")]
        public List<OrderItemDTO> OrderItems { get; set; } = new();
    }
}
What is a Repository?

A repository is a class defined for an entity with all the possible database operations. For example, a repository for a Product will have the basic CRUD operations and any other possible operations related to the Product entity. Similarly, a repository for the Customer will include the CRUD operations related to the Customer entity.

A repository generally exposes a set of methods for performing basic and sometimes advanced data operations. These methods provide a consistent API for interacting with the data source.

GetAll / GetAllAsync:

This method retrieves all records of a particular entity from the data source. It returns a collection representing every instance stored in the database, allowing clients to fetch full lists of items like all products, customers, or orders. The asynchronous version performs the operation without blocking the calling thread, improving application responsiveness.

GetById / GetByIdAsync:

This method fetches a single entity by its unique identifier, typically the primary key. It is used when detailed information about one specific record is needed, such as loading a customer’s profile or product details. The async variant enables non-blocking retrieval, which is important for scalable and performant applications.

Add / AddAsync:

The Add method inserts a new entity into the data store by staging it in the context’s change tracker. It does not immediately persist the entity to the database until the save operation is called. The asynchronous version facilitates efficient resource usage when the underlying database or provider supports asynchronous operations during the add process.

Update:

Update marks an existing entity as modified within the context so that changes will be saved to the data source during the next save operation. It is used to apply edits to records, like changing a product’s price or updating customer information. This method typically operates synchronously because it only alters the tracking state, not the database directly.

Delete:

Delete removes an entity from the data source by marking it for deletion in the change tracker, which takes effect upon saving changes. This method is used to delete records that are no longer needed, such as removing obsolete orders or customers. Like update, it generally performs a synchronous operation since it only changes the in-memory state.

Exists / ExistsAsync:

Exists checks whether a particular entity exists in the data source, commonly by verifying the presence of an entity with a given identifier. This is useful for validation, ensuring that an update or delete operation targets a valid record. The async variant allows this check to happen without blocking the calling thread.

Save / SaveAsync:

Save commits all staged changes, adds, updates, and deletes to the underlying database in a transactional manner. This operation is typically asynchronous to prevent blocking while the database applies changes, ensuring that the application remains responsive and scalable during data persistence.

Creating Repository Interfaces

Repository interfaces declare a contract for data access operations specific to an entity, such as GetAllAsync(), GetByIdAsync(), AddAsync(), Update(), Delete(), ExistsAsync(), and SaveAsync(). These interfaces ensure loose coupling by abstracting implementation details and enabling dependency injection, which facilitates easier testing and flexibility in swapping data access implementations without changing consumers. So, first, create a folder named Repositories in the Project root directory where we will create all our Repository Interfaces and their implementor classes.

ICategoryRepository.cs

Create an interface named ICategoryRepository.cs within the Repositories folder and then copy and paste the following code. The ICategoryRepository declares the contract for category data operations, including methods to get all categories, find by ID, add, update, delete, check existence, and save changes asynchronously, enabling abstraction and easy mocking.

using ECommerceAPI.Models;
namespace ECommerceAPI.Repositories
{
    public interface ICategoryRepository
    {
        Task<IEnumerable<Category>> GetAllAsync();
        Task<Category?> GetByIdAsync(int id);
        Task AddAsync(Category category);
        void Update(Category category);
        void Delete(Category category);
        Task<bool> ExistsAsync(int id);
        Task SaveAsync();
    }
}
IProductRepository.cs

Create an interface named IProductRepository.cs within the Repositories folder and then copy and paste the following code. The IProductRepository defines asynchronous CRUD and existence check operations specifically for product entities, allowing tailored product data access logic through a clean interface separated from implementation details.

using ECommerceAPI.Models;
namespace ECommerceAPI.Repositories
{
    public interface IProductRepository
    {
        Task<IEnumerable<Product>> GetAllAsync();
        Task<Product?> GetByIdAsync(int id);
        Task AddAsync(Product product);
        void Update(Product product);
        void Delete(Product product);
        Task<bool> ExistsAsync(int id);
        Task SaveAsync();
    }
}
ICustomerRepository.cs

Create an interface named ICustomerRepository.cs within the Repositories folder and then copy and paste the following code. The ICustomerRepository provides interface definitions for managing customer data, including retrieval, insertion, updates, deletions, existence verification, and saving, facilitating a modular and testable data access layer.

using ECommerceAPI.Models;
namespace ECommerceAPI.Repositories
{
    public interface ICustomerRepository
    {
        Task<IEnumerable<Customer>> GetAllAsync();
        Task<Customer?> GetByIdAsync(int id);
        Task AddAsync(Customer customer);
        void Update(Customer customer);
        void Delete(Customer customer);
        Task<bool> ExistsAsync(int id);
        Task SaveAsync();
    }
}
IOrderRepository.cs

Create an interface named IOrderRepository.cs within the Repositories folder and then copy and paste the following code. The IOrderRepository outlines the repository interface for orders, supporting operations to fetch orders (including related items and customers), add, update, delete, check existence, and persist changes asynchronously, encapsulating order-specific data logic.

using ECommerceAPI.Models;
namespace ECommerceAPI.Repositories
{
    public interface IOrderRepository
    {
        Task<IEnumerable<Order>> GetAllAsync();
        Task<Order?> GetByIdAsync(int id);
        Task AddAsync(Order order);
        void Update(Order order);
        void Delete(Order order);
        Task<bool> ExistsAsync(int id);
        Task SaveAsync();
    }
}
Repository Implementations

Concrete repository classes implement their respective interfaces to encapsulate all data access logic for a specific entity using EF Core’s DbContext. They handle asynchronous CRUD operations, entity tracking, and efficient querying, centralizing and standardizing database interactions to improve maintainability, promote reusability, and provide a clean API for business layers. First, create a folder named Implementations within the Repositories folder where we will create all our Repository Implementation classes:

CategoryRepository

Create a class file named CategoryRepository.cs within the Repositories/Implementations folder and then copy and paste the following code. The CategoryRepository implements ICategoryRepository by using EF Core’s DbContext to perform asynchronous data operations for categories, including retrieval with no tracking, entity addition, updating, deletion, existence checks, and saving changes, centralizing category data access.

using ECommerceAPI.Data;
using ECommerceAPI.Models;
using Microsoft.EntityFrameworkCore;

namespace ECommerceAPI.Repositories.Implementations
{
    public class CategoryRepository : ICategoryRepository
    {
        private readonly ECommerceDbContext _context;
        public CategoryRepository(ECommerceDbContext context)
        {
            _context = context;
        }

        public async Task<IEnumerable<Category>> GetAllAsync()
        {
            return await _context.Categories.AsNoTracking().ToListAsync();
        }

        public async Task<Category?> GetByIdAsync(int categoryId)
        {
            return await _context.Categories.FindAsync(categoryId);
        }

        public async Task AddAsync(Category category)
        {
             await _context.Categories.AddAsync(category);
        }

        public void Update(Category category)
        {
            _context.Categories.Update(category);
        }

        public void Delete(Category category)
        {
            _context.Categories.Remove(category);
        }

        public async Task<bool> ExistsAsync(int id)
        {
            return await _context.Categories.AnyAsync(c => c.CategoryId == id);
        }
        
        public async Task SaveAsync()
        {
            await _context.SaveChangesAsync();
        }   
    }
}
ProductRepository

Create a class file named ProductRepository.cs within the Repositories/Implementations folder and then copy and paste the following code. The ProductRepository implements the IProductRepository, which handles product-specific data access, utilizing eager loading for related categories and managing CRUD operations asynchronously with EF Core’s DbContext to provide optimized product management.

using ECommerceAPI.Data;
using ECommerceAPI.Models;
using Microsoft.EntityFrameworkCore;

namespace ECommerceAPI.Repositories.Implementations
{
    public class ProductRepository : IProductRepository
    {
        private readonly ECommerceDbContext _context;
        public ProductRepository(ECommerceDbContext context)
        {
            _context = context;
        }

        public async Task<IEnumerable<Product>> GetAllAsync()
        {
            return await _context.Products.Include(p => p.Category).AsNoTracking().ToListAsync();
        }

        public async Task<Product?> GetByIdAsync(int productId)
        {
            return await _context.Products.Include(p => p.Category)
                .AsNoTracking().FirstOrDefaultAsync(p => p.ProductId == productId);
        }

        public async Task AddAsync(Product product)
        {
            await _context.Products.AddAsync(product);
        }

        public void Update(Product product)
        {
            _context.Products.Update(product);
        }

        public void Delete(Product product)
        {
            _context.Products.Remove(product);
        }

        public async Task<bool> ExistsAsync(int id)
        {
            return await _context.Products.AnyAsync(c => c.ProductId == id);
        }

        public async Task SaveAsync()
        {
            await _context.SaveChangesAsync();
        }
    }
}
CustomerRepository

Create a class file named CustomerRepository.cs within the Repositories/Implementations folder and then copy and paste the following code. The CustomerRepository Implements ICustomerRepository, encapsulating all asynchronous CRUD operations for customer data. It ensures efficient data access, existence checks, and transactional saving using EF Core, isolating customer-specific persistence logic.

using ECommerceAPI.Data;
using ECommerceAPI.Models;
using Microsoft.EntityFrameworkCore;

namespace ECommerceAPI.Repositories.Implementations
{
    public class CustomerRepository : ICustomerRepository
    {
        private readonly ECommerceDbContext _context;
        public CustomerRepository(ECommerceDbContext context)
        {
            _context = context;
        }

        public async Task<IEnumerable<Customer>> GetAllAsync()
        {
            return await _context.Customers.AsNoTracking().ToListAsync();
        }

        public async Task<Customer?> GetByIdAsync(int customerId)
        {
            return await _context.Customers.FindAsync(customerId);
        }

        public async Task AddAsync(Customer customer)
        {
            await _context.Customers.AddAsync(customer);
        }

        public void Update(Customer customer)
        {
            _context.Customers.Update(customer);
        }

        public void Delete(Customer customer)
        {
            _context.Customers.Remove(customer);
        }

        public async Task<bool> ExistsAsync(int id)
        {
            return await _context.Customers.AnyAsync(c => c.CustomerId == id);
        }

        public async Task SaveAsync()
        {
            await _context.SaveChangesAsync();
        }
    }
}
OrderRepository

Create a class file named OrderRepository.cs within the Repositories/Implementations folder and then copy and paste the following code. The OrderRepository Implements IOrderRepository, managing complex order retrieval with related customer and order item navigation properties using eager loading. It supports asynchronous creation, updating, deletion, existence verification, and saving of orders, encapsulating order domain persistence concerns.

using ECommerceAPI.Data;
using ECommerceAPI.Models;
using Microsoft.EntityFrameworkCore;

namespace ECommerceAPI.Repositories.Implementations
{
    public class OrderRepository : IOrderRepository
    {
        private readonly ECommerceDbContext _context;
        public OrderRepository(ECommerceDbContext context)
        {
            _context = context;
        }

        public async Task<IEnumerable<Order>> GetAllAsync()
        {
            return await _context.Orders
                .Include(o => o.Customer)
                .Include(o => o.OrderItems)
                    .ThenInclude(oi => oi.Product)
                .AsNoTracking().ToListAsync();
        }

        public async Task<Order?> GetByIdAsync(int orderId)
        {
            return await _context.Orders
                .Include(o => o.Customer)
                .Include(o => o.OrderItems)
                    .ThenInclude(oi => oi.Product)
                .AsNoTracking()
                .FirstOrDefaultAsync(o => o.OrderId == orderId);
        }

        public async Task AddAsync(Order order)
        {
            await _context.Orders.AddAsync(order);
        }

        public void Update(Order order)
        {
            _context.Orders.Update(order);
        }

        public void Delete(Order order)
        {
            _context.Orders.Remove(order);
        }

        public async Task<bool> ExistsAsync(int id)
        {
            return await _context.Orders.AnyAsync(c => c.OrderId == id);
        }

        public async Task SaveAsync()
        {
            await _context.SaveChangesAsync();
        }
    }
}
Register Services in the Program.cs

Now, we need to register our Repositories and their concrete implementations into the dependency injection container. So, please add the following statements to the Program class.

builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
Modifying Controllers

Controllers act as the API endpoints that consume repository interfaces to perform business operations while handling HTTP requests and responses. They map between DTOs and domain entities manually or with helpers, validate incoming data, call appropriate repository methods for data retrieval or manipulation, and return appropriate HTTP status codes, thereby keeping business logic cleanly separated from data access. We need to modify the controllers to use Repositories to perform CRUD operations instead of a direct DbContex instance.

Modifying Categories Controller

Please modify the Categories Controller as follows. The CategoriesController serves as the API endpoint for category operations; it consumes the ICategoryRepository to fetch, add, update, or delete categories. It manually maps between CategoryDTO and Category entities, validates inputs, and returns appropriate HTTP responses, ensuring clean separation of concerns.

using ECommerceAPI.DTOs;
using ECommerceAPI.Models;
using ECommerceAPI.Repositories;
using Microsoft.AspNetCore.Mvc;

namespace ECommerceAPI.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class CategoriesController : ControllerBase
    {
        private readonly ICategoryRepository _categoryRepository;

        public CategoriesController(ICategoryRepository categoryRepository)
        {
            _categoryRepository = categoryRepository;
        }

        [HttpGet]
        public async Task<IActionResult> GetCategories()
        {
            var categories = await _categoryRepository.GetAllAsync();
            var dtos = categories.Select(c => new CategoryDTO
            {
                CategoryId = c.CategoryId,
                Name = c.Name,
                Description = c.Description
            });

            return Ok(dtos);
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetCategory(int id)
        {
            var c = await _categoryRepository.GetByIdAsync(id);
            if (c == null) return NotFound();

            var dto = new CategoryDTO
            {
                CategoryId = c.CategoryId,
                Name = c.Name,
                Description = c.Description
            };

            return Ok(dto);
        }

        [HttpPost]
        public async Task<IActionResult> CreateCategory([FromBody] CategoryDTO dto)
        {
            if (!ModelState.IsValid) return BadRequest(ModelState);

            var category = new Category
            {
                Name = dto.Name,
                Description = dto.Description
            };

            await _categoryRepository.AddAsync(category);
            await _categoryRepository.SaveAsync();

            dto.CategoryId = category.CategoryId; // get generated ID

            return Ok(dto);
        }

        [HttpPut("{id}")]
        public async Task<IActionResult> UpdateCategory(int id, [FromBody] CategoryDTO dto)
        {
            if (id != dto.CategoryId) 
                return BadRequest("Id mismatch");

            if (!ModelState.IsValid) 
                return BadRequest(ModelState);

            var existing = await _categoryRepository.GetByIdAsync(id);
            if (existing == null) 
                return NotFound();

            existing.Name = dto.Name;
            existing.Description = dto.Description;

            _categoryRepository.Update(existing);
            await _categoryRepository.SaveAsync();

            return NoContent();
        }

        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteCategory(int id)
        {
            var existing = await _categoryRepository.GetByIdAsync(id);
            if (existing == null) 
                return NotFound();

            _categoryRepository.Delete(existing);
            await _categoryRepository.SaveAsync();

            return NoContent();
        }
    }
}
Modifying Products Controller

Please modify the ProductsController as follows. The ProductsController handles HTTP requests related to products by interacting with IProductRepository and ICategoryRepository for validation. It performs manual DTO-to-entity mapping, manages entity-specific business logic like category existence checks, and delivers RESTful responses.

using ECommerceAPI.DTOs;
using ECommerceAPI.Models;
using ECommerceAPI.Repositories;
using Microsoft.AspNetCore.Mvc;

namespace ECommerceAPI.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class ProductsController : ControllerBase
    {
        private readonly IProductRepository _productRepository;
        private readonly ICategoryRepository _categoryRepository;

        public ProductsController(IProductRepository productRepository, ICategoryRepository categoryRepository)
        {
            _productRepository = productRepository;
            _categoryRepository = categoryRepository;
        }

        [HttpGet]
        public async Task<IActionResult> GetProducts()
        {
            var products = await _productRepository.GetAllAsync();

            var dtos = products.Select(p => new ProductDTO
            {
                ProductId = p.ProductId,
                Name = p.Name,
                Description = p.Description,
                Price = p.Price,
                CategoryId = p.CategoryId,
                CategoryName = p.Category?.Name
            });

            return Ok(dtos);
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetProduct(int id)
        {
            var p = await _productRepository.GetByIdAsync(id);
            if (p == null) return NotFound();

            var dto = new ProductDTO
            {
                ProductId = p.ProductId,
                Name = p.Name,
                Description = p.Description,
                Price = p.Price,
                CategoryId = p.CategoryId,
                CategoryName = p.Category?.Name
            };

            return Ok(dto);
        }

        [HttpPost]
        public async Task<IActionResult> CreateProduct([FromBody] ProductDTO dto)
        {
            if (!ModelState.IsValid) 
                return BadRequest(ModelState);

            bool categoryExists = await _categoryRepository.ExistsAsync(dto.CategoryId);
            if (!categoryExists) 
                return BadRequest("Invalid CategoryId");

            var product = new Product
            {
                Name = dto.Name,
                Description = dto.Description,
                Price = dto.Price,
                CategoryId = dto.CategoryId
            };

            await _productRepository.AddAsync(product);
            await _productRepository.SaveAsync();

            dto.ProductId = product.ProductId; // set generated ID

            return Ok(dto);
        }

        [HttpPut("{id}")]
        public async Task<IActionResult> UpdateProduct(int id, [FromBody] ProductDTO dto)
        {
            if (id != dto.ProductId) 
                return BadRequest("Id mismatch");

            if (!ModelState.IsValid) 
                return BadRequest(ModelState);

            var existing = await _productRepository.GetByIdAsync(id);
            if (existing == null) 
                return NotFound();

            bool categoryExists = await _categoryRepository.ExistsAsync(dto.CategoryId);
            if (!categoryExists) 
                return BadRequest("Invalid CategoryId");

            existing.Name = dto.Name;
            existing.Description = dto.Description;
            existing.Price = dto.Price;
            existing.CategoryId = dto.CategoryId;

            _productRepository.Update(existing);
            await _productRepository.SaveAsync();

            return NoContent();
        }

        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteProduct(int id)
        {
            var existing = await _productRepository.GetByIdAsync(id);
            if (existing == null) return NotFound();

            _productRepository.Delete(existing);
            await _productRepository.SaveAsync();

            return NoContent();
        }
    }
}
Modifying Customers Controller

Please modify the CustomersController as follows. The CustomersController manages customer-related API endpoints using ICustomerRepository to perform asynchronous CRUD operations. The controller validates incoming DTOs, converts between DTOs and entities manually, and ensures proper response status codes are returned.

using ECommerceAPI.DTOs;
using ECommerceAPI.Models;
using ECommerceAPI.Repositories;
using Microsoft.AspNetCore.Mvc;

namespace ECommerceAPI.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class CustomersController : ControllerBase
    {
        private readonly ICustomerRepository _customerRepository;

        public CustomersController(ICustomerRepository customerRepository)
        {
            _customerRepository = customerRepository;
        }

        [HttpGet]
        public async Task<IActionResult> GetCustomers()
        {
            var customers = await _customerRepository.GetAllAsync();

            var dtos = customers.Select(c => new CustomerDTO
            {
                CustomerId = c.CustomerId,
                FullName = c.FullName,
                Email = c.Email
            });

            return Ok(dtos);
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetCustomer(int id)
        {
            var c = await _customerRepository.GetByIdAsync(id);
            if (c == null) return NotFound();

            var dto = new CustomerDTO
            {
                CustomerId = c.CustomerId,
                FullName = c.FullName,
                Email = c.Email
            };

            return Ok(dto);
        }

        [HttpPost]
        public async Task<IActionResult> CreateCustomer([FromBody] CustomerDTO dto)
        {
            if (!ModelState.IsValid) 
                return BadRequest(ModelState);

            var customer = new Customer
            {
                FullName = dto.FullName,
                Email = dto.Email
            };

            await _customerRepository.AddAsync(customer);
            await _customerRepository.SaveAsync();

            dto.CustomerId = customer.CustomerId; // set generated ID

            return Ok(dto);
        }

        [HttpPut("{id}")]
        public async Task<IActionResult> UpdateCustomer(int id, [FromBody] CustomerDTO dto)
        {
            if (id != dto.CustomerId) 
                return BadRequest("Id mismatch");

            if (!ModelState.IsValid) 
                return BadRequest(ModelState);

            var existing = await _customerRepository.GetByIdAsync(id);
            if (existing == null) 
                return NotFound();

            existing.FullName = dto.FullName;
            existing.Email = dto.Email;

            _customerRepository.Update(existing);
            await _customerRepository.SaveAsync();

            return NoContent();
        }

        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteCustomer(int id)
        {
            var existing = await _customerRepository.GetByIdAsync(id);
            if (existing == null) 
                return NotFound();

            _customerRepository.Delete(existing);
            await _customerRepository.SaveAsync();

            return NoContent();
        }
    }
}
Modifying Orders Controller

Please modify the Orders Controller as follows. The OrdersController handles order processing by utilizing IOrderRepository, ICustomerRepository, and IProductRepository for comprehensive validation and data handling. It manages complex mapping between nested DTOs and entities, handles creation, updates, deletions, and serves detailed order information via REST endpoints.

using ECommerceAPI.DTOs;
using ECommerceAPI.Models;
using ECommerceAPI.Repositories;
using Microsoft.AspNetCore.Mvc;

namespace ECommerceAPI.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class OrdersController : ControllerBase
    {
        private readonly IOrderRepository _orderRepository;
        private readonly ICustomerRepository _customerRepository;
        private readonly IProductRepository _productRepository;

        public OrdersController(
            IOrderRepository orderRepository,
            ICustomerRepository customerRepository,
            IProductRepository productRepository)
        {
            _orderRepository = orderRepository;
            _customerRepository = customerRepository;
            _productRepository = productRepository;
        }

        [HttpGet]
        public async Task<IActionResult> GetOrders()
        {
            var orders = await _orderRepository.GetAllAsync();

            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() ?? new List<OrderItemDTO>()
            });

            return Ok(dtos);
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetOrder(int id)
        {
            var o = await _orderRepository.GetByIdAsync(id);
            if (o == null) 
                return NotFound();

            var dto = 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() ?? new List<OrderItemDTO>()
            };

            return Ok(dto);
        }

        [HttpPost]
        public async Task<IActionResult> CreateOrder([FromBody] OrderDTO dto)
        {
            if (!ModelState.IsValid) 
                return BadRequest(ModelState);

            bool customerExists = await _customerRepository.ExistsAsync(dto.CustomerId);

            if (!customerExists) 
                return BadRequest("Invalid CustomerId");

            foreach (var item in dto.OrderItems)
            {
                bool productExists = await _productRepository.ExistsAsync(item.ProductId);

                if (!productExists) 
                    return BadRequest($"Invalid ProductId: {item.ProductId}");
            }

            var order = new Order
            {
                CustomerId = dto.CustomerId,
                OrderDate = DateTime.UtcNow,
                OrderAmount = dto.OrderItems.Sum(i => i.Quantity * i.UnitPrice),
                OrderItems = dto.OrderItems.Select(i => new OrderItem
                {
                    ProductId = i.ProductId,
                    Quantity = i.Quantity,
                    UnitPrice = i.UnitPrice
                }).ToList()
            };

            await _orderRepository.AddAsync(order);
            await _orderRepository.SaveAsync();

            dto.OrderId = order.OrderId;
            dto.OrderDate = order.OrderDate;

            return Ok(dto);
        }

        [HttpPut("{id}")]
        public async Task<IActionResult> UpdateOrder(int id, [FromBody] OrderDTO dto)
        {
            if (id != dto.OrderId) 
                return BadRequest("Id mismatch");
            if (!ModelState.IsValid) 
                return BadRequest(ModelState);

            var existing = await _orderRepository.GetByIdAsync(id);
            if (existing == null) 
                return NotFound();

            bool customerExists = await _customerRepository.ExistsAsync(dto.CustomerId);
            if (!customerExists) 
                return BadRequest("Invalid CustomerId");

            foreach (var item in dto.OrderItems)
            {
                bool productExists = await _productRepository.ExistsAsync(item.ProductId);
                if (!productExists) 
                    return BadRequest($"Invalid ProductId: {item.ProductId}");
            }

            existing.CustomerId = dto.CustomerId;
            existing.OrderDate = dto.OrderDate;
            existing.OrderAmount = dto.OrderItems.Sum(i => i.Quantity * i.UnitPrice);

            // Update OrderItems: Clear existing and add new (simplified)
            existing.OrderItems.Clear();
            existing.OrderItems = dto.OrderItems.Select(i => new OrderItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                UnitPrice = i.UnitPrice
            }).ToList();

            _orderRepository.Update(existing);
            await _orderRepository.SaveAsync();

            return NoContent();
        }

        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteOrder(int id)
        {
            var existing = await _orderRepository.GetByIdAsync(id);
            if (existing == null) 
                return NotFound();

            _orderRepository.Delete(existing);
            await _orderRepository.SaveAsync();

            return NoContent();
        }
    }
}
Role of Interfaces and Abstractions in Loose Coupling

Loose coupling is a design principle in which components (classes, modules) interact with each other with minimal knowledge of each other’s internal details. This improves flexibility, maintainability, and testability. Let’s understand how interfaces and abstractions help achieve loose coupling in application development.

  • Define Contracts, Not Implementations: An interface is a contract that specifies what operations a component must provide, without saying how they are done. By depending on interfaces rather than concrete classes, components only rely on agreed behaviors, not on specific implementations.
  • Decouple Components: When one component depends on an interface, it doesn’t need to know which concrete class implements it. This separation means you can change, replace, or extend implementations without affecting dependent components.
  • Enable Dependency Injection and Flexibility: Interfaces allow frameworks or your own code to inject different implementations at runtime, enabling flexible configurations (e.g., mocking during testing, switching database providers).
  • Improve Testability: You can easily create mock or stub implementations of interfaces to isolate and test components independently without relying on actual database, network, or external dependencies.
Example in Repository Pattern
  • Your controllers depend on repository interfaces (e.g., ICategoryRepository), not concrete classes.
  • You can swap out the actual repository implementation (e.g., EF Core-based, In-memory, or Mock) without changing controllers.
  • This separation keeps your application layers independent and maintainable.

In short, interfaces and abstractions act as contracts that hide implementation details, allowing components to interact with minimal knowledge about each other. This reduces dependencies and creates a flexible, maintainable, and testable architecture, i.e., loose coupling.

Benefits of Non-Generic Repository Pattern in ASP.NET Core Web API
  • Clear Separation of Concerns: By moving all data access logic into dedicated repository classes, controllers are freed from handling database operations directly. This separation improves code organization and clarity, making the system easier to understand and maintain.
  • Centralized Data Access Code: Each entity’s data operations are consolidated into one repository, serving as the single source of truth for all CRUD and query logic related to that entity. This centralization allows easier optimization, debugging, and enforcement of data access rules.
  • Enhanced Testability: Since repositories are defined through interfaces (e.g., ICategoryRepository), we can easily create mock implementations during unit testing. This isolation allows testing controllers or services without relying on an actual database, speeding up tests and improving reliability.
  • Flexibility for Custom Business Logic: Non-generic repositories provide a natural place to implement entity-specific business rules or complex queries that don’t fit a generic template. For example, a ProductRepository might include inventory checks or price calculations unique to products without cluttering other repositories.
Drawbacks of Non-Generic Repository Pattern in ASP.NET Core Web API
  • Code Repetition: Because each entity has its own repository with similar CRUD methods, method signatures and implementations are duplicated across repositories, which can increase the overall codebase size and lead to repetitive work.
  • Scalability Concerns: As the number of entities grows, maintaining many separate repository classes can become cumbersome. Developers may need to update the same CRUD logic in multiple places, increasing the risk of inconsistencies.
Solution: Introducing the Generic Repository Pattern in ASP.NET Core Web API

To address these challenges, the Generic Repository Pattern in ASP.NET Core Web API is used:

  • Code Reuse: The generic repository provides a single, reusable implementation for common CRUD operations applicable to all entities, significantly reducing repetitive code.
  • Consistency: Centralizing shared data access logic enforces uniform behavior across entities, reducing inconsistencies and bugs.
  • Extensibility: The generic repository can be extended or combined with non-generic repositories when entity-specific logic is needed, offering a balanced, flexible architecture.
  • Simplified Maintenance: Updates to common data operations need to be made in one place only, improving maintainability and speeding up development.
When to Prefer a Non-Generic Repository Over Generic One

Despite the benefits of generic repositories, non-generic repositories remain valuable when:

  • Entities have distinct business logic or custom data access requirements that don’t fit generic methods.
  • Complex queries, joins, or stored procedures specific to an entity are needed.
  • Clear separation of responsibilities and boundaries is crucial to avoid overly broad, hard-to-manage generic repositories.

In the next article, I will discuss Implementing Generic Repository Pattern in ASP.NET Core Web API Applications. In this article, I explain Implementing Non-Generic Repository Pattern in ASP.NET Core Web API Application with Examples. I hope you enjoy this article on Implementing Non-Generic Repository Pattern in ASP.NET Core Web API.

Leave a Reply

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