Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

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 platform
Lab Info
Level
Advanced
Last updated
Nov 03, 2026
Duration
1h 5m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. 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 mongosh or redis-cli in the Terminal tab. ## How the work is split across the project

    • src/AccessPatternAdvisor.cs and src/StorageRouter.cs hold 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.cs holds the shared connections to MongoDB on 127.0.0.1:27017 and Redis on 127.0.0.1:6379. It is already complete.
    • src/ProductDocumentStore.cs and src/LegacyBackfillService.cs model product documents and their category-specific attributes, upsert them into a MongoDB collection, and migrate legacy flat rows, splitting each between MongoDB and src/InventoryStore.cs, the Redis key-value store for stock counts.
    • src/SerializationBenchmark.cs and src/ContractSelector.cs measure JSON against Protobuf for a product document and pick a storage contract from real numbers rather than a guess.
    • src/CachingProductReader.cs adds read-through caching in Redis with exact-key invalidation.
    • src/DataOwnership.cs, src/InventoryStore.cs and src/ProductUpdateService.cs record 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.M comment and a // Hint: line. You will replace the stub, not the surrounding structure.

    If a check ever fails with Could not reach MongoDB or Could not reach Redis, run bash ~/workspace/setup.sh in the Terminal tab to start the servers again, then rerun the check. ## The concepts behind the split

    A 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.

  2. 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.cs already defines the StoreKind enum and the RecommendStore signature; the body just returns a fixed value. ## Turning a recommendation into a dispatch

    A recommendation is only useful once something acts on it. src/StorageRouter.cs holds two in-memory lists standing in for the real document and key-value stores, plus a LastRoutedTo property a check can inspect after a call. The Route method needs to call your advisor and actually append the record to the matching list.

  3. 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. Lumens only means something for a lantern, BurnerCount only 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. ProductDocument in src/ProductDocumentStore.cs already 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 through

    A product document store never distinguishes "create" from "update" at the call site. Every write is an upsert keyed by SKU, and the [BsonId] attribute on Sku makes the SKU each document's _id in MongoDB. MongoDB's ReplaceOne with IsUpsert = true is 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. ProductDocumentStore wraps the products collection, and Find is already written; Upsert currently returns whatever it was given without writing anything. ## Backfilling the legacy rows

    The old relational table's rows do not look like ProductDocument. They are flat records with a Description field instead of Name, sparse Lumens and BurnerCount columns that are null for most rows, and a nullable QuantityOnHand that 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 to InventoryStore in src/InventoryStore.cs, the Redis key-value store for counters. src/LegacyBackfillService.cs defines LegacyProductRecord and a Migrate method 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.

  4. 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 ProductDocument shape the catalog stores. src/SerializationBenchmark.cs defines a SerializationResult with ByteSize and ElapsedTicks fields; MeasureJson needs to fill one in by actually serializing a document with System.Text.Json. ## The Protobuf side of the same measurement

    ProductDocument already carries the [ProtoContract] and [ProtoMember] attributes protobuf-net needs, so MeasureProtobuf only has to drive ProtoBuf.Serializer the same way MeasureJson drives JsonSerializer: serialize, measure the bytes, measure the elapsed time. ## Picking a contract and tagging the payload

    A 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's SelectAndTag takes both already-serialized payloads and needs to keep the smaller one, prefixed with a single tag byte identifying its format.

  5. 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 ProductDocumentStore absorbs the bulk of read traffic without touching MongoDB at all. src/CachingProductReader.cs keeps each cached product as a JSON string under its own key, such as product: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. StoreReadCount counts 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 changed

    Expiration 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. Invalidate needs 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.

  6. 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.cs is 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 store

    Stock 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 as inventory: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. Redis INCRBY is atomic but cannot refuse to go below zero, and a GET followed by a SET from 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 are Set, Get and Contains. Adjust is where every sale lands. ## Rejecting a stale price write with an ETag

    Price 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. UpdatePrice in src/ProductUpdateService.cs takes 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. TryReplace in src/ProductDocumentStore.cs, already written, puts the ETag in ReplaceOne'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. TryReplace returns false when nothing matched. ## Retrying without losing the other write

    A 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. UpdatePriceWithRetry takes 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 applies reprice to whatever is there now.

  7. 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.csproj
    

    src/Program.cs runs 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, and Cache 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's BurnerCount column. The inventory store holds its stock 3 separately. The ETags in the output are the first 8 characters of a new GUID, so yours differ on every run. ### 2. Follow the price race

    Now read the Two staff members edit the same price section. Alice and Bob both read 40.00 at the same ETag. Alice saves 48.00 and the ETag changes, because your Upsert from Task 3.2 stamps a new one on every write. Bob then tries to save 10% off the 40.00 he read, and gets rejected, followed by the message your ConcurrencyConflictException carries. That rejection is your UpdatePrice from Task 6.3. Without it, Bob's 36.00 would have silently replaced Alice's 48.00.

    Bob then retries through UpdatePriceWithRetry from Task 6.4, and Carol saves 50.00 while 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.00
    

    The second attempt re-read Carol's price and applied the discount to it, so the final 45.00 includes both changes. The cache shows the new price immediately because UpdatePrice invalidated it.

    The last section, Sales against the inventory counter, is the other half of the ownership split. Three concurrent sales take stock to 0 with no ETag involved, and a fourth is refused, because your Adjust from Task 6.2 never lets a counter go below zero. ### 3. Look inside MongoDB and Redis

    Everything the tour wrote is still in the two servers. Ask MongoDB for every product that has a lumens attribute:

    mongosh catalog --quiet --eval 'db.products.find({ "Attributes.lumens": { $exists: true } })'
    

    Only the lantern comes back, with _id: 'SKU-100', Price: Decimal128('45.000') and Attributes: { lumens: '300' }. The stove has no lumens attribute 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-100
    

    get prints the cached JSON with the 45.000 price, and ttl prints the seconds left before Redis deletes the key. Run redis-cli ttl product:SKU-100 again 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-100 prints 0, the count your Adjust left after the three sales. ### 4. Take away the retries

    In src/Program.cs, find this line:

    }, maxAttempts: 3) ?? throw new NotBuiltYetException();
    

    Change maxAttempts: 3 to maxAttempts: 1 and rerun the tour. This time Bob's only attempt loses to Carol, and you see Bob gave up after 1 attempt(s) with your conflict message, followed by Cache 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 to 3 when 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

Pluralsight’s AI authoring technology is designed to accelerate the creation of hands-on, technical learning experiences. Serving as a first-pass content generator, it produces structured lab drafts aligned to learning objectives defined by Pluralsight’s Curriculum team. Each lab is then enhanced by our Content team, who configure the environments, refine instructions, and conduct rigorous technical and quality reviews. The result is a collaboration between artificial intelligence and human expertise, where AI supports scale and efficiency, and Pluralsight experts ensure accuracy, relevance, and instructional quality, helping learners build practical skills with confidence.

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.

Get started with Pluralsight