Backend Development with .NET
Session 25  ·  Final Session
Capstone Project Review
Eng. Seif Mansour  ·  Andalusia Academy
Week 10  ·  2.5 hours
Session Goals

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

  • Demonstrate a working Task Management API that exercises every concept from the course
  • Verify your project against the requirements checklist before you present it
  • Deliver a tight 5–7 minute demo: live walkthrough, one code decision, one honest reflection
  • Give and receive structured feedback using the evaluation rubric
  • Name the specific area you will deepen next — and the first project that will take you there
Today is different
There is no new syntax in this session. Everything you need has already been taught — today you prove it works together.
Agenda
Time Segment Type Duration
0:00Presentation setup & intro10 min
0:10Student presentations (5–7 min each)Presentations90 min
1:40Instructor demo & comparisonDemo20 min
2:00Group retrospectiveDiscussion20 min
2:20What to learn nextDiscussion10 min
Setup rule
Your API must already be running, seeded, and your Postman collection open before your slot begins. Startup time comes out of your 7 minutes.
The Capstone Brief
One API, twenty-four sessions of material. Here is exactly what has to be in the box.
In this section
The nine required endpoints
The thirteen-item feature checklist
The full system architecture
A request end to end through the layers
What You Are Shipping

A Task Management API — a real, multi-user, authenticated REST service. Not a tutorial clone: a system a frontend team could genuinely build against.

The Product
Users register, log in, and manage their own tasks. Admins can manage anyone's.
POST /api/v1/tasks
The Contract
Versioned routes, predictable status codes, ProblemDetails errors, documented in Swagger.
GET /swagger/v1/swagger.json
The Proof
Unit tests that pass, a Docker image that runs, and a repository with no secrets in it.
docker run taskapi:capstone
The bar
A grader should be able to clone your repository, run one command, and hit /health successfully — with no help from you.
Required Endpoints — Auth & Reads
MethodRouteDescriptionAuth
POST/api/v1/auth/registerRegister a new userAnonymous
POST/api/v1/auth/loginLogin and receive a JWTAnonymous
GET/api/v1/tasksAll tasks — paginated, filterable, sortableBearer
GET/api/v1/tasks/{id}A single taskBearer
GET/api/v2/tasks/{id}v2 with an extended response shapeBearer
GET/healthHealth check — database includedAnonymous
Detail that gets missed
/health must return 503 when the database is unreachable. A health check that always returns 200 is decoration, not monitoring.
Required Endpoints — Writes
MethodRouteSuccessRejects with
POST/api/v1/tasks201 Created + Location400 invalid, 401 no token
PUT/api/v1/tasks/{id}200 OK or 204403 not owner, 404 missing
DELETE/api/v1/tasks/{id}204 No Content403 not owner, 404 missing
401 versus 403
401 Unauthorized means "I do not know who you are" — missing or invalid token. 403 Forbidden means "I know exactly who you are, and you may not do this" — a valid token belonging to someone who is neither the owner nor an Admin. Returning 404 to hide existence is a defensible choice, but say so out loud in your demo.
The Whole System
CLIENTS React SPA Postman Swagger UI Task Management API — ASP.NET Core MIDDLEWARE PIPELINE Exception handler → HTTPS → CORS → Rate limiter → Authentication → Authorization Controllers v1 and v2 routes · model binding · validation filter · ProblemDetails · Swagger XML docs Services Business rules · ownership and role checks · entity to DTO mapping · the unit-tested layer Repositories IQueryable composition · filtering, sorting, paging · EF Core DbContext Dependencies point inward: Controllers know Services, Services know Repository interfaces, nothing knows the controller. CROSS-CUTTING JWT issuing & validation Structured logging (Serilog) Health checks → /health Configuration & user-secrets Dockerfile & container image xUnit test project PostgreSQL / SQL Server Users · Roles · Tasks Schema owned by EF Core migrations
One Request, End to End
1 · HTTP REQUEST POST /api/v1/tasks Authorization: Bearer ... 2 · MIDDLEWARE HTTPS · CORS · rate limiter JWT validated → 401 or 429 here 3 · CONTROLLER Binds body to CreateTaskRequest Invalid → 400 ProblemDetails 4 · SERVICE Business rules, ownership check Not owner and not Admin → 403 5 · REPOSITORY Composes IQueryable Where · OrderBy · Skip · Take 6 · EF CORE Translates LINQ into SQL Change tracker → SaveChangesAsync 7 · DATABASE INSERT INTO "Tasks" ... Schema created by migrations 8 · RESPONSE ON THE WAY OUT Entity mapped to TaskDto → controller returns 201 Created with a Location header → serialized as JSON. Logging middleware records method, path, status, and duration. No entity, no PasswordHash, and no stack trace ever leaves the process.
Feature Checklist — Part 1 of 2
Layered architecture: Controllers → Services → Repositories
Sessions 8, 16
EF Core against PostgreSQL or SQL Server, with real migrations in the repo
Sessions 14, 15
JWT authentication: register, login, and protected endpoints
Sessions 18, 19
Role-based authorization with at least Admin and Member
Session 20
Pagination, filtering, and sorting on the task list endpoint
Session 12
API versioning — v1 and v2 for at least one endpoint
Session 11
Clean error responses using ProblemDetails, with no stack traces
Session 10
Feature Checklist — Part 2 of 2
Input validation with FluentValidation or DataAnnotations
Session 17
Rate limiting, stricter on the auth endpoints than on reads
Session 21
Swagger documentation, with the bearer token scheme wired in
Session 13
At least five unit tests covering the service layer
Session 22
A Dockerfile that builds and runs the API
Session 24
No secrets committed to Git — not now, and not anywhere in history
Sessions 2, 24
Non-negotiable
A committed connection string or JWT signing key is an automatic deduction, even if the credential is fake. Graders check git log, not just the current working tree.
Self-Check Before You Present
Every checklist item, with the exact code or command that proves it is really there.
In this section
Solution layout and Program.cs wiring
Auth, roles, and ownership
Paging, versioning, validation, errors
Tests, Docker, and secret hygiene
Check 1 — Solution Layout
  • Folders should name responsibilities, not file types
  • A stranger should locate the pagination logic in under thirty seconds
  • The test project references the API project — never the reverse
  • If Controllers/ contains LINQ against DbContext, the layering is decorative
