Skip to main content

📝 Lesson 5.3: Capstone Project

This is the finale. You'll build a complete Expense Tracker that uses every major skill from this course — records, generics, LINQ, exceptions, JSON files, async, a live web API, and unit tests — organized into clean, layered code.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Combine the whole course into one cohesive application
  • Organize a program into layers: models, storage, services, and UI
  • Persist data with JSON files and fetch live data from a web API
  • Write unit tests for your core logic
  • Extend a real application with new features on your own

Estimated Time: 120 minutes

Project: A complete, layered Expense Tracker console app.

In This Lesson

What We're Building

An Expense Tracker: a console app where you record expenses, see summaries, save them between runs, and even convert your total into another currency using live exchange rates from the internet.

Every feature exercises skills from this course. Here's the map:

FeatureConcepts used
Expense data modelRecords (3.1)
Save / load expensesFiles (4.1) + JSON (4.2)
Generic file storage helperGenerics (1.2)
Summaries & reportsLINQ (2.3), collections (1.3)
Categorizing / formattingPattern matching (3.2)
Validation & safetyExceptions (1.1), nullable (3.3)
Live currency conversionAsync (4.3) + HttpClient (5.1)
Verifying the logicUnit tests (5.2)

Planning the Architecture

Professional apps aren't one giant file — they're organized into layers, each with one responsibility. This makes code easier to understand, change, and test. Our tracker has four layers:

graph TD UI["UI layer
menu loop & input"] --> SVC["Service layer
logic, LINQ summaries"] SVC --> STORE["Storage layer
JSON file save/load"] SVC --> API["API client
live exchange rates"] SVC --> MODEL["Model
Expense record"] style UI fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style MODEL fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style STORE fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style API fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

The key principle: the UI doesn't touch files or the network directly — it asks the service, which coordinates storage, the API, and the model. That separation is what makes the logic testable in Step 6.

Step 1: The Model

Start with the data. An expense is pure data, so it's a record (Lesson 3.1) — value equality and immutability for free:

// Model/Expense.cs
public record Expense(string Description, string Category, decimal Amount, DateTime Date);

That single line gives us a clean, immutable type with a readable ToString and value equality — perfect for storing and comparing.

Step 2: JSON Storage (Generic)

Next, persistence. We'll write a generic (Lesson 1.2) storage helper that can save and load a list of any type as JSON — reusable well beyond expenses:

// Storage/JsonStore.cs
using System.Text.Json;

public class JsonStore<T>
{
    private readonly string _path;
    private readonly JsonSerializerOptions _options = new() { WriteIndented = true };

    public JsonStore(string path) => _path = path;

    public void Save(List<T> items)
    {
        string json = JsonSerializer.Serialize(items, _options);
        File.WriteAllText(_path, json);
    }

    public List<T> Load()
    {
        if (!File.Exists(_path))
        {
            return new List<T>();          // nothing saved yet — start empty
        }

        try
        {
            string json = File.ReadAllText(_path);
            return JsonSerializer.Deserialize<List<T>>(json) ?? new List<T>();
        }
        catch (JsonException)
        {
            Console.WriteLine("Warning: saved data was corrupt; starting fresh.");
            return new List<T>();
        }
    }
}

✅ Concepts stacked

This one class uses generics (works for any T), files and JSON (4.1/4.2), exception handling (1.1) for corrupt data, and null handling (3.3) with ?? new List<T>(). Because it's generic, you could reuse it to store tasks, contacts, anything.

Step 3: The Service (LINQ & Validation)

The service holds the app's logic: adding validated expenses and producing summaries with LINQ (Lesson 2.3). It keeps the in-memory list and delegates persistence to the store.

// Services/ExpenseService.cs
public class ExpenseService
{
    private readonly JsonStore<Expense> _store;
    private List<Expense> _expenses;

    public ExpenseService(JsonStore<Expense> store)
    {
        _store = store;
        _expenses = _store.Load();          // load saved data on startup
    }

    public IReadOnlyList<Expense> All => _expenses;

    public void Add(string description, string category, decimal amount)
    {
        // Validation — fail fast with clear exceptions (Lesson 1.1)
        if (string.IsNullOrWhiteSpace(description))
            throw new ArgumentException("Description is required.");
        if (amount <= 0)
            throw new ArgumentException("Amount must be positive.");

        _expenses.Add(new Expense(description, category, amount, DateTime.Now));
        _store.Save(_expenses);             // persist after each change
    }

    public decimal Total() => _expenses.Sum(e => e.Amount);

    // Group by category and total each — LINQ (2.3) + collections (1.3)
    public Dictionary<string, decimal> TotalsByCategory() =>
        _expenses
            .GroupBy(e => e.Category)
            .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount));

    public IEnumerable<Expense> TopExpenses(int count) =>
        _expenses.OrderByDescending(e => e.Amount).Take(count);
}

