Eight parts, six full projects, dozens of lessons — this is where you find out how much of "C# In Practice" actually stuck.
You started this tier already knowing how to write a class, use a List<T>, and catch an exception. You're finishing it able to write your own generic types, query and reshape data declaratively with LINQ, decouple systems with delegates and events, wire up dependency injection and configuration the way real ASP.NET Core apps do, talk to a real database through EF Core, write genuinely non-blocking asynchronous code, and — across the six projects in this very part — combine every one of those into working, runnable systems. That's not a small distance to travel either.
This final page is a real test of all of it, not a skim-and-guess quiz. Eleven coding challenges, roughly in order of increasing difficulty, pulled from every part of this tier — some straightforward, some that will genuinely make you think for a few minutes. Each one gives you the problem, a hint if you want a nudge, and a complete worked solution with explanation if you want to check your work. Actually attempt each one before opening the solution. Write the code, run it, get something wrong, fix it — that struggle is where the tier actually finishes teaching you something, and reading a solution cold skips right past it.
Before the challenges, take a moment to see the shape of everything "C# In Practice" covered. Each of these eight parts built directly on top of the Foundations tier, and on each other:
Action, Func, Predicate, lambdas, closures, local functions, higher-order functions, events, and event-driven design.HttpClient, JSON APIs, and REST fundamentals.DbContext, entities, relationships, migrations, LINQ with EF Core, tracking, and transactions.Task/Task<T>, async/await, cancellation tokens, Task.WhenAll/WhenAny, async streams, and common async mistakes.async/await — sometimes several of those at once in a single challenge. Nothing from the Advanced tier (runtime internals, memory tuning, distributed systems) is required. If a challenge feels hard, the tools to solve it are already fully in your hands.
Eleven challenges, roughly in order of increasing difficulty, spanning every part of the tier. Attempt each one yourself before opening its solution.
Challenge 1 — Depend on the interface, not the implementationEasy
Design an IPaymentProcessor interface with one method, Task<bool> ChargeAsync(decimal amount). Write two implementations, CreditCardProcessor and PayPalProcessor (both can just simulate success). Then write an OrderCheckout class that depends only on IPaymentProcessor — never on either concrete class — to complete a checkout.
This is the Dependency Inversion Principle from Part I: OrderCheckout should take an IPaymentProcessor in its constructor and call only members defined on the interface. It must be possible to pass either processor in — or a fake one in a test — without a single line of OrderCheckout changing.
public interface IPaymentProcessor
{
Task<bool> ChargeAsync(decimal amount);
}
public class CreditCardProcessor : IPaymentProcessor
{
public async Task<bool> ChargeAsync(decimal amount)
{
await Task.Delay(100); // simulated network call to a card gateway
Console.WriteLine($"Charged ${amount} to credit card.");
return true;
}
}
public class PayPalProcessor : IPaymentProcessor
{
public async Task<bool> ChargeAsync(decimal amount)
{
await Task.Delay(100);
Console.WriteLine($"Charged ${amount} via PayPal.");
return true;
}
}
public class OrderCheckout(IPaymentProcessor paymentProcessor)
{
public async Task<bool> CompleteAsync(decimal total)
{
Console.WriteLine($"Checking out order for ${total}...");
bool success = await paymentProcessor.ChargeAsync(total);
Console.WriteLine(success ? "Checkout complete." : "Checkout failed.");
return success;
}
}
// Usage — the same OrderCheckout class, two different processors
var creditCardCheckout = new OrderCheckout(new CreditCardProcessor());
await creditCardCheckout.CompleteAsync(49.99m);
var payPalCheckout = new OrderCheckout(new PayPalProcessor());
await payPalCheckout.CompleteAsync(49.99m);
Why this works: OrderCheckout's only relationship to payment processing is the abstraction, IPaymentProcessor — it has no idea whether it's holding a CreditCardProcessor, a PayPalProcessor, or a test double. That's dependency inversion in one sentence: high-level code (checkout logic) depends on an abstraction, and low-level code (specific payment providers) implements that same abstraction, rather than the high-level code reaching down and constructing a concrete provider itself.
Challenge 2 — A constrained generic repositoryEasy
Define an IEntity interface with a get-only int Id. Write a generic InMemoryRepository<T>, constrained to types implementing IEntity, with Add, GetById, GetAll, and Remove methods — backed internally by a Dictionary<int, T> keyed on each item's Id.
The generic constraint where T : IEntity is what makes item.Id legal inside the repository without any cast — without it, the compiler only knows T is some type and won't let you call members that aren't guaranteed to exist on every possible T.
public interface IEntity
{
int Id { get; }
}
public class InMemoryRepository<T> where T : IEntity
{
private readonly Dictionary<int, T> _items = [];
public void Add(T item) => _items[item.Id] = item;
public T? GetById(int id) => _items.GetValueOrDefault(id);
public IReadOnlyCollection<T> GetAll() => _items.Values.ToList();
public bool Remove(int id) => _items.Remove(id);
}
public record Product(int Id, string Name, decimal Price) : IEntity;
public record Customer(int Id, string Name, string Email) : IEntity;
// One repository class, reused for two completely unrelated entity types
var products = new InMemoryRepository<Product>();
products.Add(new Product(1, "Keyboard", 49.99m));
var customers = new InMemoryRepository<Customer>();
customers.Add(new Customer(1, "Grace Hopper", "grace@example.com"));
Why this works: this is the entire point of generics — InMemoryRepository<T> is written exactly once, but works correctly and type-safely for Product, Customer, or any future type that implements IEntity, without a single cast or a switch on type. Compare that to writing a separate, near-identical ProductRepository and CustomerRepository class by hand — generics eliminate that duplication while keeping full compile-time type safety.
Challenge 3 — Equality and hashing, by handEasy
A readonly record struct Money(decimal Amount, string Currency) already gets correct value-based equality and hashing for free. Prove you understand why, by writing the equivalent by hand as a plain struct implementing IEquatable<T>, with a properly overridden Equals and GetHashCode.
Implement IEquatable<Money>.Equals(Money other) by comparing every field, then override object.Equals to delegate to it, and override GetHashCode() using HashCode.Combine(...) across the same fields. The golden rule: if two values are Equals, they must return the same GetHashCode() — breaking that silently corrupts Dictionary and HashSet lookups.
// What you'd normally write — the compiler generates all of the below for you:
public readonly record struct Money(decimal Amount, string Currency);
// What that generated code is roughly equivalent to, written by hand:
public readonly struct ManualMoney : IEquatable<ManualMoney>
{
public decimal Amount { get; }
public string Currency { get; }
public ManualMoney(decimal amount, string currency) =>
(Amount, Currency) = (amount, currency);
public bool Equals(ManualMoney other) =>
Amount == other.Amount && Currency == other.Currency;
public override bool Equals(object? obj) =>
obj is ManualMoney other && Equals(other);
public override int GetHashCode() =>
HashCode.Combine(Amount, Currency);
public static bool operator ==(ManualMoney left, ManualMoney right) => left.Equals(right);
public static bool operator !=(ManualMoney left, ManualMoney right) => !left.Equals(right);
}
Why this works: a Dictionary<TKey, TValue> or HashSet<T> uses GetHashCode() first to jump straight to the right internal bucket, then Equals to confirm an exact match within that bucket — it's a two-step process, not one. HashCode.Combine(...) is the standard, well-distributed way to fold several fields into one hash without writing bit-shifting logic yourself. This is exactly the work a record/record struct generates automatically, which is why they're almost always preferable to writing this by hand — but knowing what they generate is what makes the shortcut trustworthy instead of magic.
Challenge 4 — Low-stock alerts with a real eventMedium
Build a Warehouse class tracking stock levels in a Dictionary<string, int>. It should raise a genuine C# event, StockLow, whenever ReduceStock(sku, quantity) brings an item's remaining quantity to 5 or below. Subscribe a simple handler that prints a warning.
This is the standard .NET event pattern from Part III, the same one the Notification Service project used: an EventArgs subclass carrying the data, an event EventHandler<T>? field, and ?.Invoke(this, ...) to raise it safely whether or not anyone has subscribed.
public class StockLowEventArgs(string sku, int remaining) : EventArgs
{
public string Sku { get; } = sku;
public int Remaining { get; } = remaining;
}
public class Warehouse
{
private readonly Dictionary<string, int> _stock = [];
private const int LowStockThreshold = 5;
public event EventHandler<StockLowEventArgs>? StockLow;
public void SetStock(string sku, int quantity) => _stock[sku] = quantity;
public void ReduceStock(string sku, int quantity)
{
if (!_stock.TryGetValue(sku, out int current))
throw new KeyNotFoundException($"Unknown SKU '{sku}'.");
int updated = current - quantity;
_stock[sku] = updated;
if (updated <= LowStockThreshold)
StockLow?.Invoke(this, new StockLowEventArgs(sku, updated));
}
}
// Usage
var warehouse = new Warehouse();
warehouse.SetStock("WIDGET-1", 8);
warehouse.StockLow += (sender, e) =>
Console.WriteLine($" Low stock: {e.Sku} has only {e.Remaining} left.");
warehouse.ReduceStock("WIDGET-1", 4); // 4 remaining — fires StockLow
Why this works: Warehouse never calls the subscriber directly and doesn't know what, if anything, is listening — it just announces "stock got low" via ?.Invoke and moves on. Declaring the field with the event keyword (rather than a plain public delegate field) is what stops outside code from overwriting every other subscriber with = or invoking it directly — only += and -= are allowed from outside the class.
Challenge 5 — Top customers, with LINQ groupingMedium
Given record Order(int CustomerId, string CustomerName, decimal Total) and a List<Order>, write a single LINQ query returning the top 3 customers by total amount spent, as record CustomerSpend(string CustomerName, decimal TotalSpent, int OrderCount), highest spender first.
Group with GroupBy on a composite key (customer id and name together), project each group with Sum and Count, then OrderByDescending and Take(3). Nothing here runs until you materialize it with ToList() — LINQ chains like this are built lazily.
public record Order(int CustomerId, string CustomerName, decimal Total);
public record CustomerSpend(string CustomerName, decimal TotalSpent, int OrderCount);
List<CustomerSpend> topCustomers = orders
.GroupBy(o => new { o.CustomerId, o.CustomerName })
.Select(g => new CustomerSpend(g.Key.CustomerName, g.Sum(o => o.Total), g.Count()))
.OrderByDescending(c => c.TotalSpent)
.Take(3)
.ToList();
Why this works: grouping on an anonymous type made of both CustomerId and CustomerName keeps customers with duplicate names distinct while still surfacing the name in the result — grouping on the id alone would work too, but you'd then need a second lookup to get the name back. Each IGrouping<TKey, Order> that GroupBy produces is itself an IEnumerable<Order>, which is exactly what makes g.Sum(...) and g.Count() legal — they're ordinary LINQ aggregation operators running over each customer's own slice of orders.
Challenge 6 — Your own lazy LINQ operatorMedium
Write your own extension method, IEnumerable<List<T>> Batch<T>(this IEnumerable<T> source, int batchSize), that splits any sequence into chunks of a given size — implemented with yield return so it's lazily evaluated exactly like the built-in LINQ operators are.
Buffer items into a local List<T> as you iterate the source with a plain foreach; the moment the buffer reaches batchSize, yield return it and start a fresh list. Don't forget to yield whatever's left in the buffer after the loop ends, if it's non-empty.
public static class EnumerableExtensions
{
public static IEnumerable<List<T>> Batch<T>(this IEnumerable<T> source, int batchSize)
{
if (batchSize <= 0)
throw new ArgumentOutOfRangeException(nameof(batchSize));
List<T> currentBatch = [];
foreach (var item in source)
{
currentBatch.Add(item);
if (currentBatch.Count == batchSize)
{
yield return currentBatch;
currentBatch = [];
}
}
if (currentBatch.Count > 0)
yield return currentBatch;
}
}
// Usage
foreach (var batch in Enumerable.Range(1, 7).Batch(3))
Console.WriteLine(string.Join(", ", batch));
// 1, 2, 3
// 4, 5, 6
// 7
Why this works: a method using yield return is compiled into a state machine that produces one value at a time, only as the caller actually enumerates it — nothing runs the moment you call Batch(3), only once you start a foreach over the result. That's deferred execution, the exact same mechanism every built-in LINQ operator (Where, Select, and the rest) is built on. .NET actually ships this exact operator built in as Chunk since .NET 6 — writing your own version here is purely so the mechanism underneath is no longer a black box.
Challenge 7 — Configuration through the options patternMedium
Build a TemperatureMonitor service that logs a warning through ILogger<T> whenever CheckTemperature(double celsius) exceeds a threshold read from configuration, using the options pattern — no hardcoded threshold anywhere in TemperatureMonitor itself.
Define a plain options class (e.g. WeatherAlertOptions with a double Threshold property), bind it once with services.Configure<WeatherAlertOptions>(configuration.GetSection("WeatherAlert")), then inject IOptions<WeatherAlertOptions> into the monitor and read .Value.Threshold. Use logger.LogWarning("...{Placeholder}...", value) — structured logging with named placeholders, not string interpolation.
public class WeatherAlertOptions
{
public double Threshold { get; set; } = 35.0;
}
public class TemperatureMonitor(
IOptions<WeatherAlertOptions> options,
ILogger<TemperatureMonitor> logger)
{
public void CheckTemperature(double celsius)
{
double threshold = options.Value.Threshold;
if (celsius >= threshold)
logger.LogWarning("Temperature alert: {Celsius}°C exceeds threshold of {Threshold}°C.", celsius, threshold);
else
logger.LogInformation("Temperature normal: {Celsius}°C.", celsius);
}
}
// Program.cs
builder.Services.Configure<WeatherAlertOptions>(
builder.Configuration.GetSection("WeatherAlert"));
builder.Services.AddSingleton<TemperatureMonitor>();
// appsettings.json
// { "WeatherAlert": { "Threshold": 38.0 } }
Why this works: TemperatureMonitor never touches IConfiguration or a JSON file directly — it depends only on a small, strongly-typed WeatherAlertOptions object handed to it through IOptions<T>, the same "depend on an abstraction, not the source" idea as Challenge 1, applied to configuration instead of a payment gateway. The {Celsius}/{Threshold} placeholders keep the raw values available to structured log sinks (searchable, filterable) rather than baking them irretrievably into a formatted string the way $"..." interpolation would.
Challenge 8 — Fan out, and don't let one failure hide the restMedium
You have three async methods simulating slow calls to different systems: FetchInventoryAsync, FetchPricingAsync, FetchReviewsAsync, each returning Task<string>. Write GetProductSummaryAsync(string productId, CancellationToken ct) that calls all three concurrently and returns the outcome of every one — success or failure — rather than throwing and losing the others the moment the first one fails.
Task.WhenAll on its own only re-throws the first faulted task's exception, hiding what happened to the rest. Wrap each individual call in its own try/catch that converts a thrown exception into a result value instead — then none of the tasks actually faults, and WhenAll always completes with the full list of outcomes.
public record SourceResult(string Source, string? Data, string? Error);
static async Task<List<SourceResult>> GetProductSummaryAsync(string productId, CancellationToken ct)
{
(string Source, Func<CancellationToken, Task<string>> Fetch)[] sources =
[
("Inventory", c => FetchInventoryAsync(productId, c)),
("Pricing", c => FetchPricingAsync(productId, c)),
("Reviews", c => FetchReviewsAsync(productId, c)),
];
var attempts = sources.Select(async source =>
{
try
{
string data = await source.Fetch(ct);
return new SourceResult(source.Source, data, null);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return new SourceResult(source.Source, null, ex.Message);
}
});
SourceResult[] results = await Task.WhenAll(attempts);
return [.. results];
}
Why this works: sources.Select(async source => ...) starts all three calls essentially at once — Select doesn't await anything itself, it just produces three already-running tasks, which is what makes this concurrent rather than sequential. Because each lambda catches its own failure and returns a SourceResult instead of letting the exception propagate, every one of the three tasks that Task.WhenAll is waiting on succeeds from WhenAll's point of view — so it always returns all three outcomes together, whether each individual source succeeded or failed. when (ex is not OperationCanceledException) deliberately lets a genuine cancellation propagate rather than being swallowed and reported as an ordinary "failure."
Challenge 9 — Monthly revenue per customer, computed in the databaseHard
Given an EF Core AppDbContext with DbSet<Order> Orders (each Order has CustomerId, PlacedAt, and Total), write an async method GetMonthlyRevenueByCustomerAsync(int year, int month, CancellationToken ct) returning each customer's total spend for that specific month — computed entirely by the database, not by pulling every order into memory first.
Filter with .Where(o => o.PlacedAt.Year == year && o.PlacedAt.Month == month) — EF Core's LINQ provider translates DateTime.Year/.Month into SQL date functions. Then GroupBy(o => o.CustomerId), project a sum per group, and only call .ToListAsync(ct) at the very end — everything before that stays an unexecuted IQueryable.
public async Task<Dictionary<int, decimal>> GetMonthlyRevenueByCustomerAsync(
int year, int month, CancellationToken ct)
{
var results = await db.Orders
.Where(o => o.PlacedAt.Year == year && o.PlacedAt.Month == month)
.GroupBy(o => o.CustomerId)
.Select(g => new { CustomerId = g.Key, Total = g.Sum(o => o.Total) })
.ToListAsync(ct);
return results.ToDictionary(r => r.CustomerId, r => r.Total);
}
Why this works: the Where, GroupBy, and Sum here never run in your C# process at all — EF Core's LINQ provider translates the whole chain into a single SQL query with a WHERE and a GROUP BY, and only the small, already-aggregated result set crosses back into memory when ToListAsync is awaited. The final .ToDictionary(...) deliberately runs after that — on the small in-memory list — because a Dictionary<TKey, TValue> isn't something SQL or EF Core's query translation understands; converting the final, already-small result is the right place to do it, not the raw order table.
Challenge 10 — A generic, reusable async job queueHard
Generalize the Background Processing Service project's queue-and-worker pattern into a reusable JobQueue<T> class: generic over the item type T, taking a Func<T, CancellationToken, Task> handler delegate at construction (instead of a hardcoded "process an order" method), with EnqueueAsync and a RunAsync loop that processes items one at a time and logs — but never crashes on — a failing item.
This is exactly OrderTaskQueue + OrderProcessingService from the previous project, merged into one class and made generic in two directions at once: generic over the item type T, and parameterized by the processing behavior itself via a Func<T, CancellationToken, Task> passed into the constructor rather than hardcoded as a method.
using System.Threading.Channels;
public class JobQueue<T>(
Func<T, CancellationToken, Task> handler,
ILogger<JobQueue<T>> logger)
{
private readonly Channel<T> _channel = Channel.CreateUnbounded<T>();
public ValueTask EnqueueAsync(T item, CancellationToken ct = default) =>
_channel.Writer.WriteAsync(item, ct);
public async Task RunAsync(CancellationToken ct)
{
try
{
await foreach (var item in _channel.Reader.ReadAllAsync(ct))
{
try
{
await handler(item, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Job handler failed for {Item}.", item);
}
}
}
catch (OperationCanceledException)
{
// expected during graceful shutdown
}
}
}
// Usage — the same worker shape, reused for something that isn't an order at all
var imageJobs = new JobQueue<string>(
async (filePath, ct) =>
{
await Task.Delay(500, ct); // simulated image resize
Console.WriteLine($"Resized {filePath}");
},
loggerFactory.CreateLogger<JobQueue<string>>());
using var cts = new CancellationTokenSource();
_ = imageJobs.RunAsync(cts.Token);
await imageJobs.EnqueueAsync("photo1.jpg");
await imageJobs.EnqueueAsync("photo2.jpg");
Why this works: generics (Part II) supply the reusable shape — the same class works for orders, image file paths, emails, or any other T — while a delegate parameter (Part III) supplies the reusable behavior, so the actual processing logic is injected rather than hardcoded. Underneath, it's the identical async producer/consumer mechanism (Part VII) as OrderProcessingService: a Channel<T> for thread-safe hand-off, await foreach over ReadAllAsync, an inner catch that isolates one bad item, and an outer catch that treats cancellation as a clean, expected shutdown rather than an error. Three separate parts of this tier, one small reusable class.
Challenge 11 — Find the bug: a captive dependencyHard
The following registration and class compile fine, but the app either fails fast at startup or misbehaves badly under concurrent load. Without running it, explain exactly what's wrong.
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite("Data Source=app.db"));
builder.Services.AddSingleton<OrderReportService>();
public class OrderReportService(AppDbContext db)
{
public async Task<int> CountOrdersAsync(CancellationToken ct) =>
await db.Orders.CountAsync(ct);
}
Compare the two service lifetimes from Part V. AddDbContext registers AppDbContext scoped by default — one instance per HTTP request. OrderReportService is registered singleton — one instance for the entire application's lifetime. What happens when a singleton takes a scoped dependency in its constructor?
The bug: this is a captive dependency — a longer-lived service (the singleton OrderReportService) holding a reference to a shorter-lived one (the scoped AppDbContext). With DI's default scope validation enabled (the default for apps built with WebApplication.CreateBuilder in the Development environment), the container detects this mismatch and throws at startup:
System.InvalidOperationException: Cannot consume scoped service
'AppDbContext' from singleton 'OrderReportService'.
If that validation were disabled (as it can be in some hosting configurations), the failure would be worse and quieter: OrderReportService would capture whichever AppDbContext instance existed at the moment it was first constructed and hold onto it forever — a single database connection shared across every future request, for the rest of the app's lifetime. Under concurrent traffic that produces exactly the kind of corruption EF Core's own runtime checks exist to catch: InvalidOperationException: A second operation was started on this context instance before a previous operation completed, because two requests are now sharing one DbContext that was only ever designed to be used by one unit of work at a time.
The fix — option 1, match the lifetimes:
builder.Services.AddScoped<OrderReportService>();
The fix — option 2, if it genuinely needs to stay a singleton: inject IDbContextFactory<AppDbContext> instead of AppDbContext directly, and create a short-lived context per call:
public class OrderReportService(IDbContextFactory<AppDbContext> dbFactory)
{
public async Task<int> CountOrdersAsync(CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Orders.CountAsync(ct);
}
}
Either fix resolves the mismatch — the first by shrinking OrderReportService's lifetime to match its dependency, the second by having the singleton create a fresh, properly-scoped DbContext for each unit of work instead of holding one captive. This is exactly why service lifetimes matter beyond just "which one do I pick" — getting them wrong doesn't just waste memory, it produces bugs that only show up under real concurrent load, long after a quick manual test looked fine.
If you worked through most of these eleven challenges — even the ones that took a few tries, even if a solution taught you something that hadn't quite clicked yet — "C# In Practice" has done its job. You can write your own generic, reusable types instead of duplicating logic per type. You can query and reshape data declaratively instead of writing manual loops for every transformation. You can decouple systems with interfaces, delegates, and events instead of wiring everything together directly. You know how dependency injection, configuration, and logging actually work under an ASP.NET Core app rather than treating them as boilerplate you copy and paste. You can talk to a real database through EF Core, and you can write async code that's actually non-blocking rather than just sprinkling async/await keywords around and hoping.
That's exactly the floor the next tier is built on. "Production .NET" — the Advanced tier — picks up right where this leaves off, and it assumes everything from Parts I–VIII here is solid:
Span<T>, Memory<T>, ArrayPool<T>, and writing genuinely allocation-conscious, benchmarked, profiled code.None of that will feel like starting over. The generic job queue in Challenge 10, the async fan-out in Challenge 8, the DI lifetime bug in Challenge 11 — those aren't warm-up exercises for the Advanced tier, they're its actual raw material. You're not walking into a new subject; you're walking into the next room of a house whose foundation and frame you've now built with your own hands, twice over.
That's the Intermediate tier, complete. Two tiers, thirteen projects, and every challenge along the way built on code you actually wrote yourself — whatever you build next, you're building it on a foundation you tested with your own hands.
dotnetmadeeasy.com — Learn C# and .NET, the right way.