TaskApi.sln
├── src/TaskApi/
│   ├── Controllers/      AuthController, TasksController
│   ├── Services/         ITaskService, TaskService
│   ├── Repositories/     ITaskRepository, TaskRepository
│   ├── Data/             AppDbContext, Migrations/
│   ├── Entities/         TaskItem, User, Role
│   ├── Dtos/             TaskDto, CreateTaskRequest
│   ├── Validators/       CreateTaskRequestValidator
│   ├── Program.cs
│   └── Dockerfile
└── tests/TaskApi.Tests/
    └── TaskServiceTests.cs
Check 2 — Registration (Part 1 of 2)
src/TaskApi/Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddScoped<ITaskRepository, TaskRepository>();
builder.Services.AddScoped<ITaskService, TaskService>();
builder.Services.AddScoped<ITokenService, TokenService>();

builder.Services.AddValidatorsFromAssemblyContaining<CreateTaskRequestValidator>();
builder.Services.AddFluentValidationAutoValidation();

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
}).AddApiExplorer(options => options.GroupNameFormat = "'v'VVV");

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
        options.TokenValidationParameters = JwtSetup.Build(builder.Configuration));

builder.Services.AddAuthorization(options =>
    options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin")));

builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(SwaggerSetup.Configure);
Every interface you injected must be registered here. A missing line surfaces as a runtime InvalidOperationException on the first request — not at build time.
Check 3 — Pipeline Order (Part 2 of 2)
src/TaskApi/Program.cs
var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
else
{
    app.UseHsts();
}

app.UseExceptionHandler();        // ProblemDetails, never a stack trace
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors("AllowFrontend");     // after routing, before auth
app.UseRateLimiter();
app.UseAuthentication();          // who are you?
app.UseAuthorization();           // what may you do?

app.MapControllers();
app.MapHealthChecks("/health");

app.Run();
If your demo shows a confusing CORS error where a clean 401 was expected, the cause is almost always UseCors placed after UseAuthorization.
Check 4 — Database & Migrations
  • Migration files are source code — they belong in the repository
  • Never call EnsureCreated() in a project that also uses migrations
  • A fresh clone plus database update must produce a working schema
  • Seed at least one Admin and one Member so a grader can test roles immediately
# Prove the migration history is intact
dotnet ef migrations list --project src/TaskApi

# Rebuild the schema from nothing
dropdb taskapi && createdb taskapi
dotnet ef database update --project src/TaskApi

# Inspect the SQL a migration will run
dotnet ef migrations script --idempotent \
  --project src/TaskApi --output migrate.sql
Frequent failure
"It works on my machine" almost always means the local database drifted from the migrations. Drop and re-apply once before the session, not during your demo.
Check 5 — Ownership and Roles
Controllers/TasksController.cs
[HttpPut("{id:int}")]
[Authorize]
public async Task<IActionResult> Update(
    int id, UpdateTaskRequest request, CancellationToken ct)
{
    var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
    var isAdmin = User.IsInRole("Admin");

    var result = await _service.UpdateAsync(id, request, userId, isAdmin, ct);

    return result.Status switch
    {
        UpdateStatus.NotFound  => NotFound(),
        UpdateStatus.Forbidden => Forbid(),
        UpdateStatus.Updated   => Ok(result.Task),
        _ => StatusCode(StatusCodes.Status500InternalServerError)
    };
}
The rule "owner or Admin" lives in the service, where it can be unit tested. The controller only translates the outcome into a status code.
Check 6 — Paging, Filtering, Sorting
Repositories/TaskRepository.cs
public async Task<PagedResult<TaskDto>> QueryAsync(TaskQuery query, CancellationToken ct)
{
    IQueryable<TaskItem> tasks = _db.Tasks.AsNoTracking();

    if (query.Status is not null)
        tasks = tasks.Where(t => t.Status == query.Status);

    if (!string.IsNullOrWhiteSpace(query.Search))
        tasks = tasks.Where(t => t.Title.Contains(query.Search));

    tasks = query.SortBy?.ToLowerInvariant() switch
    {
        "duedate" => query.Desc ? tasks.OrderByDescending(t => t.DueDate)
                                : tasks.OrderBy(t => t.DueDate),
        "title"   => query.Desc ? tasks.OrderByDescending(t => t.Title)
                                : tasks.OrderBy(t => t.Title),
        _         => tasks.OrderByDescending(t => t.CreatedAt)
    };

    var total = await tasks.CountAsync(ct);
    var items = await tasks.Skip((query.Page - 1) * query.PageSize)
                           .Take(query.PageSize)
                           .Select(t => t.ToDto())
                           .ToListAsync(ct);

    return new PagedResult<TaskDto>(items, query.Page, query.PageSize, total);
}
Note the ordering: filter, then sort, then count, then page. Calling ToListAsync() before Skip loads the whole table into memory.
Check 7 — The Response a Frontend Can Use
  • A bare array is not enough — the client cannot render a pager from it
  • Return page, pageSize, totalCount, totalPages
  • Cap pageSize server-side; ?pageSize=100000 must not be honoured
  • Invalid paging input is a 400, never a silent default
Demo tip
Show the same endpoint twice: once unfiltered, once with ?status=InProgress&sortBy=dueDate.
{
  "items": [
    {
      "id": 42,
      "title": "Write the final report",
      "status": "InProgress",
      "dueDate": "2026-08-14T00:00:00Z",
      "ownerId": 7
    }
  ],
  "page": 1,
  "pageSize": 20,
  "totalCount": 137,
  "totalPages": 7
}
Check 8 — v1 and v2 Side by Side
Controllers/TasksController.cs
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/tasks")]
public class TasksController : ControllerBase
{
    [HttpGet("{id:int}")]
    [MapToApiVersion("1.0")]
    public async Task<ActionResult<TaskDto>> GetV1(int id, CancellationToken ct)
        => await _service.GetAsync(id, ct) is { } dto ? Ok(dto) : NotFound();

    [HttpGet("{id:int}")]
    [MapToApiVersion("2.0")]
    public async Task<ActionResult<TaskDetailDto>> GetV2(int id, CancellationToken ct)
        => await _service.GetDetailAsync(id, ct) is { } dto ? Ok(dto) : NotFound();
}
v2 adds fields — owner summary, comment count, audit timestamps. It never renames or removes a v1 field, because v1 clients are still running.
Check 9 — Input Validation
public class CreateTaskRequestValidator
    : AbstractValidator<CreateTaskRequest>
{
    public CreateTaskRequestValidator()
    {
        RuleFor(x => x.Title)
            .NotEmpty().MaximumLength(200);
        RuleFor(x => x.Description)
            .MaximumLength(2000);
        RuleFor(x => x.DueDate)
            .GreaterThan(DateTime.UtcNow)
            .When(x => x.DueDate.HasValue);
        RuleFor(x => x.Status).IsInEnum();
    }
}
  • Validate the request DTO, never the entity
  • Every field with a database constraint needs a matching rule — otherwise the user sees a 500 instead of a 400
  • Messages must be actionable: "Title must be 200 characters or fewer", not "Invalid input"
  • Validation is not authorization — a well-formed request from the wrong user is still a 403
Check 10 — What an Error Looks Like
400 Bad Request — application/problem+json
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
  "errors": {
    "Title": [ "Title is required." ],
    "DueDate": [ "DueDate must be in the future." ]
  }
}
Instant deduction
Any response containing "stackTrace", a file path from your machine, or a raw Npgsql exception message. Confirm this by setting ASPNETCORE_ENVIRONMENT=Production and forcing an error before you present.
Check 11 — Rate Limiting & Swagger Auth
  • Auth endpoints need a much stricter limiter than reads — that is what stops credential stuffing
  • Partition by IP so one client cannot spend everyone's quota
  • Swagger must offer an Authorize button, or a grader cannot test your protected routes
  • Disable or protect Swagger outside Development
builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("auth", limiter =>
    {
        limiter.PermitLimit = 5;
        limiter.Window = TimeSpan.FromMinutes(1);
    });
    options.RejectionStatusCode =
        StatusCodes.Status429TooManyRequests;
});

[EnableRateLimiting("auth")]
[HttpPost("login")]
public async Task<IActionResult> Login(
    LoginRequest request) => ...
Check 12 — Five Tests That Actually Matter
tests/TaskApi.Tests/TaskServiceTests.cs
public class TaskServiceTests
{
    [Fact]
    public async Task UpdateAsync_ReturnsForbidden_WhenCallerIsNotOwnerOrAdmin()
    {
        var repository = new Mock<ITaskRepository>();
        repository.Setup(r => r.GetByIdAsync(42, It.IsAny<CancellationToken>()))
                  .ReturnsAsync(new TaskItem { Id = 42, OwnerId = 7 });

        var sut = new TaskService(repository.Object);

        var result = await sut.UpdateAsync(
            42, new UpdateTaskRequest("New title"),
            userId: 9, isAdmin: false, CancellationToken.None);

        Assert.Equal(UpdateStatus.Forbidden, result.Status);
        repository.Verify(r => r.SaveAsync(It.IsAny<CancellationToken>()), Times.Never);
    }
}
Test the rules, not the framework: ownership, admin override, missing entity, page-size capping, and sort fallback. Five tests like this beat fifty that assert a getter returns what the setter stored.
Check 13 — A Dockerfile That Builds
src/TaskApi/Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY TaskApi.sln .
COPY src/TaskApi/TaskApi.csproj src/TaskApi/
RUN dotnet restore src/TaskApi/TaskApi.csproj
COPY . .
RUN dotnet publish src/TaskApi/TaskApi.csproj -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "TaskApi.dll"]
Two stages: the SDK image compiles, the smaller runtime image ships. Copying the .csproj before the source keeps the restore layer cached across code changes.
Check 14 — No Secrets in Git
# .gitignore must cover at least these
appsettings.*.json
.env
*.pfx