And a touch of pattern matching (Lesson 3.2) to label spending levels for display:

// A pure helper — easy to unit test in Step 6
public static string SpendingLevel(decimal total) => total switch
{
    <= 0        => "none",
    < 100m      => "low",
    < 1000m     => "moderate",
    _           => "high"
};

Step 4: Live Currency (Async API)

Now the online feature: convert the total into another currency using live rates, with async + HttpClient + JSON (Lessons 4.3, 5.1, 4.2):

// Services/CurrencyClient.cs
using System.Net.Http.Json;

// Model the shape of the API's JSON response
public record RatesResponse(string Base_code, Dictionary<string, decimal> Rates);

public class CurrencyClient
{
    private readonly HttpClient _client = new();   // one shared client (Lesson 5.1)

    public async Task<decimal> ConvertAsync(decimal amountUsd, string toCurrency)
    {
        var data = await _client.GetFromJsonAsync<RatesResponse>(
            "https://open.er-api.com/v6/latest/USD");

        if (data is null || !data.Rates.TryGetValue(toCurrency, out decimal rate))
        {
            throw new InvalidOperationException($"No rate found for {toCurrency}.");
        }
        return amountUsd * rate;
    }
}

💡 Notice the safety

This method guards a possibly-null response (3.3), uses TryGetValue on a dictionary (1.3), and throws a clear exception (1.1) when a currency is unknown — then the UI catches network errors around the call. Robustness, layered in.

Step 5: The Menu

Finally the UI ties it together with a menu loop (async so it can call the API). It only talks to the service and client — never files or the network directly:

// Program.cs
var store = new JsonStore<Expense>("expenses.json");
var service = new ExpenseService(store);
var currency = new CurrencyClient();

bool running = true;
while (running)
{
    Console.WriteLine("\n===== EXPENSE TRACKER =====");
    Console.WriteLine("1. Add expense");
    Console.WriteLine("2. Show summary");
    Console.WriteLine("3. Convert total to EUR (live)");
    Console.WriteLine("4. Quit");
    Console.Write("Choose: ");

    switch (Console.ReadLine())
    {
        case "1":
            try
            {
                Console.Write("Description: ");
                string desc = Console.ReadLine() ?? "";
                Console.Write("Category: ");
                string cat = Console.ReadLine() ?? "Other";
                Console.Write("Amount: ");
                decimal amount = decimal.Parse(Console.ReadLine() ?? "0");

                service.Add(desc, cat, amount);
                Console.WriteLine("Added!");
            }
            catch (ArgumentException ex) { Console.WriteLine($"Invalid: {ex.Message}"); }
            catch (FormatException)      { Console.WriteLine("Amount must be a number."); }
            break;

        case "2":
            Console.WriteLine($"Total: {service.Total():C} " +
                              $"({ExpenseService.SpendingLevel(service.Total())})");
            foreach (var (category, sum) in service.TotalsByCategory())
            {
                Console.WriteLine($"  {category}: {sum:C}");
            }
            break;

        case "3":
            try
            {
                decimal eur = await currency.ConvertAsync(service.Total(), "EUR");
                Console.WriteLine($"Total in EUR: {eur:F2}");
            }
            catch (HttpRequestException) { Console.WriteLine("Couldn't reach the rate service."); }
            catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }
            break;

        case "4":
            running = false;
            break;

        default:
            Console.WriteLine("Please choose 1-4.");
            break;
    }
}

