By the end of this session, you will be able to:
| Time | Segment | Type | Duration |
|---|---|---|---|
| 0:00 | Why repository pattern? | Theory | 15 min |
| 0:15 | Implementing ITaskRepository | Demo | 25 min |
| 0:40 | DTOs — request vs response models | Theory | 15 min |
| 0:55 | AutoMapper setup | Demo | 20 min |
| 1:15 | Lab — refactor to repository + DTOs | Lab | 35 min |
| 1:50 | Wrap-up | Discussion | 10 min |
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._context.Tasks.Where(...).ToListAsync(), the EF Core query language bleeds into a layer that should only care about business rulesDbContext in a unit test requires a database. With an interface you swap in a mock that returns fake data instantlyDbContext must be rewrittenWhere(t => t.UserId == userId) filter gets duplicated across multiple services and controllerspublic 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);
}
TaskItem, PagedResult), not in terms of EF Core (DbSet, IQueryable). Write the implementation only after the interface is stable.
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();
}
}
Register the concrete class against the interface in Program.cs. The DI container resolves ITaskRepository to TaskRepository at runtime:
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:
public class TaskService
{
private readonly ITaskRepository _repo;
public TaskService(ITaskRepository repo)
=> _repo = repo;
public async Task<TaskItem?> GetByIdAsync(int id)
=> await _repo.GetByIdAsync(id);
}
AppDbContext. It depends only on the interface — which can be mocked in tests without touching a database.
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
}
[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.
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.
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.
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:
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.
}
[Bind] attributes, no opt-outs needed.
Update requests typically allow a subset of fields to be changed. Fields the client should never update — like CreatedAt or UserId — are simply absent:
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:
[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();
}
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;
dotnet add package AutoMapper
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.
One line in Program.cs scans the assembly for all classes that inherit Profile and registers them:
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:
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);
}
}
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.
Refactor the Task API to use the repository pattern and DTOs throughout. Each step builds on the last — complete them in order.
ITaskRepository in a Repositories/ folder and implement TaskRepository using AppDbContext
TaskItemDto, CreateTaskRequest, and UpdateTaskRequest in a DTOs/ folder
MappingProfile class with mappings for all three pairs, registering AutoMapper in Program.cs
TaskService to inject ITaskRepository and IMapper, removing all direct AppDbContext references
TaskItemDto shapes — check that PasswordHash, RefreshToken, or any other sensitive field is absent from all responses
ITaskRepository, not on DbContext, making the data layer swappable and testableDbContext they wrap — one instance per HTTP requestId, CreatedAt, and role flags are simply not present in the request modelProfile class and call _mapper.Map<T>(source) everywhereForMember whenever a server-controlled value (such as DateTime.UtcNow) must be set during mapping rather than read from the incoming requestSession 12 — Input Validation
Title — Session 12 covers how to enforce rules on those inputs using Data Annotations and FluentValidationCreateTaskRequest and UpdateTaskRequest DTOs you built today will be the targets for the validation rules added in Session 12Refactor the Task Manager API to use the repository pattern and DTOs throughout.
What to build:
ITaskRepository and TaskRepository, register the repository as Scoped in Program.csTaskItemDto, CreateTaskRequest, and UpdateTaskRequest with appropriate fieldsMappingProfile that maps all three pairs; set CreatedAt via ForMember during the create mappingTaskService to depend on ITaskRepository and IMapper — all controller actions must return DTOs, not entitiesAcceptance criteria:
GET /api/tasks/{id} returns a TaskItemDto — no entity navigation properties, no sensitive fields in the response bodyPOST /api/tasks accepts a CreateTaskRequest body; sending extra fields like id or createdAt has no effect on the created recordTaskService has zero references to AppDbContext — all database access goes through ITaskRepositoryMappingProfile is the only place where field assignments between entities and DTOs are definedTaskSummaryDto 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.