# Development secrets live outside the repository
dotnet user-secrets init --project src/TaskApi
dotnet user-secrets set "Jwt:Key" "development-only-key-at-least-32-chars"

# Search the whole history, not just the working tree
git log --all -p -S "Jwt:Key" -- appsettings.json
git log --all --name-only --diff-filter=A | grep -i "\.env$"
If you find one
Deleting the file in a new commit is not enough — the value is still in history. Rotate the credential, then rewrite history or start a clean repository before submitting.
The Five-Minute Pre-Flight
# 1. Clean build with warnings treated as errors
dotnet build -warnaserror

# 2. Tests green
dotnet test --logger "console;verbosity=normal"

# 3. Schema reproducible from scratch
dotnet ef database update --project src/TaskApi

# 4. Container builds and boots
docker build -t taskapi:capstone -f src/TaskApi/Dockerfile .
docker run --rm -p 8080:8080 --env-file .env.local taskapi:capstone

# 5. Health endpoint answers
curl -f http://localhost:8080/health && echo "READY"
Run all five on a machine that has never seen your project — a teammate's laptop is the best test. Anything that needs a manual fix is a bug in your setup instructions.
Presenting Your Work
Seven minutes is short. A rehearsed sequence is the difference between showing a system and apologising for one.
In this section
The 3 / 2 / 1 minute structure
The exact demo request sequence
Proving 401, 403, and 429
Choosing the code you walk through
Reflection that earns respect
The 5–7 Minute Structure
0:00–3:00
Demo. Walk the Postman collection top to bottom: register, login, create a task, list with filters, then deliberately trigger a 401 and a 403. Narrate what you expect before each request runs.
3:00–5:00
Code walk. One implementation decision. Show the file, explain the alternative you rejected, and say why. One file, one idea.
5:00–6:00
Reflection. What was hardest? What would you do differently with another week? Specific beats humble.
6:00–7:00
Questions. From peers and instructor. Answering "I do not know, but here is how I would find out" is a strong answer.
The Demo Script — Happy Path
# 1. Register a fresh user
curl -X POST https://localhost:7001/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"amina@example.com","password":"Str0ng!Pass"}'

