A real console app, built end to end — classes, collections, file storage, and exception handling working together, not as isolated exercises.
Every lesson so far has taught you one idea at a time — a class here, a List<T> there, a try/catch in its own little example. Real programs don't work that way. A real console app needs a model to represent its data, a collection to hold many of them, a way to save and reload that data between runs, and code that gracefully survives bad input instead of crashing.
This project puts all of it together: you'll build a working Expense Tracker — a console app that lets someone log day-to-day expenses, see totals, and persist everything to a file so it's still there the next time they run the program. Nothing here uses anything beyond what you've already learned in Parts I through VI: classes, records, collections, exception handling, and modern C# syntax. No LINQ, no async, no generics beyond List<T> — just the fundamentals, put to real use.
Read through each step, but don't just copy-paste — type it out, run it after every step, and watch it grow. By the end you'll have a complete, working program, and a template for how every real .NET console app is put together.
Build a console application that lets a user track their personal expenses. The app should run as a simple text menu, loop until the user chooses to exit, and remember its data between runs by saving to a file on disk.
List<T>, foreach, try/catch, string interpolation, pattern matching, and basic file I/O. That's deliberate: a capstone project should prove the fundamentals are solid, not introduce new ones.
Before writing any menu logic, decide what a single expense actually is. An expense needs an identifier (so it can be found and referenced later), a description, an amount, a category, and a date. Since an individual expense, once recorded, generally shouldn't change its own identity — you'd create a new entry rather than mutate history — a record is a great fit here: it gives us value-based equality and a clean, concise declaration for free.
public record Expense(int Id, string Description, decimal Amount, string Category, DateTime Date);
A single line, using a primary constructor, gives us four properties (Id, Description, Amount, Category, Date) with no boilerplate. Two expenses with identical values will compare as equal with ==, and records print nicely by default when you call ToString() — both useful during development.
Next, we need something to hold a collection of expenses and provide the operations the brief asks for — adding, listing, totaling, saving, loading. That's a job for a regular class, since it represents a mutable, stateful service, not an immutable value:
public class ExpenseTracker
{
private readonly List<Expense> _expenses = new();
private int _nextId = 1;
// methods will go here: AddExpense, ListExpenses,
// GetTotal, GetTotalsByCategory, SaveToFile, LoadFromFile
}
Notice the naming conventions from earlier in this Part already at work: _expenses and _nextId are private fields (underscore-prefixed camelCase), while ExpenseTracker and Expense are PascalCase types.
public Expense AddExpense(string description, decimal amount, string category, DateTime date)
{
if (string.IsNullOrWhiteSpace(description))
throw new ArgumentException("Description cannot be empty.", nameof(description));
if (amount <= 0)
throw new ArgumentException("Amount must be greater than zero.", nameof(amount));
var expense = new Expense(_nextId, description, amount, category, date);
_expenses.Add(expense);
_nextId++;
return expense;
}
Guard clauses at the top validate the input before doing anything else — a clean-code habit from earlier in this Part. The tracker owns assigning IDs, so callers never need to invent one themselves.
public IReadOnlyList<Expense> ListExpenses() => _expenses;
Exposing IReadOnlyList<Expense> instead of the internal List<Expense> directly means callers can look but not accidentally add or remove items behind the tracker's back — a small but important encapsulation habit.
public decimal GetTotal()
{
decimal total = 0;
foreach (var expense in _expenses)
total += expense.Amount;
return total;
}
public Dictionary<string, decimal> GetTotalsByCategory()
{
var totals = new Dictionary<string, decimal>();
foreach (var expense in _expenses)
{
if (totals.ContainsKey(expense.Category))
totals[expense.Category] += expense.Amount;
else
totals[expense.Category] = expense.Amount;
}
return totals;
}
Both methods use a plain foreach loop and running accumulators — exactly the collection-handling patterns from earlier in this tier, just applied to a real reporting need. The category totals use a Dictionary<string, decimal>, checking ContainsKey before deciding whether to start a new running total or add to an existing one.
System.Text.Jsonusing System.Text.Json;
public void SaveToFile(string path)
{
try
{
var options = new JsonSerializerOptions { WriteIndented = true };
string json = JsonSerializer.Serialize(_expenses, options);
File.WriteAllText(path, json);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Console.WriteLine($"Could not save expenses: {ex.Message}");
}
}
JsonSerializer.Serialize turns the in-memory list of Expense records straight into readable JSON text; File.WriteAllText writes it to disk. The catch clause uses an exception filter (when ex is IOException or UnauthorizedAccessException) to only handle the specific, expected failure modes of writing a file — a disk that's full, or a path we don't have permission to write to — rather than swallowing every possible exception.
public void LoadFromFile(string path)
{
if (!File.Exists(path))
return; // nothing to load yet — that's fine on first run
try
{
string json = File.ReadAllText(path);
var loaded = JsonSerializer.Deserialize<List<Expense>>(json);
if (loaded is null || loaded.Count == 0)
return;
_expenses.Clear();
_expenses.AddRange(loaded);
_nextId = 1;
foreach (var expense in _expenses)
{
if (expense.Id >= _nextId)
_nextId = expense.Id + 1;
}
}
catch (JsonException ex)
{
Console.WriteLine($"Saved expense file is corrupted and could not be read: {ex.Message}");
}
}
Checking File.Exists first means a brand-new user (with no saved file yet) doesn't get an error on their very first run. Catching JsonException specifically handles the case where the file exists but its contents are malformed — instead of crashing, the app reports the problem and starts with an empty list. Finally, _nextId is recalculated from the loaded data, so new expenses added after loading don't collide with existing IDs.
Program.csconst string DataFile = "expenses.json";
var tracker = new ExpenseTracker();
tracker.LoadFromFile(DataFile);
bool running = true;
while (running)
{
PrintMenu();
string? choice = Console.ReadLine();
switch (choice)
{
case "1": AddExpenseFlow(tracker); break;
case "2": ListExpensesFlow(tracker); break;
case "3": ShowTotalFlow(tracker); break;
case "4": ShowCategoryTotalsFlow(tracker); break;
case "5":
tracker.SaveToFile(DataFile);
running = false;
Console.WriteLine("Saved. Goodbye!");
break;
default:
Console.WriteLine("Not a valid option, try again.");
break;
}
}
This is the same menu-loop pattern you've likely already seen in earlier console app lessons: load once at startup, loop reading a choice, dispatch with a switch, save on exit. Everything else in the app is a supporting method called from here.
static void AddExpenseFlow(ExpenseTracker tracker)
{
Console.Write("Description: ");
string description = Console.ReadLine() ?? "";
Console.Write("Amount: ");
string? amountInput = Console.ReadLine();
if (!decimal.TryParse(amountInput, out decimal amount))
{
Console.WriteLine("That doesn't look like a valid amount. Expense not added.");
return;
}
Console.Write("Category: ");
string category = Console.ReadLine() ?? "Uncategorized";
try
{
var expense = tracker.AddExpense(description, amount, category, DateTime.Today);
Console.WriteLine($"Added: {expense.Description} — {expense.Amount:C} ({expense.Category})");
}
catch (ArgumentException ex)
{
Console.WriteLine($"Could not add expense: {ex.Message}");
}
}
Two layers of protection here: decimal.TryParse handles the case where the user types something that isn't a number at all (no exception thrown, just a clean false return), and the try/catch around AddExpense catches the validation errors the tracker itself raises (like an empty description). Neither bad input crashes the app — the user just gets a clear message and returns to the menu.
Here's the full program, combined. In a real project you'd typically split Expense.cs, ExpenseTracker.cs, and Program.cs into separate files, but everything is shown together here so you can read the whole thing top to bottom.
using System.Text.Json;
// ── Model ──
public record Expense(int Id, string Description, decimal Amount, string Category, DateTime Date);
// ── Service ──
public class ExpenseTracker
{
private readonly List<Expense> _expenses = new();
private int _nextId = 1;
public Expense AddExpense(string description, decimal amount, string category, DateTime date)
{
if (string.IsNullOrWhiteSpace(description))
throw new ArgumentException("Description cannot be empty.", nameof(description));
if (amount <= 0)
throw new ArgumentException("Amount must be greater than zero.", nameof(amount));
var expense = new Expense(_nextId, description, amount,
string.IsNullOrWhiteSpace(category) ? "Uncategorized" : category, date);
_expenses.Add(expense);
_nextId++;
return expense;
}
public IReadOnlyList<Expense> ListExpenses() => _expenses;
public decimal GetTotal()
{
decimal total = 0;
foreach (var expense in _expenses)
total += expense.Amount;
return total;
}
public Dictionary<string, decimal> GetTotalsByCategory()
{
var totals = new Dictionary<string, decimal>();
foreach (var expense in _expenses)
{
if (totals.ContainsKey(expense.Category))
totals[expense.Category] += expense.Amount;
else
totals[expense.Category] = expense.Amount;
}
return totals;
}
public void SaveToFile(string path)
{
try
{
var options = new JsonSerializerOptions { WriteIndented = true };
string json = JsonSerializer.Serialize(_expenses, options);
File.WriteAllText(path, json);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Console.WriteLine($"Could not save expenses: {ex.Message}");
}
}
public void LoadFromFile(string path)
{
if (!File.Exists(path)) return;
try
{
string json = File.ReadAllText(path);
var loaded = JsonSerializer.Deserialize<List<Expense>>(json);
if (loaded is null || loaded.Count == 0) return;
_expenses.Clear();
_expenses.AddRange(loaded);
_nextId = 1;
foreach (var expense in _expenses)
if (expense.Id >= _nextId)
_nextId = expense.Id + 1;
}
catch (JsonException ex)
{
Console.WriteLine($"Saved expense file is corrupted and could not be read: {ex.Message}");
}
}
}
// ── Program ──
class Program
{
const string DataFile = "expenses.json";
static void Main()
{
var tracker = new ExpenseTracker();
tracker.LoadFromFile(DataFile);
bool running = true;
while (running)
{
PrintMenu();
switch (Console.ReadLine())
{
case "1": AddExpenseFlow(tracker); break;
case "2": ListExpensesFlow(tracker); break;
case "3": ShowTotalFlow(tracker); break;
case "4": ShowCategoryTotalsFlow(tracker); break;
case "5":
tracker.SaveToFile(DataFile);
running = false;
Console.WriteLine("Saved. Goodbye!");
break;
default:
Console.WriteLine("Not a valid option, try again.");
break;
}
}
}
static void PrintMenu()
{
Console.WriteLine();
Console.WriteLine("=== Expense Tracker ===");
Console.WriteLine("1. Add expense");
Console.WriteLine("2. List expenses");
Console.WriteLine("3. Show total");
Console.WriteLine("4. Show totals by category");
Console.WriteLine("5. Save and exit");
Console.Write("Choose an option: ");
}
static void AddExpenseFlow(ExpenseTracker tracker)
{
Console.Write("Description: ");
string description = Console.ReadLine() ?? "";
Console.Write("Amount: ");
if (!decimal.TryParse(Console.ReadLine(), out decimal amount))
{
Console.WriteLine("That doesn't look like a valid amount. Expense not added.");
return;
}
Console.Write("Category: ");
string category = Console.ReadLine() ?? "Uncategorized";
try
{
var expense = tracker.AddExpense(description, amount, category, DateTime.Today);
Console.WriteLine($"Added: {expense.Description} — {expense.Amount:C} ({expense.Category})");
}
catch (ArgumentException ex)
{
Console.WriteLine($"Could not add expense: {ex.Message}");
}
}
static void ListExpensesFlow(ExpenseTracker tracker)
{
var expenses = tracker.ListExpenses();
if (expenses.Count == 0)
{
Console.WriteLine("No expenses recorded yet.");
return;
}
foreach (var expense in expenses)
{
Console.WriteLine(
$"#{expense.Id,-3} {expense.Date:yyyy-MM-dd} {expense.Category,-15} {expense.Amount,10:C} {expense.Description}");
}
}
static void ShowTotalFlow(ExpenseTracker tracker) =>
Console.WriteLine($"Total spent: {tracker.GetTotal():C}");
static void ShowCategoryTotalsFlow(ExpenseTracker tracker)
{
var totals = tracker.GetTotalsByCategory();
if (totals.Count == 0)
{
Console.WriteLine("No expenses recorded yet.");
return;
}
foreach (var pair in totals)
Console.WriteLine($"{pair.Key,-15} {pair.Value,10:C}");
}
}
Run it, add a few expenses, exit, and re-run — the expenses will still be there, loaded straight from expenses.json in the working directory. That's the whole point of this project: none of these individual pieces are new, but seeing them cooperate — model, service, persistence, and a resilient console UI — is what turns isolated lessons into a real, working program.
The tracker above satisfies the brief, but a real app always has room to grow. Try extending it yourself — each challenge below reuses only concepts from Parts I–VI, with a hint if you get stuck.
Challenge 1 — Delete an expense by Id. Add a menu option that removes a specific expense given its ID.
You can't safely Remove from inside a foreach that's still scanning for a match and removing in the same pass if you're not careful (recall the "mutating a collection while iterating" mistake from the previous lesson!). Instead, find the matching Expense first with a simple loop, store it in a variable, and call _expenses.Remove(match) after the loop ends — or use _expenses.RemoveAll(e => e.Id == id) if you're comfortable with that method.
Challenge 2 — Edit an existing expense. Since Expense is an immutable record, editing means creating a replacement with the same Id.
Records support nondestructive mutation with the with expression: var updated = original with { Amount = 42.00m };. Find the existing expense's position in the list, then replace it: _expenses[index] = updated;.
Challenge 3 — Filter expenses by month. Add a menu option to show only expenses recorded in a given month and year.
Ask the user for a month and year, parse them with int.TryParse, and loop through ListExpenses() with a plain foreach, printing only the ones where expense.Date.Month and expense.Date.Year match.
Challenge 4 — Warn when spending exceeds a monthly budget. Let the user set a budget amount, and print a warning after adding an expense if the current month's total now exceeds it.
Store the budget as a decimal field on ExpenseTracker (or pass it in). After AddExpense succeeds, sum this month's expenses with a foreach loop similar to GetTotal(), but filtered by month and year, and compare against the budget.
Challenge 5 — Export a plain-text report. Add an option that writes a formatted summary (total, and totals by category) to a separate .txt file, in addition to the JSON data file.
Build the report as a single string using string interpolation and \n (or a StringBuilder if you've come across one), then write it with File.WriteAllText("report.txt", report) — wrapped in a try/catch just like SaveToFile does for the JSON file.
You've built a complete, working console application — model, service layer, persistence, and a resilient user-facing loop, all from fundamentals you already had.
dotnetmadeeasy.com — Learn C# and .NET, the right way.