- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
C# Application Moves from Database Connections to Polyglot Persistence
Globomantics is a small online retailer's order and inventory service. It currently opens one ad-hoc SQLite connection for everything: catalog reads, stock writes, and reporting. As Globomantics grows, that single connection can no longer serve every workload well. Reporting queries need to run read-only, reports and audit writes need parameterized ADO.NET commands with guaranteed disposal, the business logic needs to be testable without a real database, and order summaries and audit trails are cheaper to serve from a document cache and a partition-keyed store than from repeated relational queries. You will rebuild the persistence layer in place, store by store, until every workload has its own path: secure connections, an EF Core catalog, ADO.NET reports, a repository and unit of work, and two NoSQL stores. A single order-placement call ties the unit of work, cache, and audit store together.
Lab Info
Table of Contents
-
Challenge
Step 1: Meet Globomantics single-connection problem
Globomantics: from one connection to polyglot persistence
OrbitCommerce is an order and inventory service developed by Globomantics for small online retailers. Right now its entire persistence layer is one hand-rolled
SqliteConnection, opened wherever code needs it, doing catalog reads, stock writes, and reporting queries alike. That single connection has no notion that reporting should be read-only, no schema evolution story, no seam for testing the order logic without a live database, and no cheap path for the summaries and audit trails customers and support staff look up over and over.Over the next four steps, you will replace that one connection, store by store:
- A provider-matched secure connection selector that opens the right kind of SQLite connection for the workload asking for it.
- An EF Core catalog that models products, seeds them, logs the SQL it generates, and migrates its own schema.
- Raw ADO.NET reporting and audit writes, with disposal that holds up even when a query throws.
- A repository and unit of work that hide EF Core behind an interface a test can replace with a fake, so order logic is testable without a database.
- HybridCache and a partition/row-keyed store feeding a single order-placement call that exercises every store you built. ## How the steps fit together
Each step adds a new capability to Globomantics, and step 5 brings everything together into a single order-placement operation. By the end,
PlaceOrderAndSummarizeAsyncwill:- Check and reserve stock through the repository and unit of work from step 4.
- Record an audit entry in the table-storage-style store from step 5.
- Return a cached order summary using HybridCache from step 5.
The EF Core catalog from step 3 supports the repository by handling the underlying database reads and writes.
How to run the project
The starter project already builds, but every method you will complete is a stub that throws
NotImplementedException. The application catches these exceptions and displays anot built yetmessage for each incomplete task. Each task's validation check will fail until you replace the corresponding stub with working behavior.You can run the application at any point to see which tasks are still reported as not implemented. In the Terminal tab, run:
dotnet run --project src/Lab.csprojEach task goes beyond creating a type or interface. You'll implement functionality that you can validate and observe in action.
-
Challenge
Step 2: Build the secure, provider-matched connection selector
OrbitCommerce's catalog workload needs to write, and its reporting workload should never be able to. A single connection string can't express that difference, but
Microsoft.Data.Sqlite'sSqliteConnectionStringBuildercan: itsModesetting controls whether a connection can create, write, or only read a database file, and itsForeignKeyssetting turns on SQLite's foreign-key enforcement.src/ConnectionSelector.csalready declares aWorkloadKindenum and aSelectConnectionmethod that routes a workload to the right factory method -- you're about to make those factory methods actually match their workload's security needs. Catalog writes now open a connection with foreign keys enforced. Reporting shouldn't inherit that same write-enabled connection just because it reads from the same file. It needs a builder configured forReadOnlymode instead, so a reporting query can never accidentally mutate stock or prices. -
Challenge
Step 3: Create and evolve the catalog with EF Core
The catalog needs more than a connection. It needs a schema, and a way to get real rows into it before anything can query them.
src/CatalogContext.csdefines aProductentity and aDbContextthat exposes it as aProductsset.Database.EnsureCreated()turns that model into an actual SQLite schema, andSaveChanges()persists whatever you've added to the context's change tracker. You'll use both to get the catalog off the ground. With products seeded, you need to see what SQL EF Core actually runs when you query them, not to change the query's result, but to trace it.OnConfiguringalready wires upLogToso EF Core log messages are written toCapturedSql. The property stores the most recent log message, which can include generated SQL when a query executes. The LINQ query you write must be materialized to execute and generate a SQL command. Schemas change. Rather than reaching for EF Core's full migration tooling, you can evolve a SQLite table directly withDatabase.ExecuteSqlRaw, which runs a raw SQL string against the context's connection. This lets you add a column and then backfill it for every row that already exists. -
Challenge
Step 4: Query with ADO.NET and isolate order logic behind a repository
Reporting queries don't need EF Core's change tracking. They need a fast, parameterized read. The methods in
src/InventoryReports.cswill open aSqliteConnectionand aSqliteCommanddirectly.Both are
IDisposable, and ausingstatement guaranteesDisposeruns whether the code inside it returns normally or throws. This matters the moment a malformed query or a locked database causesExecuteReaderto fail partway through. Audit writes have the same disposal problem as reads, but with an extra twist: the insert itself might be the thing that throws, and the command still has to be disposed afterward.A
try/finallyaround the command (or ausingdeclaration, which desugars to the same thing) disposes it on every exit path, successful or not. Order logic shouldn't know it's talking to EF Core. It should talk to an interface, so a test can substitute something that isn't a real database.src/InventoryRepository.csdeclaresIInventoryRepositoryand anInventoryRepositoryimplementation backed by theCatalogContextfrom step 3.AdjustStockAsyncis already wired up;GetStockAsyncis the read side of that same interface. With reads and writes both behindIInventoryRepository, the unit of work insrc/OrderUnitOfWork.cscan enforce a business rule, never oversell stock, without touching SQL at all.Because it depends only on the interface, a test can hand it a hand-written fake of
IInventoryRepositoryand assert exactly which methods got called, with what arguments, for a given stock level. -
Challenge
Step 5: Add NoSQL paths and combine every store in order placement
Order summaries are read far more often than they change, which makes them a good fit for a cache instead of a repeated query.
HybridCache.GetOrCreateAsynctakes a key and a factory delegate: on a cache hit it returns the stored value without calling the factory at all, and on a miss it calls the factory, stores the result, and returns it.src/OrderSummaryCache.cswraps that behindGetSummaryAsync, backed by acomputeSummarydelegate the constructor already holds. Audit trails fit a different shape: many independent rows, looked up by a coarse partition and a fine-grained row identifier, the way table storage organizes data.src/AuditTableStore.cssimulates this behavior using an in-memory dictionary keyed by a (PartitionKey,RowKey) tuple. Unlike a persistent table-storage service, the dictionary does not retain data across application restarts.GetPartitionalready reads every row for a given partition.Upsertis the write operation that stores rows and replaces earlier values at the same keys. Every store you've built now has one job: the unit of work reserves stock, the audit store records that it happened, and the cache serves the summary.src/OrderPlacementFacade.csholds all three through the interfaces they implement:IOrderUnitOfWork,IAuditStore, andIOrderSummaryCache. Just as the unit of work depends only onIInventoryRepository, that lets a test swap in a stand-in for each one.PlaceOrderAndSummarizeAsyncis the single call that has to sequence them correctly, including refusing to audit or summarize an order that never got placed. -
Challenge
Step 6: Watch the persistence layer at work
After completing and validating all tasks, run the application to see how the components work together. Then modify three examples to verify their behavior.
1. Run the tour
In the Terminal tab, run:
dotnet run --project src/Lab.csprojsrc/Program.csruns the prewritten application workflow, calling the methods you completed throughout the lab. The application deletes and recreates the database each time, so you can rerun it to verify your implementations.The next three exercises involve small edits to
src/Program.cs. ### 2. Try to write through the reporting connectionThe reporting connection is meant to be read-only. To prove it, find this line in
src/Program.cs:Console.WriteLine($"Reporting connection open, state = {reportingConnection.State}");Add this directly below it:
try { using var write = reportingConnection.CreateCommand(); write.CommandText = "CREATE TABLE Probe (Id INTEGER)"; write.ExecuteNonQuery(); Console.WriteLine("Reporting connection wrote to the database!"); } catch (SqliteException ex) { Console.WriteLine($"Reporting write refused: {ex.Message}"); }Rerun the tour. Instead of the write going through, you should see
Reporting write refused: SQLite Error 8: 'attempt to write a readonly database'. That refusal comes from theMode = SqliteOpenMode.ReadOnlysetting you added in Task 2.2, not from any check in your own code. ### 3. See the SQL EF Core generatedFind this line:
Console.WriteLine($"Products at or above 20.00: {expensive.Count}");Add this below it:
Console.WriteLine(catalog.CapturedSql);Rerun the tour. Your one-line LINQ
Wherefrom Task 3.2 appears as a realSELECT ... FROM "Products" ... WHEREstatement.Notice that the price threshold arrives as the parameter
@minimumPricerather than as a number pasted into the SQL. EF Core gives you the same protection that you added by hand to the ADO.NET query in Task 4.1. ### 4. Catch the cache serving a stored valueFind this line:
Console.WriteLine($"Cached summary for order 42: {summary}");Add a second lookup for the same order below it:
await Task.Delay(100); var again = await summaryCache.GetSummaryAsync(42); Console.WriteLine($"Second lookup: {again}");Rerun the tour. Each summary records the moment it was computed, and both lines show the same timestamp even though the second lookup ran 100 ms later.
HybridCache returned the stored summary without calling the factory again. Change the second lookup to
GetSummaryAsync(43)and the timestamps will differ, because a new key is a cache miss.That's the lab: the single connection you started with is now a store matched to each workload, and you've watched three of them do the job they were built for.
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.