# 2. Log in and capture the token
TOKEN=$(curl -s -X POST https://localhost:7001/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"amina@example.com","password":"Str0ng!Pass"}' \
  | jq -r .accessToken)

# 3. Create a task — expect 201 and a Location header
curl -i -X POST https://localhost:7001/api/v1/tasks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Prepare the demo","dueDate":"2026-08-10T00:00:00Z"}'

# 4. List with filter, sort and paging applied
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://localhost:7001/api/v1/tasks?status=InProgress&sortBy=dueDate&page=1&pageSize=5"
Use Postman in the room — it is easier to read on a projector — but keep this curl script as a backup for when the GUI misbehaves.
The Demo Script — Proving the Guards
# 401 — no token at all
curl -i -X POST https://localhost:7001/api/v1/tasks -d '{}'
# HTTP/1.1 401 Unauthorized

# 403 — a valid token, but the caller is neither owner nor Admin
curl -i -X DELETE https://localhost:7001/api/v1/tasks/42 \
  -H "Authorization: Bearer $OTHER_USER_TOKEN"
# HTTP/1.1 403 Forbidden

# 429 — rate limiter on the login endpoint
for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code} " -X POST \
    https://localhost:7001/api/v1/auth/login -d '{}'
done
# 400 400 400 400 400 429 429 429 429 429
This is the part that scores
Anyone can show a successful POST. Showing that the wrong caller is stopped, with the correct status code, is what demonstrates you understood authentication, authorization, and security as three separate things.
Choosing Your Code Walk