✅ Look at everything working together

A menu loop routes with a switch; input is validated and parsed with exception handling; summaries come from LINQ via the service; deconstruction (var (category, sum)) unpacks dictionary entries; and option 3 awaits a live web API. Data persists automatically because the service saves after every change.

Step 6: Testing

Because the logic lives in the service (not tangled into the UI), it's straightforward to test with xUnit (Lesson 5.2). We test the pure, deterministic parts — validation and calculations:

// Tests/ExpenseServiceTests.cs
using Xunit;

public class ExpenseServiceTests
{
    private ExpenseService NewService() =>
        new ExpenseService(new JsonStore<Expense>(
            Path.GetTempFileName()));   // temp file so tests don't clash

    [Fact]
    public void Add_ValidExpense_IncreasesTotal()
    {
        var service = NewService();
        service.Add("Coffee", "Food", 4.50m);
        Assert.Equal(4.50m, service.Total());
    }

    [Fact]
    public void Add_NegativeAmount_Throws()
    {
        var service = NewService();
        Assert.Throws<ArgumentException>(() => service.Add("Bad", "X", -5m));
    }

    [Theory]
    [InlineData(0, "none")]
    [InlineData(50, "low")]
    [InlineData(500, "moderate")]
    [InlineData(5000, "high")]
    public void SpendingLevel_Categorizes(decimal total, string expected)
    {
        Assert.Equal(expected, ExpenseService.SpendingLevel(total));
    }
}

💡 Why the design paid off

We can test SpendingLevel and Add without any UI, files we care about, or network — because the logic is separated from those concerns. That's the reward for layering the app: the important behavior is easy to verify. (Testing the live API call would use more advanced techniques like mocking, a great next topic.)

Make It Your Own

The tracker is a foundation. Extend it — each challenge reinforces skills from the course.

🏋️ Extension Challenges

  1. Delete an expense (⭐): Add a menu option that lists expenses with numbers and removes one by index (safe index checking, Lesson 1.3/1.1).
  2. Filter by category (⭐): Add a report that prompts for a category and uses LINQ Where to show only those expenses (2.3).
  3. This month only (⭐⭐): A summary that filters expenses to the current month using the Date field and LINQ.
  4. Choose the target currency (⭐⭐): Let the user type any currency code and pass it to ConvertAsync; handle "unknown currency" gracefully (3.2 pattern matching for messages).
  5. More tests (⭐⭐): Add tests for TotalsByCategory and TopExpenses, including edge cases (empty list).
  6. Budgets & events (⭐⭐⭐): Add a monthly budget and raise an event (Lesson 2.2) when spending exceeds it; have the UI subscribe and warn.

✅ This is how real software grows

A clean, layered design makes each of these additions a small, local change — not a risky rewrite. That is the whole payoff of the practices in this course.

Course Wrap-Up

🎉 What this capstone demonstrates

In one application you combined:

  • Records for the data model, generics for reusable storage
  • Exceptions and nullable handling for robustness
  • LINQ and collections for summaries and reports
  • Pattern matching for concise categorization
  • Files + JSON for persistence, async + HttpClient for live data
  • Unit tests to prove the logic is correct

🎓 Look how far you've come

You began this course able to write basic C# programs with classes and OOP. You can now:

  • Write robust code with exceptions, generics, and the right collections
  • Program in a functional style with delegates, events, and LINQ
  • Use modern features — records, pattern matching, and nullable reference types
  • Work with files, JSON, and asynchronous operations
  • Call web APIs and write unit tests
  • Structure a real, layered application and grow it safely

🚀 Where to Go Next

  • ASP.NET Core: build web APIs and web apps — a natural home for these skills
  • Entity Framework Core: work with databases using C# objects
  • Dependency injection & IHttpClientFactory: structure larger applications
  • Advanced testing: mocking, integration tests, and test-driven development
  • Performance: spans, memory, and profiling for high-performance C#

📚 Recommended Resources

🎓 Congratulations — you've completed Intermediate C#!

You now write robust, modern, well-tested C# and can structure real applications. You have the foundation to build almost anything on the .NET platform. Keep building, keep testing, and keep learning. 🚀