Backend Development with .NET
Session 11
Repository Pattern
& DTOs
Eng. Seif Mansour  ·  Andalusia Academy
Week 6  ·  2 hours
Session Goals

By the end of this session, you will be able to:

  • Implement the repository pattern to abstract database access behind a clean interface
  • Explain why exposing entity objects directly from an API is a security and stability risk
  • Define request and response DTOs and map between entities and DTOs using AutoMapper
  • Prevent over-posting attacks by controlling which fields clients are allowed to submit
Session Agenda
Time Segment Type Duration
0:00Why repository pattern?Theory15 min
0:15Implementing ITaskRepositoryDemo25 min
0:40DTOs — request vs response modelsTheory15 min
0:55AutoMapper setupDemo20 min
1:15Lab — refactor to repository + DTOsLab35 min
1:50Wrap-upDiscussion10 min
The Repository Pattern
Your service layer currently knows about DbContext directly. The repository pattern puts a typed interface between them — one that describes what data operations are possible without revealing how they are implemented.
In this section
  • Why tight coupling to DbContext is a problem
  • Designing the ITaskRepository interface
  • Implementing TaskRepository with EF Core
  • Registering the repository in the DI container
Why Not Use DbContext Directly?
  • Leaking infrastructure into business logic — when a service calls _context.Tasks.Where(...).ToListAsync(), the EF Core query language bleeds into a layer that should only care about business rules
  • Hard to test — spinning up a real DbContext in a unit test requires a database. With an interface you swap in a mock that returns fake data instantly
  • Hard to change the database — if you ever move from SQL Server to PostgreSQL or a document store, every service that calls DbContext must be rewritten
  • No single place for query logic — the same Where(t => t.UserId == userId) filter gets duplicated across multiple services and controllers
  • Violates the Dependency Inversion Principle — high-level modules (services) should depend on abstractions, not on concrete infrastructure classes
Designing ITaskRepository
Repositories/ITaskRepository.cs
public interface ITaskRepository
{
    Task<TaskItem?> GetByIdAsync(int id);
    Task<PagedResult<TaskItem>> GetAllAsync(TaskFilterParams filter);
    Task<TaskItem> CreateAsync(TaskItem task);
    Task UpdateAsync(TaskItem task);
    Task DeleteAsync(TaskItem task);
}
Interface-first design
Define the interface before writing the implementation. The interface is what the service layer sees — it describes the contract in terms of your domain (TaskItem, PagedResult), not in terms of EF Core (DbSet, IQueryable). Write the implementation only after the interface is stable.
Implementing TaskRepository
Repositories/TaskRepository.cs
public class TaskRepository : ITaskRepository
{
    private readonly AppDbContext _context;
    public TaskRepository(AppDbContext context) => _context = context;

    public async Task<TaskItem?> GetByIdAsync(int id)
        => await _context.Tasks.FindAsync(id);

    public async Task<TaskItem> CreateAsync(TaskItem task)
    {
        _context.Tasks.Add(task);
        await _context.SaveChangesAsync();
        return task;
    }

    public async Task UpdateAsync(TaskItem task)
    {
        _context.Tasks.Update(task);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteAsync(TaskItem task)
    {
        _context.Tasks.Remove(task);
        await _context.SaveChangesAsync();
    }
}
Registering the Repository

Register the concrete class against the interface in Program.cs. The DI container resolves ITaskRepository to TaskRepository at runtime:

Program.cs
builder.Services.AddScoped<
    ITaskRepository,
    TaskRepository>();

AddScoped creates one instance per HTTP request — matching the lifetime of DbContext, which is also scoped. Always align repository lifetime with the context lifetime it wraps.

The service receives the interface through constructor injection:

Services/TaskService.cs
public class TaskService
{
    private readonly ITaskRepository _repo;

    public TaskService(ITaskRepository repo)
        => _repo = repo;

    public async Task<TaskItem?> GetByIdAsync(int id)
        => await _repo.GetByIdAsync(id);
}
Result
The service no longer imports or references AppDbContext. It depends only on the interface — which can be mocked in tests without touching a database.
DTOs — Request & Response Models
A DTO (Data Transfer Object) is a class shaped for communication, not for storage. It carries only what the API consumer needs to send or receive — nothing more. Returning entity objects directly is a security vulnerability, not a time-saver.
In this section
  • Why exposing entities breaks security
  • Response DTOs — what the client receives
  • Request DTOs — preventing over-posting
  • DTO naming conventions
Key Principle
"Never serialize your entity objects directly.
The API surface and the database schema
are two different contracts."
Entities are shaped for the database. DTOs are shaped for the consumer. Conflating the two leaks sensitive fields, creates circular reference errors, and ties your API shape permanently to your database schema — making any future change a breaking change.
Why Entities Are Dangerous to Expose

Returning a User entity from a controller action sends every mapped field to the client:

// Entity — never serialize this
public class User
{
    public int Id { get; set; }
    public string Email { get; set; } = "";
    // NEVER expose these:
    public string PasswordHash { get; set; } = "";
    public string RefreshToken { get; set; } = "";
    public string InternalNotes { get; set; } = "";
}

A response DTO exposes only what the consumer legitimately needs:

// Response DTO — safe to return
public class UserResponseDto
{
    public int Id { get; set; }
    public string Email { get; set; } = "";
    // PasswordHash and RefreshToken
    // are simply not here — they
    // cannot be accidentally serialized
}
Security risk
JSON serializers serialize all public properties by default. Forgetting to add [JsonIgnore] to a sensitive field means that field ships to every client. A DTO eliminates the risk entirely — you cannot accidentally include a field that was never defined.
Defining a Response DTO
DTOs/TaskItemDto.cs
public class TaskItemDto
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string? Description { get; set; }
    public bool IsCompleted { get; set; }
    public DateTime? DueDate { get; set; }
    public DateTime CreatedAt { get; set; }
}

