All articles

Implement Repository Pattern with ASP.NET Core Web API

Implement the Repository Pattern with ASP.NET Core Web API on .NET 10 — EF Core 10, DTO mapping with extension methods, async CRUD operations, and the built-in OpenAPI support with Scalar.

0 · log in to like, save & follow Share on LinkedIn Share on X

Updated for .NET 10 — this tutorial now targets .NET 10 and EF Core 10, uses the OpenAPI support built into ASP.NET Core (with the Scalar API browser instead of Swagger UI), and maps DTOs with simple, dependency-free extension methods.

Advantages of Repository Pattern

  • Dependency Injection: you inject the interface, so in unit tests you can mock the repository and test the controller on its own. If the controller talks to the EF Core DbContext directly, there is nothing to mock and the test needs a real database. For more details see Dependency Injection

  • Decouples Persistence Framework

    With the repository in between, changing the persistence framework later — say from EF Core to Dapper — touches only the repository classes. The business layer does not know or care what sits behind the interface.

  • Minimize Duplicate Query Logic

    Say you need the products of a category in five different places. Without a repository you end up copying the same query five times:

    _context.Products
       .Where(p => p.CategoryId == categoryId)
       .OrderBy(p => p.ProductId)
       .Take(10);
    

    Put the query in the repository once, and every caller reduces to one line:

    _productRepository.GetProductsByCategoryId(categoryId);
    

Repository Pattern with ASP.NET Core Web API — Controller, ICategoryRepository, CategoryRepository and SQL Server