Pick one decision that required judgement. Strong candidates:

Auth wiring
How the token is built, which claims it carries, and how the ownership check reads them back.
TokenService.cs
Query composition
How filter, sort, and page combine into a single SQL query instead of three round trips.
TaskRepository.cs
Error translation
How a service outcome becomes 404, 403, or 400 without exceptions used as control flow.
UpdateStatus.cs
Say the sentence
"I chose X over Y because Z." That single sentence is worth more than scrolling through four files in silence.
Presenting — Do and Do Not
Do
Seed data before you start so lists are not empty.
Do not
Run a migration live. It will be the one that fails.
Do
Increase your editor and terminal font size beforehand.
Do not
Read code aloud line by line. Explain the intent instead.
Do
Name what is unfinished before anyone asks about it.
Do not
Debug live. Note the failure, move on, return if time allows.
Your One Minute of Reflection
What was hardest?
Name the concrete blocker — the migration that would not apply, the 401 that turned out to be a clock-skew issue.
What would you redo?
Usually a structural choice: repository shape, DTO mapping, or where validation lives.
What surprised you?
Often the amount of code that is not features: configuration, error shaping, wiring.
What is still missing?
Stating a known gap yourself is credibility. Being caught out on it is not.
How It Is Scored
One hundred points across six areas. Nothing here is a surprise — it maps directly onto the checklist.
In this section
The six scoring areas
What earns full marks in each
The deductions we see every cohort
How to give useful peer feedback
The Evaluation Rubric
All required endpoints work
25 pts
Architecture & code quality
20 pts
Auth & authorization correct
20 pts
Error handling & validation
15 pts
Tests pass
10 pts
Docker runs
10 pts
Read the weighting
Working endpoints plus correct auth is 45 of 100. A beautiful architecture with a broken login scores worse than a plain one that works end to end.
What Full Marks Look Like
AreaFull marks means
EndpointsAll nine respond with correct status codes, including v2 and /health
ArchitectureNo DbContext in a controller; interfaces registered in DI; DTOs at the boundary
AuthToken validated correctly; role checks enforced; ownership enforced in the service
ErrorsProblemDetails everywhere; validation rejects bad input with actionable messages
TestsFive or more service tests, all green, covering rules rather than getters
Dockerdocker build then docker run serves /health with no manual edits
Deductions We See Every Cohort
01
Entities returned directly. One endpoint forgot the DTO and leaks PasswordHash. Grep your responses.
02
403 returned as 401 — or worse, as 200 with an empty body. Authorization failures need their own status.
03
Pagination without a total count. The client cannot build a pager from an array alone.
04
v2 that is identical to v1. Versioning is only demonstrated when the shapes actually differ.
05
Tests that never run. Committed but excluded from the solution, or failing on a clean clone.
06
Dockerfile that assumes localhost. A hard-coded connection string to localhost cannot reach a database from inside a container.
Giving Peer Feedback
  • Every student writes feedback for the two presentations before their own
  • Comment on the artefact, never the person
  • Anchor each point to a rubric area so it is actionable
  • One thing that worked, one thing to change, one question
