Seven parts, dozens of lessons, two full projects — this is where you find out how much of it actually stuck.
You started this tier not knowing what a variable was. You're finishing it having built a working console app with file persistence and another with a real class hierarchy handling polymorphic payroll calculations. That's not a small distance to travel, and it deserves a real test — not a quiz you can skim through by pattern-matching keywords, but a set of genuine coding challenges that make you actually write C#, in increasing difficulty, pulling from everything across Parts I through VII.
Each challenge below gives you a problem, a hint if you want a nudge, and a complete worked solution with explanation if you want to check your work or see it done. Try to solve each one yourself first — actually write the code, run it, watch it work — before opening the solution. That struggle is where the learning happens; reading a solution without attempting it first teaches you far less than five honest minutes of getting it wrong.
Before diving into the challenges, take a moment to see the shape of everything you've covered. Each of these seven parts built directly on the ones before it:
List<T>, Dictionary<TKey,TValue>, and iterating and manipulating groups of data.try/catch/finally, custom exceptions, reading and writing files, working with DateTime.switch expressions, nullable reference types, and current idiomatic syntax.async/await, no dependency injection, no generics beyond List<T> and Dictionary<TKey,TValue>. If a challenge feels hard, the tools to solve it are already fully in your hands — that's the entire point of a capstone.
Nine challenges, roughly in order of increasing difficulty. Attempt each one before opening its solution.
Challenge 1 — FizzBuzz, with a twistEasy
Write a method PrintFizzBuzz(int max) that prints the numbers from 1 to max. For multiples of 3, print "Fizz" instead of the number. For multiples of 5, print "Buzz". For multiples of both 3 and 5, print "FizzBuzz". Use a switch expression rather than a chain of if/else if.
Compute both n % 3 == 0 and n % 5 == 0 as a tuple (n % 3, n % 5), then switch on that tuple using pattern matching — check the "both zero" case before checking either one alone.
static void PrintFizzBuzz(int max)
{
for (int n = 1; n <= max; n++)
{
string result = (n % 3, n % 5) switch
{
(0, 0) => "FizzBuzz",
(0, _) => "Fizz",
(_, 0) => "Buzz",
_ => n.ToString()
};
Console.WriteLine(result);
}
}
Why this works: the tuple pattern (0, 0) must be checked before (0, _) and (_, 0) — switch expressions test patterns top-to-bottom and use the first match, so ordering the "both" case first is what makes 15, 30, 45... correctly produce "FizzBuzz" instead of stopping at "Fizz". The loop bound is n <= max since we genuinely want to include max itself here, unlike an index-based loop over a collection.
Challenge 2 — Palindrome checkerEasy
Write a method bool IsPalindrome(string text) that returns true if text reads the same forwards and backwards, ignoring case and spaces (so "Race car" should count as a palindrome).
First, build a "cleaned" version of the string containing only letters, all lowercase (loop through each character, check char.IsLetter, and append the lowercase version to a new string or a StringBuilder). Then compare that cleaned string to its own reversed form.
static bool IsPalindrome(string text)
{
string cleaned = "";
foreach (char c in text)
{
if (char.IsLetter(c))
cleaned += char.ToLower(c);
}
int left = 0;
int right = cleaned.Length - 1;
while (left < right)
{
if (cleaned[left] != cleaned[right])
return false;
left++;
right--;
}
return true;
}
Why this works: instead of building a fully reversed string and comparing it, this uses two pointers — one starting at the front, one at the back — walking toward the middle. The moment any pair of mirrored characters doesn't match, it returns false immediately without checking the rest. If the pointers meet or cross without a mismatch, it's a palindrome. Note the careful loop bound left < right — not <= — since once they meet or pass each other, every pair has already been checked.
Challenge 3 — A validated BankAccount classEasy
Design a BankAccount class with a private balance field, a public read-only Balance property, and Deposit(decimal amount) / Withdraw(decimal amount) methods. Both methods should reject invalid amounts (zero, negative, or a withdrawal larger than the current balance) by throwing appropriate exceptions — never let the balance go negative or accept a non-positive deposit.
This is pure encapsulation: keep the field private (e.g., _balance), expose it only through a get-only Balance property, and put every validation check as a guard clause at the top of Deposit and Withdraw before touching the field.
public class BankAccount
{
private decimal _balance;
public decimal Balance => _balance;
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Deposit amount must be positive.", nameof(amount));
_balance += amount;
}
public void Withdraw(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Withdrawal amount must be positive.", nameof(amount));
if (amount > _balance)
throw new InvalidOperationException("Insufficient funds.");
_balance -= amount;
}
}
Why this works: _balance can never be modified from outside the class — the only doors in are Deposit and Withdraw, and both refuse to let the account enter an invalid state. Using ArgumentException for bad input and InvalidOperationException for a valid-but-currently-impossible operation (overdrawing) follows the standard .NET convention of choosing an exception type that describes why the operation failed.
Challenge 4 — Polymorphic shape hierarchyMedium
Design an abstract Shape class with an abstract Area() method. Create Circle and Rectangle subclasses. Then write a method decimal TotalArea(List<Shape> shapes) that sums the area of every shape in the list — without checking each shape's concrete type.
This is the same pattern as the Employee System project: a List<Shape> can hold a mix of concrete subclasses, and calling the abstract method through a foreach loop automatically dispatches to each object's own override.
public abstract class Shape
{
public abstract decimal Area();
}
public class Circle : Shape
{
public decimal Radius { get; }
public Circle(decimal radius) => Radius = radius;
public override decimal Area() => 3.14159m * Radius * Radius;
}
public class Rectangle : Shape
{
public decimal Width { get; }
public decimal Height { get; }
public Rectangle(decimal width, decimal height) => (Width, Height) = (width, height);
public override decimal Area() => Width * Height;
}
static decimal TotalArea(List<Shape> shapes)
{
decimal total = 0;
foreach (var shape in shapes)
total += shape.Area(); // polymorphism — correct override runs per shape
return total;
}
Why this works: TotalArea never asks "is this a Circle or a Rectangle?" — it just calls Area() and trusts virtual dispatch to run the correct implementation for whatever concrete object is actually there. This is exactly why Area() was declared abstract on Shape: it guarantees every subclass must supply one, so this loop can never accidentally call a missing implementation.
Challenge 5 — Inventory system with safe removalMedium
You have a List<string> inventory of item names. Write a method RemoveExpiredItems(List<string> inventory, List<string> expiredNames) that removes every item whose name appears in expiredNames, without throwing an InvalidOperationException.
This is a direct callback to the "mutating a collection while iterating" mistake — you cannot safely call inventory.Remove(...) from inside a foreach (var item in inventory). Loop over a copy of the list instead, or loop backward by index.
static void RemoveExpiredItems(List<string> inventory, List<string> expiredNames)
{
// Loop over a snapshot copy, remove from the real list
foreach (var item in inventory.ToList())
{
if (expiredNames.Contains(item))
inventory.Remove(item);
}
}
Why this works: inventory.ToList() creates an independent copy of the list at that moment, so the foreach is iterating over the copy — which never changes — while inventory.Remove(item) safely modifies the real, original list. No enumerator ever sees a collection change mid-iteration, so no exception is thrown.
Challenge 6 — Parsing with a custom exceptionMedium
Write a method int ParsePositiveInteger(string input) that parses input as an integer and returns it — but throws a custom exception, InvalidPositiveIntegerException, if the text isn't a valid integer, or if it parses to zero or a negative number. The exception's message should clearly explain which of the two problems occurred.
Define public class InvalidPositiveIntegerException : Exception with a constructor that just forwards a message to the base Exception(string message) constructor. Use int.TryParse first to catch non-numeric text before separately checking the sign of a successfully parsed number.
public class InvalidPositiveIntegerException : Exception
{
public InvalidPositiveIntegerException(string message) : base(message) { }
}
static int ParsePositiveInteger(string input)
{
if (!int.TryParse(input, out int value))
throw new InvalidPositiveIntegerException($"'{input}' is not a valid whole number.");
if (value <= 0)
throw new InvalidPositiveIntegerException($"{value} is not a positive number.");
return value;
}
// Example usage:
try
{
int quantity = ParsePositiveInteger(Console.ReadLine() ?? "");
Console.WriteLine($"Quantity accepted: {quantity}");
}
catch (InvalidPositiveIntegerException ex)
{
Console.WriteLine($"Invalid input: {ex.Message}");
}
Why this works: a custom exception type lets the caller catch exactly this failure mode specifically (via catch (InvalidPositiveIntegerException ex)) without accidentally swallowing unrelated exceptions the way a broad catch (Exception ex) would. Checking TryParse first, separately from the sign check, means the exception message is genuinely specific to which rule was actually broken.
Challenge 7 — Save and reload a list of recordsMedium
Define a record Book(string Title, string Author, int Year). Write two methods: SaveBooks(List<Book> books, string path) and List<Book> LoadBooks(string path), using System.Text.Json. LoadBooks should return an empty list (not throw) if the file doesn't exist yet.
This mirrors exactly what SaveToFile/LoadFromFile did in the Expense Tracker project. Check File.Exists at the very top of LoadBooks before attempting to read anything.
using System.Text.Json;
public record Book(string Title, string Author, int Year);
static void SaveBooks(List<Book> books, string path)
{
var options = new JsonSerializerOptions { WriteIndented = true };
string json = JsonSerializer.Serialize(books, options);
File.WriteAllText(path, json);
}
static List<Book> LoadBooks(string path)
{
if (!File.Exists(path))
return new List<Book>();
string json = File.ReadAllText(path);
var books = JsonSerializer.Deserialize<List<Book>>(json);
return books ?? new List<Book>();
}
Why this works: Book being a record means System.Text.Json can serialize and deserialize it automatically, since records expose their data through ordinary public properties under the hood. The File.Exists check before reading avoids a FileNotFoundException entirely on a fresh run, and books ?? new List<Book>() guards against Deserialize returning null (which can happen if the file contains the literal text "null").
Challenge 8 — Find the bugHard
The following code throws an exception. Without running it, read it carefully and identify exactly what's wrong and why — then state what the stack trace would tell you if you ran it.
public class Inventory
{
private List<string> _items = new() { "Hammer", "Wrench", "Drill" };
public string GetItemAt(int position)
{
return _items[position];
}
}
class Program
{
static void Main()
{
var inventory = new Inventory();
for (int i = 1; i <= 3; i++)
{
Console.WriteLine(inventory.GetItemAt(i));
}
}
}
Count the items in _items, then count exactly which values i takes in the loop. Compare that against valid list indices.
The bug: _items has 3 elements, so its valid indices are 0, 1, and 2. The loop starts at i = 1 and runs through i = 3 (inclusive, because of i <= 3) — a double off-by-one mistake. It skips index 0 ("Hammer" is never printed) and reaches for index 3, which doesn't exist.
The stack trace would look approximately like this:
Unhandled exception. System.ArgumentOutOfRangeException: Index was out of range.
at System.Collections.Generic.List`1.get_Item(Int32 index)
at Inventory.GetItemAt(Int32 position) in Program.cs:line 6
at Program.Main() in Program.cs:line 16
Reading top-down: the exception is thrown inside the framework's own List<T> indexer, called from GetItemAt at line 6 (the return _items[position]; line), called from Main at line 16 (inside the loop). This confirms exactly what the manual read-through found.
The fix: the loop should run for (int i = 0; i < 3; i++) — or better yet, avoid hardcoding the count entirely and use i < inventory.Count style logic if Inventory exposed a count, or simplest of all, have Inventory expose a way to enumerate its items directly with foreach.
Challenge 9 — Mini Library System (combines everything)Hard
Build a small library system: an abstract LibraryItem class (with Title and an abstract GetLoanPeriodDays()), two subclasses Book (14-day loan) and DVD (7-day loan), and a Library class holding a List<LibraryItem> with a method CheckOut(string title) that finds the item by title (throwing a custom ItemNotFoundException if it's missing) and returns its due date as DateTime.Today plus its loan period.
This combines Challenge 4's polymorphism (an abstract method that varies by subclass), Challenge 6's custom exception pattern, and a plain foreach search loop like the ones in both projects. Search for the match with a loop and an if, don't use LINQ's FirstOrDefault.
public class ItemNotFoundException : Exception
{
public ItemNotFoundException(string title)
: base($"No library item found with title '{title}'.") { }
}
public abstract class LibraryItem
{
public string Title { get; }
protected LibraryItem(string title) => Title = title;
public abstract int GetLoanPeriodDays();
}
public class Book : LibraryItem
{
public Book(string title) : base(title) { }
public override int GetLoanPeriodDays() => 14;
}
public class DVD : LibraryItem
{
public DVD(string title) : base(title) { }
public override int GetLoanPeriodDays() => 7;
}
public class Library
{
private readonly List<LibraryItem> _items = new();
public void AddItem(LibraryItem item) => _items.Add(item);
public DateTime CheckOut(string title)
{
LibraryItem? found = null;
foreach (var item in _items)
{
if (item.Title == title)
{
found = item;
break;
}
}
if (found is null)
throw new ItemNotFoundException(title);
return DateTime.Today.AddDays(found.GetLoanPeriodDays());
}
}
// Example usage:
var library = new Library();
library.AddItem(new Book("Clean Code"));
library.AddItem(new DVD("The Matrix"));
try
{
DateTime dueDate = library.CheckOut("Clean Code");
Console.WriteLine($"Due back: {dueDate:yyyy-MM-dd}"); // 14 days from today
library.CheckOut("A Missing Title"); // throws
}
catch (ItemNotFoundException ex)
{
Console.WriteLine($"Checkout failed: {ex.Message}");
}
Why this works: found is declared as a nullable LibraryItem?, since it's entirely possible no match exists — the loop uses break to stop scanning the instant a match is found (no point checking the rest), and the is null check afterward decides whether to throw. GetLoanPeriodDays() is where polymorphism does the real work: Library.CheckOut never needs to know or care whether it's holding a Book or a DVD — it just asks the item for its own loan period and trusts the correct override to run.
If you worked through most of these challenges — even if you needed a hint here and there, even if a solution taught you something you hadn't quite locked in yet — the Foundations tier has done its job. You can model a real domain with classes and interfaces, manage collections of data safely, handle failure without crashing, and read your way through a stack trace instead of panicking at one.
That's exactly the floor the next tier is built on. "C# In Practice" — the Intermediate tier — picks up right where this leaves off, and everything in it assumes the fundamentals from Parts I–VII are solid:
foreach loops you've been writing.List<T> and Dictionary<TKey,TValue> you've been using — writing your own reusable, type-safe generic classes and methods.async/await — writing responsive code that waits on slow operations (network calls, file access) without blocking your entire program.None of that will feel like starting over — it'll feel like the next room in a house whose foundation you already poured yourself.
That's the Foundations tier, complete. Whatever you build next, you're building it on a foundation you tested yourself, one challenge at a time.
dotnetmadeeasy.com — Learn C# and .NET, the right way.