- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Details Crowd the Core in Clean Architecture
ShelfSync is the restocking backend for a small chain of hardware stores. Store managers report low stock and place reorders through it; a nightly job reads a low-stock report to plan truck routes. The existing code has StockItem objects that carry EF Core annotations and JSON-shaping properties alongside the actual business rule (when does an item need reordering, and by how much). You will extract that rule into a clean domain entity, route all reordering through an application use case that only knows about repository and notifier interfaces, split the read-heavy low-stock report away from the write path, and wire concrete EF Core and notification adapters in from a composition root - keeping one simple lookup endpoint deliberately un-layered because the abstraction would not pay for itself there.
Lab Info
Table of Contents
-
Challenge
Step 1: Meet ShelfSync's tangled restocking core
ShelfSync is the restocking backend behind a small chain of hardware stores. Store managers log low stock and place reorders through it, and a nightly job reads a low-stock report to plan the next day's delivery routes. Both jobs sound simple, but the code that backs them has grown a single
StockItemclass that tries to be a database row, a JSON payload, and a business rule all at once. Picture that class:[Table("stock_items")]and[Column("qty")]attributes sit next to a[JsonPropertyName("currentQty")]property, and buried between them is the one line that actually matters — the comparison that decides whether an item needs reordering. Change how the table is named, and you risk touching the same file that decides whether a truck gets dispatched. That coupling is the whole problem this lab addresses. The starter has already moved the annotations out:StockItemRowinsrc/Infrastructure/ShelfSyncDbContext.csnow holds the[Key]and[Column]attributes, andStockIteminsrc/Domain/StockItem.csis left with only the fields the reorder rule needs. Clean Architecture answers this by drawing a line around the business rules — the Domain layer — and enforcing the Dependency Rule: outer layers may depend inward on the domain, but the domain never depends outward on a database, a web framework, or a UI. An Application layer sits just outside the domain and orchestrates use cases purely through interfaces — ports — that name what the domain needs (a repository to load and save, a notifier to alert) without saying which concrete technology answers them. Concrete Infrastructure adapters — EF Core, an email sender — implement those ports from the outside, and a composition root is the one place allowed to wire a concrete adapter to the interface it satisfies. Over the next four steps you will pullStockItem's reorder rule into a clean Domain entity, build Application use cases —ReorderServiceandLowStockAlertService— that only ever seeIStockRepositoryandINotifier, split the low-stock report into its own read-optimized projection so it never has to replay domain rules, and finish by wiring EF Core adapters in from a composition root. One endpoint — a single-item quantity lookup — will deliberately skip all of this, because a one-field read has no rule to protect and no write to coordinate; you'll build it directly against the database and see why that's the right call, not a shortcut.info> If you get stuck, you can refer to the provided solution code for each task, available in the
solutionfolder. -
Challenge
Step 2: Give StockItem a business-only vocabulary
Every StockItem currently answers 'does this need reordering?' with logic scattered wherever someone last needed the answer.
src/Domain/StockItem.csholds the entity you'll fix that in: it already has the fields —Quantity,ReorderThreshold,ReorderQuantity— but its methods don't yet use them. An anemic entity like this is just a data bag; a rich one answers questions about itself. You'll turnStockIteminto the latter, one method at a time, without it ever importing anything about databases or HTTP. WithNeedsReorder()telling callers when to act, the entity should also tell them how much to act by — otherwise every caller ends up re-deriving that arithmetic fromQuantityandReorderQuantitythemselves. The last gap is the other direction: something has to be the single placeQuantityis allowed to increase, so that a badly-formed delivery can never leave the entity in a stateNeedsReorder()was never designed to see. -
Challenge
Step 3: Orchestrate reordering through injected ports
With the domain rules in place,
src/Application/ReorderService.csis where reordering actually happens — but this file must never construct a database connection or an HTTP client. It depends only onIStockRepositoryandINotifier, two ports already declared insrc/Application/IStockRepository.csandsrc/Application/INotifier.cs, and on theStockItemmethods you just wrote. That's the Dependency Rule in practice: the use case names what it needs and lets something else decide how those needs get met. Placing a reorder is only half the workflow — the pending amount it records has to be cleared once the delivery actually lands, or the item will look like it's still waiting on stock that already arrived.PlaceReorderAsynconly ever asks about the one item a caller names. The nightly job needs the opposite: a sweep across every item in the warehouse, which is a separate use case with its own file,src/Application/LowStockAlertService.cs. -
Challenge
Step 4: Split the command path from the low-stock report
So far, reading whether an item needs reordering and writing a stock change have shared the same path through the domain entity. That's fine for one item, but a report that scans every item in the warehouse and re-runs
NeedsReorder()for each one is answering a question the write side already answered when the change was committed. CQRS separates the two: a command handler owns writes, and a read-optimized projection — a denormalized snapshot kept in sync by the command side — answers queries without touching the domain at all. In this lab the command side covers stock adjustments.ReorderServicefrom Step 3 still saves deliveries on its own, so a fuller split would route those through the handler too, or the report would fall behind. You'll build the command path first, insrc/Application/StockCommandHandler.cs. The command handler now calls out to a projection store after every write — that store issrc/Infrastructure/LowStockProjectionStore.cs, and it's the piece responsible for actually keeping the snapshot honest as items cross in and out of needing reorder. With the snapshot kept current on every write, the report itself gets simple:src/Application/LowStockReportService.csshould never have to load aStockItemor callNeedsReorder()again — it just reads what the projection already knows. -
Challenge
Step 5: Wire adapters in and judge where the layering pays off
Every piece so far has depended on interfaces —
IStockRepository,INotifier— without ever being told which concrete class answers them. Something has to make that decision, and Clean Architecture puts it in exactly one place: a composition root. Before you can write that root, though, you need a real adapter for it to wire in.src/Infrastructure/EfStockRepository.csis whereIStockRepositoryfinally meets an actual EF CoreDbContext. With a concrete repository (and the notifier the starter already provides) in hand,src/CompositionRoot.cscan assemble a fully wiredReorderService— the only file in the solution that's allowed to know about both the interfaces and the classes that implement them. The other use cases are already wired there for you, soBuildReorderServiceis the one method left to write. Not every read deserves this treatment.src/Api/StockLookupEndpoint.csexposes a single-item quantity lookup with no business rule behind it and nothing to coordinate — routing it through a repository, a use case, and a composition-root wire-up would be ceremony with no payoff. Build it directly against the database and let the contrast make the case for everything before it. -
Challenge
Step 6: Watch the clean core run ShelfSync
Every task is done and graded. Now run ShelfSync as one program.
src/Program.csseeds four items into an EF Core in-memory database, then drives a reorder, the nightly sweep, and the low-stock report through the code you wrote. You'll finish by changing one line in the domain and watching both sides of the CQRS split follow it. ### Follow one reorder through every layerIn the Terminal tab, run:
dotnet run --project src/Lab.csprojLook at the
-- Reorder, wired by CompositionRoot --section.Reorder placed for HAM-16: 26 unitspassed through every layer you built. YourBuildReorderServicefrom Task 5.2 wired the EF adapter intoReorderService,GetByIdAsyncfrom Task 5.1 turned the database row into a domainStockItem, and the entity's ownNeedsReorder()andCalculateReorderQuantity()worked out 30 - 4 = 26. TAP-25 has 22 on the shelf against a threshold of 8, so it gets0 units.The lookup lines read the same database without any layers.
HAM-16 has 4 on the shelfbecomesHAM-16 has 30 on the shelfonceConfirmReceiptAsyncsaves the delivery, and an unknown item givesNAIL-99 returns -1. That's your one-methodGetQuantityfrom Task 5.3. It has no repository and no use case, and it still sees exactly what the layered path wrote.The last line starts with
Delivery of 0 for DRL-18 rejected:and ends with your exception's message. That's the guard inReceiveStockfrom Task 2.3.ReorderServicenever checks the amount itself. The rule lives in the entity, so every caller gets it. ### Compare the sweep with the reportThe next two sections answer the same question in two different ways. The nightly sweep from Task 3.3 loads every item and asks each one
NeedsReorder(). TheConsoleNotifierprintsLow stock alert: item SCR-08 is at 10 unitsandLow stock alert: item DRL-18 is at 2 units, followed byAlerts sent: 2. SCR-08 sits exactly at its threshold of 10, which is why Task 2.1 asked for<=.The report from Task 4.3 finds the same two items without calling
NeedsReorder()at all. It reads the rows your projection from Task 4.2 stored, with the most depleted item first: DRL-18 with a quantity of 2 and a suggested reorder of 10, then SCR-08 with 10 and 40. Then one command goes through yourStockCommandHandlerfrom Task 4.1:Command: received 40 of SCR-08, quantity now 50. The report drops toReport rows: 1. The handler refreshed the projection as part of the write, so the report never had to recompute anything. ### Move the reorder line in one placeOpen
src/Domain/StockItem.csand find the comparison inNeedsReorder(). If you followed Task 2.1, it reads:return Quantity <= ReorderThreshold;Change
<=to<, save, and rerun the tour. SCR-08 disappears from both sides: the sweep showsAlerts sent: 1, and the first report showsReport rows: 1. You edited one line in the domain, and a use case on the command side and a read model on the query side both changed. You didn't touchLowStockAlertServiceorLowStockProjectionStore, because neither one owns the rule. They both ask the entity.Put
<=back before you finish. To confirm every check still passes, rundotnet test tests/Lab.Tests.csproj --nologo --verbosity quiet.ShelfSync's reorder rule now lives in one plain class. The database, the notifier, and the report are details plugged in around it.
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.