Useful
"Your 403 path is clean. Under Error handling, the 404 for a missing task returned HTML rather than ProblemDetails — is the exception handler registered before routing?"
Not useful
"Looks good." No rubric area, no observation, nothing the author can act on.
Key Concept
"A backend is judged on what it refuses. Anyone can return 200 — the craft is in the 400, the 403, and the 429."
— Session 25
Group Retrospective
Twenty-five sessions and fifty-one hours. Before we look forward, look at how far the ground has moved.
In this section
The full course roadmap
What each phase actually gave you
Retro prompts for the group
Where You Started, Where You Are
1 Foundation & Setup SESSIONS 1–2 What a backend is Client–server model .NET SDK and IDE Git workflow 2 C# Essentials SESSIONS 3–7 Types, control flow Methods, exceptions OOP and interfaces Generics, collections 3 Web API & HTTP Design SESSIONS 8–13 Controllers, routing Status codes, errors Versioning, paging Swagger documentation 4 Data & Persistence SESSIONS 14–17 EF Core, DbContext Migrations, LINQ Repository, DTOs Input validation 5 Auth, Security & Testing SESSIONS 18–23 JWT, roles, policies Rate limiting, CORS xUnit, integration Logging, health checks 6 Polish & Capstone SESSIONS 24–25 Docker, deployment Environment config Capstone review You are here
What Each Phase Gave You
PhaseThe capability you now have
1 — FoundationYou can explain what runs on a server and why the backend is the only enforcer of rules
2 — C#You can read and write idiomatic C#: types, OOP, generics, collections, delegates
3 — Web APIYou can design an HTTP contract others can build against without asking you questions
4 — DataYou can model a schema, evolve it with migrations, and query it without loading the table
5 — SecurityYou can prove who a caller is, decide what they may do, and stop abuse before it lands
6 — DeliveryYou can package the whole thing so it runs somewhere other than your own laptop
Retrospective — As a Group
Which concept finally clicked?
And what made it click — the explanation, the lab, or debugging it at 1am?
Which one is still fuzzy?
Dependency injection lifetimes, EF change tracking, and async are the usual answers. Naming it is how you fix it.
What cost the most time?
Environment setup, migrations, or token configuration. This tells us what to teach differently.
What will you keep using?
Layering, DTOs, and ProblemDetails transfer to every stack — not just .NET.
Ground rule
"This was confusing" is useful feedback. Say it plainly — the curriculum improves from it.
What to Learn Next
Seven directions from here. Pick one, build something real with it, then pick the next.
In this section
Microservices and message queues
SignalR and gRPC
CQRS with MediatR
Redis caching
Cloud deployment and CI/CD
Seven Directions From Here
DirectionWhat it solvesStart with
MicroservicesSplitting one API into services that scale and deploy separatelyRabbitMQ, MassTransit
SignalRPushing updates to clients instead of making them pollWebSockets, hubs
gRPCFast binary service-to-service calls with a generated contractProtobuf, .proto files
CQRS + MediatRSeparating read and write models as the domain growsMediatR, pipeline behaviours
Azure / AWSRunning on managed infrastructure instead of your machineApp Service, RDS
CI/CDBuilding, testing, and deploying automatically on every pushGitHub Actions
CachingCutting database load and latency on hot readsRedis, IDistributedCache
Next — Real-Time with SignalR
  • Your task board currently needs a refresh to see someone else's change
  • SignalR keeps a WebSocket open and pushes the change instead
  • Groups let you scope a broadcast to one project or one board
  • The same JWT secures the hub — [Authorize] works there too
public class TaskHub : Hub
{
    public Task JoinProject(string projectId) =>
        Groups.AddToGroupAsync(Context.ConnectionId, projectId);
}

// Broadcast when a task changes
await _hub.Clients.Group(task.ProjectId)
          .SendAsync("TaskUpdated", task.ToDto(), ct);

// Program.cs
builder.Services.AddSignalR();
app.MapHub<TaskHub>("/hubs/tasks");
Next — CQRS with MediatR
Features/Tasks/GetTaskById.cs
public record GetTaskByIdQuery(int Id) : IRequest<TaskDto?>;

public class GetTaskByIdHandler : IRequestHandler<GetTaskByIdQuery, TaskDto?>
{
    private readonly AppDbContext _db;
    public GetTaskByIdHandler(AppDbContext db) => _db = db;

    public Task<TaskDto?> Handle(GetTaskByIdQuery request, CancellationToken ct) =>
        _db.Tasks.AsNoTracking()
                 .Where(t => t.Id == request.Id)
                 .Select(t => t.ToDto())
                 .FirstOrDefaultAsync(ct);
}

// The controller collapses to one line per endpoint
[HttpGet("{id:int}")]
public async Task<IActionResult> Get(int id, CancellationToken ct) =>
    await _mediator.Send(new GetTaskByIdQuery(id), ct) is { } dto ? Ok(dto) : NotFound();
