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);

Steps to Implement Repository Pattern
Setting up Application
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" }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.

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));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:
- Microsoft.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.Tools
- Microsoft.AspNetCore.OpenApi
- 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.
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.ModelsThe generated models will be available in the Data.Models folder.
Do one cleanup after scaffolding: delete the
OnConfiguringoverride that carries a hard-coded connection string. The connection string lives inappsettings.json, and we already registered the context withAddDbContext. 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 }
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
DeleteAsyncreturnsTask<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.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:
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(); }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); }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; }DeleteAsync
This method accepts an integer parameter categoryId and deletes that category from the database. When the category does not exist it returns
falserather 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; }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(); }
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; } } }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:
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); }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()); }CreateCategory
Create a new Category — the HttpPost method.
CreatedAtActionresponds with 201 Created and aLocationheader pointing to the new resource. Usingnameof(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); }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(); }DeleteCategory
Delete the Category with the given categoryId. The
boolfrom 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."); }
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.

Comments (0)
No comments yet — be the first to share your thoughts.