- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Polyglot Data Access in a C# Service
The company runs its catalog on a single overloaded relational table that mixes rarely-changing product descriptions with rapidly-changing inventory counters, and every read competes with every write. You are splitting this into a polyglot data layer inside the company's existing catalog service: a document store for product records that vary in shape, a key-value store for inventory counters that are read and written by SKU far more often than they are queried by attribute, and a cache tier that absorbs read traffic without ever serving a stale price after a write. You will also decide, with real measurements, whether product documents are cheaper to store as JSON or as a generated Protobuf contract, and you will decide which store owns each piece of data before guarding the one write path that must never silently lose an update: changing a product's price.
Lab Info
Table of Contents
-
Challenge
Step 1: Tour the company's catalog storage split
Splitting the company's catalog table into three stores
The company has run its catalog off a single relational table since launch. That table stores two things that have nothing in common except a SKU column: product descriptions that change a few times a year, and inventory counters that change on every sale. Every checkout write blocks every catalog-page read, and the table has outgrown a single index strategy.
You are building the storage core of the company's replacement catalog service: a document store for product records whose shape varies by category, a key-value store for inventory counters that are almost always accessed by exact SKU, and a cache tier in front of both that must never hand back a price you have already overwritten.
Each store in this lab is a real server running on this machine: MongoDB 7.0 holds the product documents, and Redis 6.0 holds the inventory counters and serves as the cache. The lab started both when it launched. Every check runs against these servers, in its own database and key prefix, so nothing you try in one task can break another task's check. You can look inside either server at any time with
mongoshorredis-cliin the Terminal tab. ## How the work is split across the projectsrc/AccessPatternAdvisor.csandsrc/StorageRouter.cshold the decision logic that sends a record to the right store based on how it is read and written, and the router that actually dispatches it.src/CatalogConnections.csholds the shared connections to MongoDB on127.0.0.1:27017and Redis on127.0.0.1:6379. It is already complete.src/ProductDocumentStore.csandsrc/LegacyBackfillService.csmodel product documents and their category-specific attributes, upsert them into a MongoDB collection, and migrate legacy flat rows, splitting each between MongoDB andsrc/InventoryStore.cs, the Redis key-value store for stock counts.src/SerializationBenchmark.csandsrc/ContractSelector.csmeasure JSON against Protobuf for a product document and pick a storage contract from real numbers rather than a guess.src/CachingProductReader.csadds read-through caching in Redis with exact-key invalidation.src/DataOwnership.cs,src/InventoryStore.csandsrc/ProductUpdateService.csrecord which store owns each piece of data, update stock counters atomically, and protect the one write path that must never lose an update: setting a product's price.
Every file above already compiles. Where a method's body is a stub, it is marked with a
// TODO: Task N.Mcomment and a// Hint:line. You will replace the stub, not the surrounding structure.If a check ever fails with
Could not reach MongoDBorCould not reach Redis, runbash ~/workspace/setup.shin the Terminal tab to start the servers again, then rerun the check. ## The concepts behind the splitA document store earns its keep when records vary in shape and are queried by more than one attribute. A key-value store earns its keep when access is almost always by a single partition key and throughput matters more than query flexibility, which is exactly the profile of an inventory counter hit on every order. Getting this choice wrong shows up later as either a document store straining under point-lookup traffic it was never tuned for, or a key-value store that cannot answer "which products are low on stock" without a full scan.
Once records land in the right store, two more decisions follow: whether to pay JSON's readability tax or Protobuf's schema rigidity for the bytes you store, and how to keep a cache fast without ever serving a price that a write has already superseded. Splitting data across stores raises one more question: when two copies of a value disagree, which one wins? Each piece of data needs exactly one owner, and every other copy, the cache included, is derived from it. The next five steps build each decision as code you can run and check, starting with the routing logic.
-
Challenge
Step 2: Route records to the right store
Deciding where a record belongs
Before anything gets stored, the catalog needs a rule: given how often a kind of record is read versus written, and how many distinct partition keys it has, should it go to the document store or the key-value store? A record with few distinct keys and read-heavy traffic, like a product catalog entry looked up by several attributes, fits a document store. A record with a huge number of distinct keys, each written almost as often as it is read, like an inventory counter keyed by SKU, fits a key-value store instead.
src/AccessPatternAdvisor.csalready defines theStoreKindenum and theRecommendStoresignature; the body just returns a fixed value. ## Turning a recommendation into a dispatchA recommendation is only useful once something acts on it.
src/StorageRouter.csholds two in-memory lists standing in for the real document and key-value stores, plus aLastRoutedToproperty a check can inspect after a call. TheRoutemethod needs to call your advisor and actually append the record to the matching list. -
Challenge
Step 3: Model, backfill, and upsert product documents
Modeling a product as a document
The legacy table gives every product the same columns, so details that only apply to one category end up as sparse columns.
Lumensonly means something for a lantern,BurnerCountonly for a stove, and every other row stores a null in each. A document handles this differently. Fields every product shares stay at the top level, and the details that vary by category move into an embedded map, so each document carries only what applies to it and a new category needs no schema change. In MongoDB, that map is stored as an embedded document inside each product.ProductDocumentinsrc/ProductDocumentStore.csalready has the shared fields. It is missing the two that make it a document rather than a row. ## Upsert: the write every product document goes throughA product document store never distinguishes "create" from "update" at the call site. Every write is an upsert keyed by SKU, and the
[BsonId]attribute onSkumakes the SKU each document's_idin MongoDB. MongoDB'sReplaceOnewithIsUpsert = trueis exactly that write in one call: it replaces the document that matches the filter, or inserts it when nothing matches. The store also bumps a revision number on each write and stamps a fresh ETag, an opaque version token, so later readers can tell whether anything changed.ProductDocumentStorewraps theproductscollection, andFindis already written;Upsertcurrently returns whatever it was given without writing anything. ## Backfilling the legacy rowsThe old relational table's rows do not look like
ProductDocument. They are flat records with aDescriptionfield instead ofName, sparseLumensandBurnerCountcolumns that are null for most rows, and a nullableQuantityOnHandthat was never populated for older SKUs. They also mix two kinds of data the new layer keeps apart: product details go to the document store, and the stock count goes toInventoryStoreinsrc/InventoryStore.cs, the Redis key-value store for counters.src/LegacyBackfillService.csdefinesLegacyProductRecordand aMigratemethod that should reshape each legacy row into the document you modeled, split off its stock count, and send the document through the upsert path you just wrote. -
Challenge
Step 4: Measure and choose a serialization contract
Measuring before choosing
JSON is self-describing and easy to inspect in a debugger; Protobuf is smaller on the wire and faster to encode once a schema is fixed. Neither claim is worth trusting until it is measured against the actual
ProductDocumentshape the catalog stores.src/SerializationBenchmark.csdefines aSerializationResultwithByteSizeandElapsedTicksfields;MeasureJsonneeds to fill one in by actually serializing a document withSystem.Text.Json. ## The Protobuf side of the same measurementProductDocumentalready carries the[ProtoContract]and[ProtoMember]attributes protobuf-net needs, soMeasureProtobufonly has to driveProtoBuf.Serializerthe same wayMeasureJsondrivesJsonSerializer: serialize, measure the bytes, measure the elapsed time. ## Picking a contract and tagging the payloadA size comparison only helps if something acts on it, and whatever is stored has to say which format it is in, because a reader years from now cannot tell JSON bytes from Protobuf bytes by looking.
src/ContractSelector.cs'sSelectAndTagtakes both already-serialized payloads and needs to keep the smaller one, prefixed with a single tag byte identifying its format. -
Challenge
Step 5: Cache reads with precise invalidation
Read-through caching in front of the document store
Most catalog reads hit the same handful of popular SKUs, so a Redis cache in front of
ProductDocumentStoreabsorbs the bulk of read traffic without touching MongoDB at all.src/CachingProductReader.cskeeps each cached product as a JSON string under its own key, such asproduct:SKU-100, and gives that key a time-to-live (TTL) when it writes it. Redis deletes the key by itself when the TTL runs out, so the reader never tracks expiry times.StoreReadCountcounts how many reads actually fell through to the store, which is how a check confirms the cache is doing its job rather than always missing. ## Invalidating exactly what changedExpiration alone would let a stale price live for up to the full TTL after a write. The catalog cannot wait that long, but it also cannot afford to clear every cached SKU every time one product changes.
Invalidateneeds to delete only the Redis key for the SKU that was written, leaving every other cached product untouched. Flushing the whole database would remove the stale price too, but it would also send every popular SKU back to MongoDB at once. -
Challenge
Step 6: Assign data ownership and protect authoritative writes
Naming the owner of every field
The catalog now spreads one product across three places: details in the document store, the stock count in the key-value store, and copies of both in the cache. When two copies disagree, one of them has to win. That one is the authoritative store for the field, and every write to the field goes there. Everything else, the cache included, is derived: you can throw it away and rebuild it from the owners at any time.
src/DataOwnership.csis where the catalog records that decision, so it lives in the code rather than only in a design document. ## Counters belong to the key-value storeStock is the field that changes on every sale, which is why it lives in Redis rather than on the product document.
InventoryStore(src/InventoryStore.cs) keeps one key per SKU, such asinventory:SKU-100. A counter does not need an ETag check: a sale does not care what the count was, only that one unit left. It does need the change to be atomic, and it must never sell stock that is not there. RedisINCRBYis atomic but cannot refuse to go below zero, and aGETfollowed by aSETfrom C# lets two sales read the same count and lose a decrement. The answer is a Lua script: Redis runs a script as a single step, so no other command can land between its read and its write.AdjustScript, at the top of the class, is that script, already written, as areSet,GetandContains.Adjustis where every sale lands. ## Rejecting a stale price write with an ETagPrice is different. A staff member chooses a new price after looking at the old one, so if two people edit the same SKU seconds apart, the second write must not silently replace the first. Every upsert now stamps a new ETag (Task 3.2), so the ETag a caller read tells you exactly which version they saw.
UpdatePriceinsrc/ProductUpdateService.cstakes that ETag along with the new price and refuses the write when the stored ETag has moved on. The comparison has to be part of the write itself. If the service compared ETags first and wrote afterwards, two writers holding the same ETag could both pass the comparison before either one wrote.TryReplaceinsrc/ProductDocumentStore.cs, already written, puts the ETag inReplaceOne's filter, so MongoDB only matches the document if its ETag is still the one the caller read, and it checks and writes in one atomic operation.TryReplacereturns false when nothing matched. ## Retrying without losing the other writeA conflict is often transient, and the right response is usually to try again. But a retry is only safe if it starts from what is stored now. Sending the same new price with a fresh ETag would get past the check and erase the competing write, which is exactly the lost update the ETag exists to prevent.
UpdatePriceWithRetrytakes the change as a function,reprice, that turns the current price into the new one (a 10% discount, say). On each attempt it re-reads the document and appliesrepriceto whatever is there now. -
Challenge
Step 7: Watch the price race play out
Now run the entire catalog data layer at once. The tour ends with its most important scenario: three people editing the same price, and none of their changes being silently overwritten.
1. Run the tour
In the Terminal tab, run:
dotnet run --project src/Lab.csprojsrc/Program.csruns through every store in the order you built them. Start with the first and third sections. Who owns what prints your ownership map from Task 6.1:Name -> Document,Price -> Document,StockCount -> KeyValue, andCache is derived, safe to drop: True. Backfill shows one legacy row reshaped by Task 3.3. The document store holds the lantern as the document you modeled in Task 3.1, with"Category":"lighting"and"Attributes":{"lumens":"300"}and no trace of the stove'sBurnerCountcolumn. The inventory store holds itsstock 3separately. The ETags in the output are the first 8 characters of a new GUID, so yours differ on every run. ### 2. Follow the price raceNow read the Two staff members edit the same price section. Alice and Bob both read
40.00at the same ETag. Alice saves48.00and the ETag changes, because yourUpsertfrom Task 3.2 stamps a new one on every write. Bob then tries to save 10% off the40.00he read, and getsrejected, followed by the message yourConcurrencyConflictExceptioncarries. That rejection is yourUpdatePricefrom Task 6.3. Without it, Bob's36.00would have silently replaced Alice's48.00.Bob then retries through
UpdatePriceWithRetryfrom Task 6.4, and Carol saves50.00while his first attempt is in flight:attempt 1: reads 48.00, writes 43.20 meanwhile Carol saves 50.00 attempt 2: reads 50.00, writes 45.00 Saved 45.00 after 2 attempt(s) Cache now serves 45.00The second attempt re-read Carol's price and applied the discount to it, so the final
45.00includes both changes. The cache shows the new price immediately becauseUpdatePriceinvalidated it.The last section, Sales against the inventory counter, is the other half of the ownership split. Three concurrent sales take stock to
0with no ETag involved, and a fourth isrefused, because yourAdjustfrom Task 6.2 never lets a counter go below zero. ### 3. Look inside MongoDB and RedisEverything the tour wrote is still in the two servers. Ask MongoDB for every product that has a
lumensattribute:mongosh catalog --quiet --eval 'db.products.find({ "Attributes.lumens": { $exists: true } })'Only the lantern comes back, with
_id: 'SKU-100',Price: Decimal128('45.000')andAttributes: { lumens: '300' }. The stove has nolumensattribute at all, not even a null one, which is the point of the embedded map from Task 3.1. Now look at the cache entry in Redis. It only lives for 30 seconds, so the first command reruns the tour quietly to write a fresh one:dotnet run --project src/Lab.csproj > /dev/null redis-cli get product:SKU-100 redis-cli ttl product:SKU-100getprints the cached JSON with the45.000price, andttlprints the seconds left before Redis deletes the key. Runredis-cli ttl product:SKU-100again and watch it count down; once it prints-2, the key is gone and the next read goes back to MongoDB.redis-cli get inventory:SKU-100prints0, the count yourAdjustleft after the three sales. ### 4. Take away the retriesIn
src/Program.cs, find this line:}, maxAttempts: 3) ?? throw new NotBuiltYetException();Change
maxAttempts: 3tomaxAttempts: 1and rerun the tour. This time Bob's only attempt loses to Carol, and you seeBob gave up after 1 attempt(s)with your conflict message, followed byCache now serves 50.00. Carol's write stands and Bob gets a clear error to act on, instead of either change disappearing. Change it back to3when you're done.Congratulations on completing the lab! The single overloaded table you started with is now a MongoDB collection and a set of Redis keys, with one owner per field, and its most sensitive write can no longer silently lose an update.
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.