- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Clean Boundaries in a Secured ASP.NET Core Application
You are the sole backend engineer on a small digital-library team. The team has agreed on Clean Architecture and scaffolded every layer, but none of them work yet. Over the course of this lab you will grow the repository layer by layer: a domain core that knows nothing about databases, an application layer full of MediatR handlers and FluentValidation validators, an EF Core infrastructure layer, a thin ASP.NET Core Web API, and finally a paged Blazor WebAssembly front-end that sends signed-out users to sign in. Each layer must compile and behave correctly before the next one is added, enforcing the inward-pointing dependency rule at every turn.
Lab Info
Table of Contents
-
Challenge
Step 1: Explore the solution structure and architecture goals
Welcome to the Book Catalog lab
You are the sole backend engineer on a small digital-library team. The team has agreed on Clean Architecture and scaffolded every layer, but none of them work yet. Over the next five steps you will complete them layer by layer:
- A Domain layer that knows nothing about databases
- An Application layer full of MediatR handlers and FluentValidation validators
- An Infrastructure layer that owns EF Core
- A thin ASP.NET Core Web API
- A Blazor WebAssembly front-end that redirects signed-out users to sign in
Each layer must behave correctly before you build on it, and dependencies must keep pointing inward at every turn. ### Solution layout
Every layer is a folder in a single project,
src/Lab.csproj, with its own namespace (BookCatalog.Domain,BookCatalog.Application, and so on):workspace/ ├── src/ │ ├── Domain/ ← knows nothing outside itself │ ├── Application/ ← depends on Domain only │ ├── Infrastructure/ ← depends on Domain; the only place EF Core appears │ ├── Api/ ← depends on Application + Infrastructure │ ├── BlazorClient/ ← calls the Api through a typed BooksClient │ ├── Program.cs ← a console tour of your work, run in Step 6 │ └── Lab.csproj ← the one project that compiles every layer └── tests/ ← the checks that grade each taskDependencies always point inward. Because every layer is in one project, the compiler won't stop an outward reference, so keeping the rule is up to you. Confirm that EF Core stays out of the inner layers:
grep -rl "EntityFrameworkCore" src/Domain src/ApplicationIt prints nothing. Run it again when you finish Step 4, and it should still print nothing. ### Key types already in place
The starter scaffold provides:
Result<T>: a typed result wrapper with.IsSuccess,.Value, and.ErrorpropertiesPagedResult<T>: carriesItems,TotalCount,Page, andPageSizeBookDtorecord: the projection surface used by the query handlerBookCatalogDbContext: EF Core context with aBooksDbSet<Book>BooksClient: the typed HTTP client the Blazor book list calls- Skeleton files for every class you will complete, each marked with a
// TODO: Task N.Ncomment
Run the checks now to see your starting line:
dotnet test tests/Lab.Tests.csproj --nologo --verbosity quietAll 12 task checks fail: the output lists each failing test, such as
Lab.Tests.Step2Tests.Task_2_1, with the reason it failed. Each check starts passing as you complete its task. -
Challenge
Step 2: Build the persistence-ignorant domain layer
The domain layer: no persistence allowed
Clean Architecture's central rule is that the domain sits at the center of the dependency graph. It must not reference EF Core, ASP.NET Core, or any other infrastructure concern. Every other layer depends on it; it depends on nothing outside itself.
A value object is an immutable type whose identity is its value rather than a database key.
Isbnis a textbook example: twoIsbninstances that hold"9780132350884"are interchangeable. Because the value object owns its own validation logic, you can never construct anIsbnwith a malformed value—the factory method makes an invalid state unrepresentable. ### Aggregate roots and factory methodsAn aggregate root is the entry point to a cluster of related objects. External code may only reach the cluster through the root—never by reaching inside and mutating child objects directly.
Bookis the aggregate root for the book catalog.Factory methods on aggregate roots serve two purposes: they enforce invariants (a book must have a valid ISBN and a non-empty title) and they keep construction logic in one place. Using a static
Createmethod that returnsResult<Book>means callers handle errors through return values rather than exceptions. ### Repository interfaces belong in the domainThe domain defines what it needs from persistence without saying how persistence works. The
IBookRepositoryinterface lives inDomain/Interfaces/, making the dependency on a storage abstraction explicit. EF Core, in-memory fakes, and SQL all satisfy this interface equally—the domain never knows which one is running. -
Challenge
Step 3: Route CQRS commands and queries through MediatR
The application layer: commands, queries, and pipelines
The application layer orchestrates the domain. It contains no business rules of its own; it delegates those to domain entities and value objects. Its job is to accept an intent (a command or a query), validate it, run the correct domain behavior, and return a result.
CQRS (Command Query Responsibility Segregation) splits reads and writes into separate paths. MediatR is the in-process bus: you
Senda request object and a matchingIRequestHandlerexecutes it. This decouples controllers from handlers completely—the controller doesn't need to know which handler exists. ### Projecting domain entities to response modelsA domain entity should never escape the application boundary directly. Exposing
Bookfrom an API endpoint would couple external clients to your internal model—any refactor breaks the contract. Instead, the query handler projects eachBookto aBookDtorecord before returning it. ThePagedResult<BookDto>wrapper adds the paging metadata the Blazor client needs to render navigation controls. ### Pipeline behaviors: cross-cutting concerns without cluttering handlersMediatR's
IPipelineBehavior<TRequest, TResponse>is the application-layer equivalent of ASP.NET Core middleware. You register one or more behaviors, and every request passes through them in order before reaching the handler.ValidationBehavioruses this hook to run all registeredIValidator<TRequest>instances and short-circuit the pipeline when any rule fails. The handler never sees an invalid command—it can assume every invariant held by the validator is already satisfied. -
Challenge
Step 4: Connect EF Core infrastructure and expose API controllers
The infrastructure layer: where EF Core lives
EF Core is an infrastructure concern. Putting
DbContextcalls inside domain entities or application handlers would make the domain depend on persistence—exactly what Clean Architecture forbids. The infrastructure layer is whereMicrosoft.EntityFrameworkCoregets configured. It implementsIBookRepositoryusingBookCatalogDbContextand wires everything up through a DI extension method, so the API host calls a singleAddInfrastructure()and does not configure EF Core itself. ### Service registration as a first-class concernIn ASP.NET Core the DI container is the wiring diagram of your application. Keeping all infrastructure registrations inside an
AddInfrastructureextension method meansProgram.csstays readable and the infrastructure layer controls its own configuration surface. The host only knows that infrastructure exists; it does not know how EF Core is configured. ### Thin controllers: HTTP in, HTTP outA controller's role in Clean Architecture is translation: convert an HTTP request to a MediatR request, send it, and convert the result to an HTTP response. No validation logic, no database calls, no business rules. When a controller has a single
await _mediator.Send(...)per action and a smallif/elseto pick the status code, it is doing exactly the right amount of work.The
[ProducesResponseType]attributes are not decorative—tools such as NSwag read them from the OpenAPI document to generate accurate client method signatures. -
Challenge
Step 5: Harden, test, and build a paged Blazor client
Hardening: middleware, logging, and tests
A production service needs three safety nets that live outside the core request path:
- Global exception middleware—catches anything that slips past the handler pipeline and returns a well-formed RFC 9457
ProblemDetailsresponse instead of a raw 500 stack trace. - Structured logging—records the HTTP verb, path, and exception as discrete fields, not a concatenated string, so a log-aggregation tool can filter and group them.
- Unit tests—verify that the handler correctly propagates domain errors and that the middleware correctly formats unhandled exceptions, without needing a running server or a real database.
RFC 9457 (
application/problem+json, which replaces RFC 7807) is the IETF standard for HTTP API error responses. Returning it from your middleware means every consumer—the Blazor client, a third-party integration, a developer hittingcurl—gets a consistent, machine-readable error envelope. ### Testing handlers in isolationThe command handler has no HTTP, no EF Core, and no framework lifecycle—it is a plain C# class that takes an interface. That makes it ideal for unit testing. A hand-rolled in-memory fake is preferable to a mocking framework here because it proves that the aggregate is actually handed to
AddAsync, not just that the method was called with some argument. Fast, dependency-free tests like these run in every CI build without a database connection. ### Blazor WebAssembly: typed clients and authentication guardsIn production, NSwag reads the OpenAPI document your API emits and generates a strongly-typed
BooksClientclass, so the Blazor app never hard-codes API routes. This lab ships a hand-writtenBooksClientinsrc/BlazorClient/Services/BooksClient.csso the project builds without a running API.The Blazor WebAssembly app runs in the browser, so anyone can load its pages. Treat the guard in
OnInitializedAsyncas a user-experience check: it reads theAuthenticationStateand redirects to/loginbefore any API call is made. In production, the API must also reject unauthenticated requests on the server, which this lab does not cover. - Global exception middleware—catches anything that slips past the handler pipeline and returns a well-formed RFC 9457
-
Challenge
Step 6: Watch a book cross every boundary
Every task is done and graded. Now run the console tour in
src/Program.cs, which sends real commands and queries through the layers you built. You'll watch three things: books traveling from a command to a paged result, two layers refusing bad input, and a crash turning into a clean error response. ### Follow two books from command to pageIn the Terminal tab, run:
dotnet run --project src/Lab.csprojFind the
Task 3.1 / 3.3section. The first two commands each printBook created with Id=...with a freshGuid. Each one went through MediatR, yourValidationBehavior, your handler,Book.Create, and the EF CoreBookRepository. The handler only ever saw theIBookRepositoryinterface you declared in the domain.The
Task 3.2 / 4.1section reads them back:Page=1, PageSize=10, TotalCount=2, Items returned=2. Domain-Driven Design is listed before Patterns of Enterprise Application Architecture because your repository orders by title. Each ISBN prints as a plain string such as9780321125217, because your query handler projectedIsbn.Valueinto aBookDtoinstead of returning the entity. ### Watch two layers refuse bad inputIn the same
Task 3.1 / 3.3section, two more commands are refused:Correctly short-circuited: Title is required.Your validator caught the blank title, and yourValidationBehaviorfrom Task 3.3 returned the failure without callingnext, so the handler never ran.Rejected by the domain: Invalid ISBN format.The validator only checks that the ISBN isn't empty, sonot-an-isbngot past it. Your handler calledBook.Create, and theIsbnvalue object from Task 2.1 refused the format.
Four create commands went in, and
TotalCountis still2. Neither bad book reached the database.Now see the behavior collect every failure, not just the first. In
src/Program.cs, find this line:var result = await mediator.Send(new CreateBookCommand("", "9780321125217"));Change it to:
var result = await mediator.Send(new CreateBookCommand("", ""));Rerun the tour. The line now reads
Correctly short-circuited: Title is required.; ISBN is required.Your behavior ran every rule and joined the failures, so a client can fix both fields in one go. Put the ISBN back when you're done. ### See a crash become a problem-details responseThe
Task 5.1section sends aPOST /api/booksrequest through yourExceptionMiddleware, and the next step in the pipeline throwsInvalidOperationException: boom. Two things come out:- A
fail:log entry that readsUnhandled exception for POST /api/books, followed by the exception and its stack trace.MethodandPathare logged as structured properties, so a log tool can filter on them instead of parsing text. The stack trace stays in the server's log; compare it with the response body below, which carries only the message. StatusCode=500, ContentType=application/problem+jsonand the body{"title":"An unexpected error occurred.","status":500,"detail":"boom"}. Every client gets the same RFC 9457 shape instead of a stack trace.
The Blazor book list and handler tests do not run during the console demonstration. Their functionality is covered by separate automated tests.
To verify that all implementations still pass their automated checks, run:
dotnet test tests/Lab.Tests.csproj --nologo --logger "console;verbosity=normal"You started with an empty catalog. You now have a domain that refuses bad data, a pipeline that stops blank input before it reaches a handler, and an API that fails safely.
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.