- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Clean Architecture Faces Scale Tests and Change
A ticketing service runs ticket sales and refunds for a growing slate of live events out of a single .NET service. Leadership, spooked by a bad launch elsewhere, wants Catalog, Pricing, and Fulfillment split into microservices before the next on-sale. The engineering team suspects that's premature: the real problems are a payment gateway called directly from business logic, a refund handler nobody can test without hitting a live API, and UI tests so brittle nobody trusts them. Over this lab you rebuild the refund path context by context -- drawing the boundaries first, hiding the payment call behind an interface, isolating the refund command's dependencies, proving the workflow in memory, and keeping the technology choice for notifications open -- so the team can make the microservices call on evidence instead of fear.
Lab Info
Table of Contents
-
Challenge
Step 1: Orientation
Step 1: Orientation
A ticketing service sells tickets and processes refunds for a growing slate of live events out of one .NET service. The refund workflow -- the path from "a customer wants their money back" to "the payment gateway confirms it and the customer is told" -- currently runs through business logic that calls the payment gateway's HTTP client directly, calls a notification service directly, and mixes catalog, pricing, and fulfillment concerns in the same methods. Nobody can test a refund without hitting a live payment API, and the UI tests that exist are so brittle the team routinely ignores their failures.
Leadership, rattled by a bad launch at a competitor, wants Catalog, Pricing, and Fulfillment split into separate microservices before the next on-sale. The engineering team's suspicion is that this is solving the wrong problem: splitting a tangled monolith into tangled microservices just moves the tangle across a network boundary, and now every test that used to be slow is also unreliable. ## What "Clean Architecture" actually buys the team here
The fix is not a deployment topology. It's making the workflow's internal boundaries explicit and enforceable, so that whichever technology sits behind the boundary -- HTTP gateway, in-memory fake, SMS, email -- the core refund logic doesn't change and doesn't need a live dependency to test. Four ideas do the work:
- Bounded contexts: which parts of the system are allowed to call which other parts directly, written down as a rule the compiler and tests can enforce, not a diagram everyone forgets.
- Ports and adapters: the refund logic depends on an interface (
IPaymentGateway,INotificationSender), never onHttpClientor a specific vendor SDK. - Dependency inversion: the application layer defines the interface it needs; the infrastructure layer implements it. Infrastructure depends on the application, not the other way round.
- Test doubles and red-green-refactor: with the gateway and notifier behind interfaces, the refund command's logic can be driven by a failing test, made to pass, and refactored -- all without a live payment API. ## What you'll build
Over the next four steps you rebuild the refund path piece by piece:
- Step 2 encodes the Catalog/Pricing/Fulfillment/Payments/Notifications boundaries as a rule the code enforces, plus a workload-based check for whether a context's traffic actually justifies pulling it into its own service.
- Step 3 builds the HTTP-backed payment adapter, an in-memory fake behind the same interface, and an application service that only ever sees the interface.
- Step 4 builds the refund command handler itself, driving its validation and approval logic through red-green-refactor and verifying notifications with a hand-built double.
- Step 5 proves the whole workflow with an in-memory acceptance scenario, then swaps the notification technology underneath it to show the core doesn't care.
By the end, the team has working code that demonstrates where the boundaries actually are -- evidence leadership can act on, instead of a diagram they can argue about.
info> If you get stuck, you can refer to the provided solution code for each task, available in the
solutionfolder. -
Challenge
Step 2: Draw bounded contexts before committing to microservices
Step 2: Draw bounded contexts before committing to microservices
Before any code talks to a payment gateway, the team needs to agree -- in a form the compiler enforces -- which parts of the refund workflow are allowed to call which other parts directly.
Fulfillmenttriggering a payment refund is expected.Paymentsreaching back intoPricingto recalculate a quote is not: that's the kind of undeclared coupling that turns a clean split into another tangled monolith, just with a network hop in the middle.src/BoundedContextMap.csalready declares theContextenum for Catalog, Pricing, Fulfillment, Payments, and Notifications, and the shape of two methods:IsCallAllowed, the enforceable boundary rule, andShouldExtractService, a workload-based check for whether a context is actually busy enough to justify its own deployment pipeline. With the call boundaries enforced, the second question is separate: even where a boundary exists, does the traffic through it justify splitting that context into its own service? A context with light load and one owner gains nothing from an independent pipeline -- it just adds a network call and a deployment to babysit.ShouldExtractServiceturns that judgement into a rule based on daily request volume and team size, so the extraction decision is made from the same kind of evidence as the boundary itself. -
Challenge
Step 3: Put payment communication behind an application interface
Step 3: Put payment communication behind an application interface
The monolith's worst habit is business logic that calls the payment gateway's HTTP client directly -- which means testing a refund means hitting a live API. The fix is
IPaymentGateway, already declared insrc/PaymentGatewayAdapter.csalongside the adapter that implements it against the real gateway over HTTP. Nothing above this adapter is allowed to know it's HTTP underneath. To prove the refund logic never actually needed the network,src/FakePaymentGateway.csimplements the sameIPaymentGatewayinterface entirely in memory, keeping a list of refunds it was asked to process instead of sending anything over HTTP. This is the fake the rest of the lab's tests will inject wherever the real adapter would otherwise be required. Finally,src/RefundApplicationService.csshows what dependency inversion buys the application layer: it takes anIPaymentGatewayin its constructor and never asks what's behind it. Whether that's the HTTP adapter or the in-memory fake is a decision made entirely outside this class. -
Challenge
Step 4: Isolate command dependencies with red-green-refactor
Step 4: Isolate command dependencies with red-green-refactor
The refund command is where validation, payment, and notification meet, and it's exactly the kind of logic that's easy to get subtly wrong under pressure -- approving a refund that's out of the return window, or notifying a customer before the money has actually moved.
src/RefundCommandHandler.csdeclaresRefundRequest, theINotificationSenderinterface, and a handler that takes bothIPaymentGatewayandINotificationSenderthrough its constructor, so every collaborator can be faked in a test. With validation in place,ApproveRefundis the method that sequences everything: check the request, move the money, tell the customer -- and only in that order, because notifying about a refund that failed is worse than not notifying at all. Verifying that a notification actually fired needs something to inspect afterward -- a realINotificationSenderwould send an actual message with no way to check it.src/FakeNotificationSender.csis a hand-built double that keeps every call it received in a list instead, which is the whole point of a test double: observable state in place of a side effect. -
Challenge
Step 5: Run acceptance scenarios in memory and defer technology choices
Step 5: Run acceptance scenarios in memory and defer technology choices
An acceptance test that proves the refund workflow end to end shouldn't need a browser, a UI, or a live payment API -- it needs the application layer wired up with fakes and driven the way a Given/When/Then scenario is written on paper.
src/ScenarioExecutor.csdeclaresExecuteScenario, the one method every scenario in this lab runs through: given a handler and a request, it runs the workflow and reports whether the outcome matched what was expected.src/RefundAcceptanceScenarios.csuses that executor to prove the customer-facing scenario that matters most: a valid, in-window refund request should be approved. Building it entirely from fakes is what makes it an acceptance test the team can run on every commit, not a manual click-through before a release. The last piece of evidence is that the notification technology was never a decision the core needed to make early.src/SmsNotificationAdapter.csimplements the sameINotificationSenderinterface the fake and the handler already depend on, but through an SMS-shaped channel instead. Swapping it in for the fake provesRefundCommandHandlerbehaves identically either way -- the technology choice stayed deferred until it was actually needed. -
Challenge
Step 6: Watch the refund workflow hold together
Every task is done and graded. Now watch the pieces you wrote separately behave as one refund workflow. You will run the whole thing, see a refund rejected before it reaches payment, and see the same handler work with a different notification technology. ### See the boundaries and the evidence check decide
Open the Terminal tab and run:
dotnet run --project src/Lab.csprojFind the two sections near the top. Under Task 2.1,
Fulfillment -> Payments allowed? TruebutCatalog -> Payments allowed? False. That is yourIsCallAllowedrule: a declared dependency passes and an undeclared one fails loudly.Under Task 2.2,
Volume=50000, Team=12 -> extract? False, even with a team of 12. YourShouldExtractServicerequires volume to exceed 50,000, so a context sitting exactly at the threshold is not yet a case for a microservice.Try it: in
src/Program.cs, changeShouldExtractService(50000, 12)toShouldExtractService(50001, 12), run again, and the line now readsextract? True. Change it back when you're done. ### Watch a bad refund stop before paymentIn the output, find the
Task 4.1 & 4.2section. The valid request (5 days since purchase) givesApproveRefund (valid request) approved: TrueandNotifications sent so far: 1. The stale request (400 days) gives:ApproveRefund (stale request) approved: False Refunds recorded: 1, notifications sent: 1Both counts are still 1, the numbers from the valid request. The stale one never reached the fake payment gateway and never triggered a notification. That is your
ValidateRefundRequestfrom Task 4.1 guarding the sequence in yourApproveRefundfrom Task 4.2, and you can see it without a live payment API. ### Swap the notification technology under the same handlerThe last two sections prove the workflow in memory and then change technology. Under Task 5.2,
RunCustomerRefundScenario passed: Trueran the full refund through your fakes with no browser or server. Under Task 5.3, the sameRefundCommandHandleris built withSmsNotificationAdapterinstead of the fake sender:ApproveRefund using SmsNotificationAdapter approved: True SMS texts recorded: 1 order-600: Your refund has been approved.The handler's code did not change. Only the object behind
INotificationSenderdid. That is the dependency inversion the lab was about, and it is what lets the team defer a technology decision until the workload actually asks for it.To rerun the graded checks at any time:
dotnet test tests/Lab.Tests.csproj --nologo --verbosity quietYou now have a refund workflow with enforced boundaries, a payment call hidden behind an interface, and a core you can test and re-wire without touching a live dependency.
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.