Steps to Implement Repository Pattern

  1. Setting up Application

    1. Create Database

      For this tutorial, we will be using a SQL database having Category as a table. The Web API will perform CRUD operations on this database. Create a SQL database with the name GeekStore. You can download the Create GeekStore database SQL script.

      Update the connection string in appsettings.json

      "ConnectionStrings": {
          "GeekStore": "Server=localhost;Database=GeekStore;Trusted_Connection=True;TrustServerCertificate=True"
      }
      
    2. Create ASP.NET Core Web API

      Create an ASP.NET Core Web API application with the name GeekStore. This blog uses .NET 10 — from the command line:

      dotnet new webapi --use-controllers -n GeekStore.API.Core
      

      (In Visual Studio 2026, pick the ASP.NET Core Web API template and check Use controllers.) For more detailed steps please visit Create ASP.NET Core Web API with Entity Framework.

      Create folders as shown

      • Contracts: for repository contracts.
      • Controllers: for API controllers.
      • Data.Models: for domain models generated by EF Core.
      • DTOs: for Data Transfer Objects.
      • Mappings: for DTO mapping extension methods.
      • Repository: for repository classes.

      ASP.NET Core Web API Repository Pattern folder Structure

      Register the DbContext in Program.cs.

      var builder = WebApplication.CreateBuilder(args);
      
      var connectionString = builder.Configuration.GetConnectionString("GeekStore");
      
      builder.Services.AddDbContext<GeeksStoreContext>(options =>
          options.UseSqlServer(connectionString));
      
    3. Install NuGet Packages

      We are using Entity Framework Core for data access and the built-in OpenAPI support with the Scalar API browser for testing the endpoints. Install the following NuGet packages:

      1. Microsoft.EntityFrameworkCore
      2. Microsoft.EntityFrameworkCore.SqlServer
      3. Microsoft.EntityFrameworkCore.Tools
      4. Microsoft.AspNetCore.OpenApi
      5. Scalar.AspNetCore

      Note: earlier versions of this tutorial used AutoMapper. AutoMapper has since moved to a commercial license (and versions below 15.1.1 carry a known denial-of-service advisory), so this tutorial now maps DTOs with plain extension methods — no extra dependency, and the mapping is explicit and compile-time checked.

    4. Generate Data Model

      You can generate database models using EF Core Database first. Use this command in the Package Manager Console to generate the data models:

      Scaffold-DbContext "Server=localhost;Database=GeekStore;Trusted_Connection=True;TrustServerCertificate=True"
          -Provider Microsoft.EntityFrameworkCore.SqlServer
          -OutputDir Data.Models
      

      The generated models will be available in the Data.Models folder.

      Do one cleanup after scaffolding: delete the OnConfiguring override that carries a hard-coded connection string. The connection string lives in appsettings.json, and we already registered the context with AddDbContext. With a primary constructor the context declaration shrinks to:

      public partial class GeeksStoreContext(DbContextOptions<GeeksStoreContext> options)
          : DbContext(options)
      {
          public virtual DbSet<Category> Categories { get; set; } = null!;
          public virtual DbSet<Product> Products { get; set; } = null!;
          // OnModelCreating stays as scaffolded
      }
      
  2. Create Interface

    The interface is what makes the pattern useful for testing: the business layer depends on the contract, not on the database or how it is set up.

    You can also have more than one implementation of the same contract — for example one repository for the GeekStore online store and another for its back office.

    Add a new interface with the name ICategoryRepository to the folder Contracts, and add all required CRUD operation definitions.

    using GeekStore.API.Core.Data.Models;
    
    namespace GeekStore.API.Core.Contracts
    {
        public interface ICategoryRepository
        {
            Task<Category?> GetAsync(int? categoryId);
    
            Task<List<Category>> GetAllAsync();
    
            Task<Category> CreateAsync(Category category);
    
            Task<bool> DeleteAsync(int categoryId);
    
            Task UpdateAsync(Category category);
        }
    }
    

    Notice that DeleteAsync returns Task<bool>. The repository only reports whether something was deleted; it does not know about HTTP. Status codes are the controller's business, and keeping them there is exactly the separation this pattern is about.

  3. Create Repository Class

    The repository class implements the contract and holds all the data-access logic. It works purely in entity models — DTO concerns stay out of it.

    Add a new class named CategoryRepository.cs to the Repository folder; it implements the ICategoryRepository contract. With a primary constructor the DbContext dependency sits right in the class header, so there is no constructor boilerplate at all:

    using GeekStore.API.Core.Contracts;
    using GeekStore.API.Core.Data.Models;
    using Microsoft.EntityFrameworkCore;
    
    namespace GeekStore.API.Core.Repository
    {
        public class CategoryRepository(GeeksStoreContext context) : ICategoryRepository
        {
        }
    }
    

    Add the following methods to CategoryRepository:

    1. GetAllAsync

      This method gets all categories available in the database and returns them as a list. AsNoTracking() tells EF Core this is a read-only query, skipping change tracking for faster reads.

      public async Task<List<Category>> GetAllAsync()
      {
          return await context.Categories.AsNoTracking().ToListAsync();
      }
      
    2. GetAsync

      This method accepts an integer parameter categoryId and returns the Category details.

      public async Task<Category?> GetAsync(int? categoryId)
      {
          if (categoryId is null)
          {
              return null;
          }
      
          return await context.Categories.FindAsync(categoryId);
      }
      
    3. CreateAsync

      This method accepts a Category object, inserts it into the database as a new Category, and returns the newly created Category.

      public async Task<Category> CreateAsync(Category category)
      {
          context.Categories.Add(category);
          await context.SaveChangesAsync();
          return category;
      }
      
    4. DeleteAsync

      This method accepts an integer parameter categoryId and deletes that category from the database. When the category does not exist it returns false rather than throwing — deciding what that means for the HTTP response is the controller's job.

      public async Task<bool> DeleteAsync(int categoryId)
      {
          var category = await GetAsync(categoryId);
          if (category is null)
          {
              return false;
          }
      
          context.Categories.Remove(category);
          await context.SaveChangesAsync();
          return true;
      }
      
    5. UpdateAsync

      Updates Category values in the database as provided by the client/controller.

      public async Task UpdateAsync(Category category)
      {
          context.Update(category);
          await context.SaveChangesAsync();
      }
      
  4. Map Domain Models to DTOs

    The repository returns domain models, but the controller should expose DTOs. Earlier this mapping was AutoMapper's job. Plain extension methods do it in the same number of lines, with no extra package — and if a DTO and an entity drift apart, the compiler catches it at build time instead of AutoMapper failing at runtime.

    You can find all required DTO class definitions here.

    Add a new class CategoryMappings.cs to the folder Mappings:

    using GeekStore.API.Core.Data.Models;
    using GeekStore.API.Core.DTOs.Category;
    
    namespace GeekStore.API.Core.Mappings
    {
        public static class CategoryMappings
        {
            public static Category ToEntity(this CreateCategoryDto dto) => new()
            {
                CategoryName = dto.CategoryName,
                Description = dto.Description,
                CategoryDiscount = dto.CategoryDiscount
            };
    
            public static GetCategoryDetailsDto ToDetailsDto(this Category category) => new()
            {
                Id = category.Id,
                CategoryName = category.CategoryName,
                Description = category.Description,
                CategoryDiscount = category.CategoryDiscount
            };
    
            public static void ApplyTo(this UpdateCategoryDto dto, Category category)
            {
                category.CategoryName = dto.CategoryName;
                category.Description = dto.Description;
                category.CategoryDiscount = dto.CategoryDiscount;
            }
        }
    }
    
  5. APIController

    The CategoryRepository is ready with all CRUD operations, so the controller can use it now. Add a new API controller named CategoryController to the Controllers folder and inject the repository through the primary constructor:

    using GeekStore.API.Core.Contracts;
    using GeekStore.API.Core.Data.Models;
    using GeekStore.API.Core.DTOs.Category;
    using GeekStore.API.Core.Mappings;
    using Microsoft.AspNetCore.Mvc;
    
    namespace GeekStore.API.Core.Controllers
    {
        [Route("api/[controller]")]
        [ApiController]
        public class CategoryController(ICategoryRepository categoryRepository) : ControllerBase
        {
        }
    }
    

    Add the following CRUD operations that use the Category repository:

    1. GetAllCategories

      Get the list of categories from the repository and return it to the client. Note what crosses the boundary here: the repository hands back Category domain models, the action returns GetCategoryDetailsDto DTOs.

      [HttpGet("GetAllCategories")]
      public async Task<ActionResult<List<GetCategoryDetailsDto>>> GetAllCategories()
      {
          var categories = await categoryRepository.GetAllAsync();
          var records = categories.Select(c => c.ToDetailsDto()).ToList();
          return Ok(records);
      }
      
    2. GetCategory

      Get the details of a single Category. When the id does not exist we return 404 Not Found — a missing record is an expected condition, not something to throw an exception for.

      // GET: api/Category/GetCategory?categoryId=1
      [HttpGet("GetCategory")]
      public async Task<ActionResult<GetCategoryDetailsDto>> GetCategory(int categoryId)
      {
          var category = await categoryRepository.GetAsync(categoryId);
      
          if (category is null)
          {
              return NotFound($"CategoryID {categoryId} is not found.");
          }
      
          return Ok(category.ToDetailsDto());
      }
      
    3. CreateCategory

      Create a new Category — the HttpPost method. CreatedAtAction responds with 201 Created and a Location header pointing to the new resource. Using nameof(GetCategory) instead of a string means a rename cannot silently break the link.

      [HttpPost]
      public async Task<ActionResult<Category>> CreateCategory(CreateCategoryDto createCategoryDto)
      {
          var category = createCategoryDto.ToEntity();
      
          await categoryRepository.CreateAsync(category);
      
          return CreatedAtAction(nameof(GetCategory), new { categoryId = category.Id }, category);
      }
      
    4. UpdateCategory

      This method updates a Category with the required validation. This is the HttpPut method.

      [HttpPut("UpdateCategory")]
      public async Task<IActionResult> UpdateCategory(int categoryId, UpdateCategoryDto updateCategoryDto)
      {
          if (categoryId != updateCategoryDto.Id)
          {
              return BadRequest("Invalid Category Id");
          }
      
          var category = await categoryRepository.GetAsync(categoryId);
      
          if (category is null)
          {
              return NotFound($"CategoryID {categoryId} is not found.");
          }
      
          updateCategoryDto.ApplyTo(category);
          await categoryRepository.UpdateAsync(category);
      
          return NoContent();
      }
      
    5. DeleteCategory

      Delete the Category with the given categoryId. The bool from the repository maps directly to the right status code: 204 No Content when deleted, 404 Not Found when it never existed.

      [HttpDelete("DeleteCategory")]
      public async Task<IActionResult> DeleteCategory(int categoryId)
      {
          var deleted = await categoryRepository.DeleteAsync(categoryId);
          return deleted ? NoContent() : NotFound($"CategoryID {categoryId} is not found.");
      }
      
  6. Register Dependency of Repository

    How the IoC container creates repository instances depends on the registration. We want a fresh CategoryRepository per request, so register it as AddScoped — scoped instances live for exactly one request, same as the DbContext they wrap. For more details check Dependency Injection lifetime.

    One more thing before running: since .NET 9, ASP.NET Core generates the OpenAPI document by itself, so Swashbuckle is no longer needed. Scalar fills the role Swagger UI used to play — an interactive page to try the endpoints. The complete Program.cs:

    using GeekStore.API.Core.Contracts;
    using GeekStore.API.Core.Data.Models;
    using GeekStore.API.Core.Repository;
    using Microsoft.EntityFrameworkCore;
    using Scalar.AspNetCore;
    
    var builder = WebApplication.CreateBuilder(args);
    
    var connectionString = builder.Configuration.GetConnectionString("GeekStore");
    
    builder.Services.AddDbContext<GeeksStoreContext>(options =>
        options.UseSqlServer(connectionString));
    
    builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
    
    builder.Services.AddControllers();
    builder.Services.AddOpenApi();
    
    var app = builder.Build();
    
    if (app.Environment.IsDevelopment())
    {
        app.MapOpenApi();                 // /openapi/v1.json
        app.MapScalarApiReference();      // interactive API browser at /scalar/v1
    }
    
    app.UseHttpsRedirection();
    app.MapControllers();
    app.Run();
    

    Run the application and open /scalar/v1 in the browser. All CRUD operations are listed there and you can execute each one from the page itself.

    Implement Repository Pattern with ASP.NET Core Web API

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.