Compare this to the entity: UserId, internal foreign keys, and navigation properties are absent. Clients receive exactly what they need to display a task — nothing they could misuse.

Naming convention
Suffix response DTOs with Dto (e.g., TaskItemDto). Suffix request DTOs with Request (e.g., CreateTaskRequest, UpdateTaskRequest). This makes the direction of flow clear at a glance anywhere in the codebase.
Request DTOs — Preventing Over-Posting

Over-posting is when a client sends fields the server did not intend to accept — and the model binder sets them on the entity anyway.

Without a request DTO, a malicious POST body like this could set privileged fields:

{
  "title": "My Task",
  "id": 999,
  "isAdmin": true,
  "createdAt": "2020-01-01"
}

A request DTO defines exactly which fields the client may send:

DTOs/CreateTaskRequest.cs
public class CreateTaskRequest
{
    [Required]
    [MaxLength(200)]
    public string Title { get; set; } = "";

    public string? Description { get; set; }

    public DateTime? DueDate { get; set; }

    // Id, CreatedAt, UserId are NOT here.
    // The client cannot set them — ever.
}
Defense
Fields not on the request DTO are never bound — regardless of what the client sends. No [Bind] attributes, no opt-outs needed.
Update and Delete Request DTOs

Update requests typically allow a subset of fields to be changed. Fields the client should never update — like CreatedAt or UserId — are simply absent:

DTOs/UpdateTaskRequest.cs
public class UpdateTaskRequest
{
    [Required]
    [MaxLength(200)]
    public string Title { get; set; } = "";

    public string? Description { get; set; }

    public bool IsCompleted { get; set; }

    public DateTime? DueDate { get; set; }
}

The controller passes the route id separately — it comes from the URL, not the body, so it cannot be spoofed through the request payload:

Controllers/TasksController.cs
[HttpPut("{id}")]
public async Task<IActionResult> Update(
    int id,
    [FromBody] UpdateTaskRequest dto)
{
    var task = await _repo.GetByIdAsync(id);
    if (task is null) return NotFound();

    task.Title       = dto.Title;
    task.Description = dto.Description;
    task.IsCompleted = dto.IsCompleted;
    task.DueDate     = dto.DueDate;

    await _repo.UpdateAsync(task);
    return NoContent();
}
AutoMapper
Manually copying fields from an entity to a DTO — and back — produces repetitive, error-prone code. AutoMapper generates those assignments from a declared mapping profile, eliminating the boilerplate while keeping the conversion logic in one place.
In this section
  • Installing AutoMapper
  • Writing a MappingProfile
  • Registering AutoMapper in DI
  • Injecting and using IMapper in services
The Manual Mapping Problem

Without a mapping library, every service method hand-copies properties between the entity and the DTO:

// Repeated in every service method
var dto = new TaskItemDto
{
    Id          = task.Id,
    Title       = task.Title,
    Description = task.Description,
    IsCompleted = task.IsCompleted,
    DueDate     = task.DueDate,
    CreatedAt   = task.CreatedAt
};
return dto;

With AutoMapper this becomes a single call. The field-copying is generated from the mapping profile:

// One call — AutoMapper handles the rest
var dto = _mapper.Map<TaskItemDto>(task);
return dto;
Not magic
AutoMapper matches properties by name. If a source and destination property have different names, you must configure the mapping explicitly. Trust the convention for simple cases; be explicit when names diverge.
Installing AutoMapper & Writing a Profile
dotnet add package AutoMapper
Mappings/MappingProfile.cs
public class MappingProfile : Profile
{
    public MappingProfile()
    {
        // Entity -> Response DTO (property names match — no extra config needed)
        CreateMap<TaskItem, TaskItemDto>();

        // Request DTO -> Entity (set server-controlled fields explicitly)
        CreateMap<CreateTaskRequest, TaskItem>()
            .ForMember(dest => dest.CreatedAt,
                       opt  => opt.MapFrom(_ => DateTime.UtcNow));
    }
}

ForMember lets you override how a single destination property is filled. Here CreatedAt is always set to the server's current UTC time — the client cannot influence it even if they send a value.

Registering AutoMapper & Injecting IMapper

One line in Program.cs scans the assembly for all classes that inherit Profile and registers them:

Program.cs
builder.Services.AddAutoMapper(
    typeof(MappingProfile));

Pass any type from the assembly that contains the profiles. AutoMapper discovers all Profile subclasses in that assembly automatically.