Each feature becomes one file holding its request, its handler, and its validator. Worth it once a service class grows past roughly ten methods — overkill before that.
Next — Caching with Redis
Services/TaskService.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "taskapi:";
});

public async Task<TaskDto?> GetAsync(int id, CancellationToken ct)
{
    var key = $"task:{id}";
    var cached = await _cache.GetStringAsync(key, ct);
    if (cached is not null)
        return JsonSerializer.Deserialize<TaskDto>(cached);

    var dto = await _repository.GetDtoAsync(id, ct);
    if (dto is not null)
        await _cache.SetStringAsync(key, JsonSerializer.Serialize(dto),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
            }, ct);

    return dto;
}
The hard part is never the read — it is invalidation. Evict task:{id} inside your update and delete paths, or clients will read stale data for five minutes.
Next — CI/CD with GitHub Actions
  • A workflow file runs these commands on a clean machine for every push
  • That clean machine is the honest answer to "works on my laptop"
  • Secrets come from the repository's secret store, never the repository itself
  • Block merges when the build or tests fail — that is the whole payoff
# What the pipeline runs on every push
dotnet restore
dotnet build --no-restore -c Release
dotnet test --no-build -c Release --logger trx

docker build -t ghcr.io/your-name/taskapi:$GIT_SHA .
docker push ghcr.io/your-name/taskapi:$GIT_SHA
A Realistic Next Ninety Days
01
Weeks 1–2. Finish and deploy the capstone to a public URL. A running link beats a private repository in every conversation.
02
Weeks 3–4. Add CI with GitHub Actions, plus integration tests using WebApplicationFactory.
03
Weeks 5–7. Build a second, different API — booking, inventory, ticketing. New domain, same skills, no tutorial.
04
Weeks 8–10. Add one advanced capability: Redis caching, SignalR, or a background worker.
05
Weeks 11–13. Connect a real React frontend. Consuming your own API teaches you what your API got wrong.
Summary
1
The capstone is thirteen checklist items and nine endpoints — verify each one against real output, not memory
2
A grader must clone, build, and reach /health without your help. Rehearse that on another machine
3
Demo the refusals: 400, 401, 403, 429. They prove more than any successful POST
4
Working endpoints and correct auth are 45 of 100 points — make those bulletproof before polishing anything else
5
Layering, DTOs, and structured errors are transferable habits, not .NET trivia — they follow you to any stack
6
Pick one direction next, ship something real with it, then pick the next. Breadth without a built artefact fades
What's Next — After This Course

There is no Session 26. This is where the syllabus ends and your own roadmap starts.

  • Ship the capstone publicly. A live URL and a clean README are the portfolio piece — not the code alone
  • Go deeper before going wider. One advanced topic taken to production beats five skimmed tutorials
  • Read other people's APIs. Stripe, GitHub, and Twilio documentation will teach you API design faster than any course
  • Contribute or collaborate. Reviewing someone else's pull request builds judgement that solo work cannot
  • Keep the repository alive. An API you still maintain in six months says more than one you finished in a weekend
Staying in touch
The course materials remain yours. Sessions 1–25 stay online as a reference — come back to them when you meet a problem you have seen before.
Final Assignment

Submit the finished capstone: a repository link plus a README.md that lets a stranger run your API in under five minutes.

  • All thirteen checklist items complete, each mapped in the README to the file or command that proves it
  • A clean clone passes dotnet build -warnaserror, dotnet test, and docker build with no manual edits
  • A Postman collection committed to the repository covering the happy path plus a deliberate 401, 403, and 429
  • git log --all -S finds no connection string, JWT key, or password anywhere in history
  • A RETROSPECTIVE.md of roughly 300 words: hardest problem, what you would redo, and the one topic you will learn next
Bonus
Deploy the API to a public URL (Azure App Service, Render, Fly.io, or a VPS) and add a GitHub Actions workflow that builds, tests, and redeploys on every push to main. Put the live /health link and the workflow status badge at the top of your README.
Questions?
Session 25 — Capstone Project Review  ·  Backend Development with .NET