Inject IMapper into the service via the constructor:

Services/TaskService.cs
public class TaskService
{
    private readonly ITaskRepository _repo;
    private readonly IMapper _mapper;

    public TaskService(
        ITaskRepository repo,
        IMapper mapper)
    {
        _repo   = repo;
        _mapper = mapper;
    }

    public async Task<TaskItemDto?> GetByIdAsync(int id)
    {
        var task = await _repo.GetByIdAsync(id);
        return task is null
            ? null
            : _mapper.Map<TaskItemDto>(task);
    }
}
Create and Map in One Flow
Services/TaskService.cs
public async Task<TaskItemDto> CreateAsync(CreateTaskRequest request)
{
    // Map request DTO -> entity (AutoMapper sets CreatedAt via ForMember)
    var task = _mapper.Map<TaskItem>(request);

    // Persist via repository (no DbContext in sight)
    var created = await _repo.CreateAsync(task);

    // Map entity -> response DTO (sensitive fields absent by design)
    return _mapper.Map<TaskItemDto>(created);
}

Notice the three layers are cleanly separated: the request DTO enters, the entity is persisted through the repository, and the response DTO exits. No DbContext, no raw entity, no sensitive fields reach the controller.

Lab — Refactor to Repository & DTOs
Take the Task API built in Sessions 09–10 and apply everything covered today: introduce the repository interface, replace direct DbContext usage in services, add request and response DTOs, configure AutoMapper, and verify no sensitive fields leak through any endpoint.
In this section
  • Create ITaskRepository and TaskRepository
  • Define TaskItemDto, CreateTaskRequest, UpdateTaskRequest
  • Add a MappingProfile and register AutoMapper
  • Update TaskService to use ITaskRepository and return DTOs
  • Verify no sensitive fields appear in any response
Lab — Refactor to Repository & DTOs

Refactor the Task API to use the repository pattern and DTOs throughout. Each step builds on the last — complete them in order.

1 Create ITaskRepository in a Repositories/ folder and implement TaskRepository using AppDbContext
2 Create TaskItemDto, CreateTaskRequest, and UpdateTaskRequest in a DTOs/ folder
3 Add a MappingProfile class with mappings for all three pairs, registering AutoMapper in Program.cs
4 Update TaskService to inject ITaskRepository and IMapper, removing all direct AppDbContext references
5 Verify via Swagger UI that every endpoint returns TaskItemDto shapes — check that PasswordHash, RefreshToken, or any other sensitive field is absent from all responses
Summary
  • The repository pattern places an interface between the service layer and the database — services depend on ITaskRepository, not on DbContext, making the data layer swappable and testable
  • Register repositories as Scoped to match the lifetime of the DbContext they wrap — one instance per HTTP request
  • Response DTOs expose only what the consumer legitimately needs — fields like password hashes and refresh tokens are absent by design, not by annotation
  • Request DTOs prevent over-posting — server-controlled fields such as Id, CreatedAt, and role flags are simply not present in the request model
  • AutoMapper eliminates hand-copying — declare mappings once in a Profile class and call _mapper.Map<T>(source) everywhere
  • Use ForMember whenever a server-controlled value (such as DateTime.UtcNow) must be set during mapping rather than read from the incoming request
What's Next

Session 12 — Input Validation

  • The request DTOs you defined today accept any string for Title — Session 12 covers how to enforce rules on those inputs using Data Annotations and FluentValidation
  • You will learn how ASP.NET Core's model validation pipeline automatically rejects malformed requests before they reach your service layer, and how to return structured validation error responses
  • The CreateTaskRequest and UpdateTaskRequest DTOs you built today will be the targets for the validation rules added in Session 12
  • After Session 12 the complete data flow — validated request DTO in, repository operation, response DTO out — will be fully hardened
Before next session
Complete the assignment below. Make sure the lab is working — all five steps — before Session 12. The validation session builds directly on the DTO and repository structure you set up today.
Assignment

Refactor the Task Manager API to use the repository pattern and DTOs throughout.

What to build:

  • Create ITaskRepository and TaskRepository, register the repository as Scoped in Program.cs
  • Define TaskItemDto, CreateTaskRequest, and UpdateTaskRequest with appropriate fields
  • Write a MappingProfile that maps all three pairs; set CreatedAt via ForMember during the create mapping
  • Update TaskService to depend on ITaskRepository and IMapper — all controller actions must return DTOs, not entities

Acceptance criteria:

  • GET /api/tasks/{id} returns a TaskItemDto — no entity navigation properties, no sensitive fields in the response body
  • POST /api/tasks accepts a CreateTaskRequest body; sending extra fields like id or createdAt has no effect on the created record
  • TaskService has zero references to AppDbContext — all database access goes through ITaskRepository
  • MappingProfile is the only place where field assignments between entities and DTOs are defined
Bonus
Add a TaskSummaryDto that includes only Id, Title, and IsCompleted. Use it in a new GET /api/tasks list endpoint so that the collection response is lighter than the single-item response. Map it with an additional entry in MappingProfile.
Questions?
Session 11 — Repository Pattern & DTOs