Why this post exists
This is Day 2 of a six-day plan to learn C# deeply. Day 1 covered the language core: types, strings, control flow, methods and collections. Day 2 is where C# starts to feel like C#: you stop writing loose lines of code and start designing objects that protect their own data and safety nets that stop one bad input from taking the program down.
Day 2 covers seven concepts: classes and properties, static/readonly/const, inheritance and interfaces, enums/structs/records, exception handling, null handling, and file I/O. Each one has what it is, an analogy, a short example, a practice question and a complete solution with the reasons it is written that way. At the end, a capstone Library Management app pulls all of Days 1 and 2 together.
Try each practice question yourself before reading the solution. All code targets .NET 8 or later and uses top-level statements, so you can paste each solution into the Program.cs of a new console project (dotnet new console) and run it.
Concept 6: Classes, constructors, properties and access modifiers
What it is. A class is a template that describes some data and the behaviour that goes with it. An object is one thing built from that template. A constructor runs when the object is created and makes sure it starts out valid. A property is a controlled doorway to a piece of data: it looks like a field from outside, but it can validate or restrict access. Access modifiers (public, private, protected, internal) decide who is allowed to see what.
The analogy: an architect's blueprint and the houses built from it. The blueprint (class) is not a house; each house (object) has its own furniture (data). The constructor is the builder, who refuses to hand over a house without a roof. Access modifiers are the rooms: public is the front porch anyone can walk onto, private is the bedroom only the owner enters, protected is the family-only wing, internal is the gated neighbourhood (your own project). A property is the front desk: visitors do not walk into the filing room, they ask the desk, and the desk can check ID first.
var dog = new Dog("Rex", 3);
dog.Birthday();
Console.WriteLine(dog); // Rex is 4
var puppy = new Dog("Bit"); // uses the second constructor
Console.WriteLine(puppy.Age); // 0
var cat = new Cat { Name = "Tom" }; // object initializer; Name is required
cat.Color = "black";
Console.WriteLine($"{cat.Name} is {cat.Color}"); // Tom is black
class Dog
{
private int _age; // private field: hidden from other classes
public string Name { get; } // read-only after construction
public int Age
{
get => _age;
private set // only this class can change it
{
if (value < 0) throw new ArgumentOutOfRangeException(nameof(value), "Age cannot be negative.");
_age = value;
}
}
public Dog(string name, int age)
{
Name = name;
Age = age;
}
public Dog(string name) : this(name, 0) { } // constructor chaining
public void Birthday() => Age++;
public override string ToString() => $"{Name} is {Age}";
}
class Cat
{
public required string Name { get; init; } // must be set, and only during initialization
public string Color { get; set; } = "grey"; // default value
}
Output:
Rex is 4
0
Tom is black
What is happening here:
-
private int _ageis a field, hidden from every other class. Everything outside goes through theAgeproperty. -
{ get; }makes a property read-only after construction.{ get; private set; }lets everyone read it but only the class itself change it. - A property's setter can validate. Here a negative age is rejected, so a
Dogcan never hold one. -
: this(name, 0)chains one constructor to another so the setup logic lives in one place. -
requiredplusinitmeans the value must be supplied in the object initializer and cannot be changed afterwards. It is a lightweight way to demand data without a long constructor. -
override ToString()controls what you see when the object is printed. Without it you get the type name.
Practice question 6: BankAccount
Create a BankAccount class with an owner and a balance. It needs a Deposit and a Withdraw method that validate their input, and the balance must be read-only from outside the class.
Solution
var account = new BankAccount("Asha", 100m);
account.Deposit(50m);
account.Withdraw(30m);
Console.WriteLine($"{account.Owner}: {account.Balance:F2}");
try { account.Withdraw(500m); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }
try { account.Deposit(-5m); }
catch (ArgumentOutOfRangeException ex) { Console.WriteLine($"Rejected deposit, parameter: {ex.ParamName}"); }
try { _ = new BankAccount(" "); }
catch (ArgumentException ex) { Console.WriteLine($"Rejected account, parameter: {ex.ParamName}"); }
public class BankAccount
{
public BankAccount(string owner, decimal openingBalance = 0m)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
ArgumentOutOfRangeException.ThrowIfNegative(openingBalance);
Owner = owner.Trim();
Balance = openingBalance;
}
public string Owner { get; }
public decimal Balance { get; private set; }
public void Deposit(decimal amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
Balance += amount;
}
public void Withdraw(decimal amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
if (amount > Balance)
{
throw new InvalidOperationException(
$"Insufficient funds: balance is {Balance:F2}, requested {amount:F2}.");
}
Balance -= amount;
}
}
Output:
Asha: 120.00
Insufficient funds: balance is 120.00, requested 500.00.
Rejected deposit, parameter: amount
Rejected account, parameter: owner
Why this is the best way to write it:
-
The object is valid from its first line. The constructor rejects a blank owner or a negative opening balance, so an invalid
BankAccountcan never exist. That removes a whole category of "how did the balance get like this?" bugs. -
Balance { get; private set; }is the heart of encapsulation. Anyone can read it, but the only code that can change it isDepositandWithdraw, and both validate first. A public setter would let any caller writeaccount.Balance = 1_000_000. -
decimal, notdouble, because this is money (see Day 1). -
The
ThrowIf...helpers give one-line guards that throw the right exception type with the parameter name filled in, which is what you see in the output (amount,owner). -
Two kinds of failure, two exception types. A bad argument (negative amount) is an
ArgumentExceptionfamily error: the caller made a programming mistake. Insufficient funds is anInvalidOperationException: the argument is fine, but the object's current state does not allow it. In concept 10 you will turn that second one into a custom exception. -
Defaults on the constructor (
openingBalance = 0m) avoid writing a second constructor for the common case.
Concept 7: static, readonly, const and encapsulation
What it is. Four keywords that answer the question "who owns this value, and when can it change?". const is fixed at compile time. readonly is assigned once, when the object is built. static belongs to the class itself, not to any one object. Encapsulation is the habit of hiding the inside of a class and exposing only safe operations.
The analogy: an apartment building. A static member is the notice board in the lobby: one board, shared by every resident. An instance member is the fridge in each apartment: everyone has their own. A const is a sign engraved into the wall: decided before the building opened. A readonly field is a sealed envelope: filled in once when the apartment is built, never opened again. Encapsulation is the car dashboard: you use the pedals and the wheel, not the fuel injectors.
var first = new Counter("first");
var second = new Counter("second");
first.Hit();
first.Hit();
second.Hit();
Console.WriteLine(first.Hits); // 2 (instance: each object has its own)
Console.WriteLine(second.Hits); // 1
Console.WriteLine(Counter.Total); // 3 (static: one copy shared by the class)
Console.WriteLine(Counter.Prefix); // ctr (const: baked in at compile time)
Console.WriteLine(first.Label); // ctr-first
class Counter
{
public const string Prefix = "ctr"; // const: compile-time constant, implicitly static
public static int Total { get; private set; } // static: belongs to the class, not an object
private readonly string _name; // readonly: assigned once, in the constructor
public int Hits { get; private set; }
public string Label => $"{Prefix}-{_name}";
public Counter(string name) => _name = name;
public void Hit()
{
Hits++;
Total++;
}
}
Output:
2
1
3
ctr
ctr-first
What is happening here:
-
constvalues are baked in at compile time and are implicitly static. Only simple types (numbers,bool,char,string) can beconst. -
readonlyfields can hold any type and are assigned in the declaration or the constructor, never afterwards. Use it for anything that should not change after construction. -
staticmembers are reached through the type name (Counter.Total), not an object. Astatic classcannot be instantiated at all, which suits helpers. - Static state is shared by everything in the program, including every thread. It is convenient and dangerous: treat mutable static data with suspicion.
- An immutable object has no way to change after construction. Operations on it return a new object, exactly like
stringdoes.
Practice question 7: IdGenerator and Money
Write an IdGenerator that hands out unique ids from a static counter. Then write an immutable Money class (amount plus currency) that can be added, compared and printed.
Solution
Console.WriteLine(IdGenerator.Next("ORD"));
Console.WriteLine(IdGenerator.Next("ORD"));
Console.WriteLine(IdGenerator.Next("INV"));
var price = new Money(19.99m, "USD");
var tax = new Money(1.60m, "USD");
Money total = price + tax;
Console.WriteLine(total); // 21.59 USD
Console.WriteLine(price); // 19.99 USD (unchanged)
Console.WriteLine(total == new Money(21.59m, "USD")); // True
try { _ = price + new Money(1m, "EUR"); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }
public static class IdGenerator
{
private static int _last;
public static string Next(string prefix = "ID") =>
$"{prefix}-{Interlocked.Increment(ref _last):D4}";
}
public sealed class Money : IEquatable<Money>
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
ArgumentException.ThrowIfNullOrWhiteSpace(currency);
Amount = Math.Round(amount, 2, MidpointRounding.AwayFromZero);
Currency = currency.Trim().ToUpperInvariant();
}
public Money Add(Money other)
{
ArgumentNullException.ThrowIfNull(other);
if (other.Currency != Currency)
{
throw new InvalidOperationException($"Cannot add {other.Currency} to {Currency}.");
}
return new Money(Amount + other.Amount, Currency); // a NEW object; this one never changes
}
public static Money operator +(Money a, Money b) => a.Add(b);
public bool Equals(Money? other) =>
other is not null && Amount == other.Amount && Currency == other.Currency;
public override bool Equals(object? obj) => Equals(obj as Money);
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
public static bool operator ==(Money? a, Money? b) => a is null ? b is null : a.Equals(b);
public static bool operator !=(Money? a, Money? b) => !(a == b);
public override string ToString() => $"{Amount:F2} {Currency}";
}
Output:
ORD-0001
ORD-0002
INV-0003
21.59 USD
19.99 USD
True
Cannot add EUR to USD.
Why this is the best way to write it:
-
Interlocked.Incrementmakes the counter thread-safe: two threads asking at the same instant can never receive the same id. A plain_last++is three steps (read, add, write) that can interleave. You will study this properly on Day 5; using the safe tool from the start costs nothing. -
IdGeneratoris astatic class. It holds no per-object data, so there is nothing to instantiate. The compiler enforces that. -
{counter:D4}pads to four digits so ids sort naturally (ORD-0001). Note that this one counter is shared across prefixes, which is whyINVgot 0003. -
Moneyhas get-only properties and no setters, so once built it never changes.Addreturns a newMoney, which is whypricestill prints 19.99 after the addition. - It rounds in the constructor. Normalising at the door means two values that are equal to the cent are always equal in the code, instead of failing a comparison over a stray fraction.
- The currency check prevents a silent bug. Adding euros to dollars throws instead of quietly producing nonsense.
-
It implements
IEquatable<Money>, overridesEqualsandGetHashCode, and defines==. Together they makeMoneywork in==comparisons and as a dictionary key. Hand-writing this once shows you exactly what arecordgives you for free in concept 9. -
sealedstops anyone subclassing it and breaking the immutability promise.
Concept 8: Inheritance, polymorphism, abstract classes, virtual/override and interfaces
What it is. Inheritance lets a class build on another (class Dog : Animal), reusing and extending it. Polymorphism means code can work with the general type (Animal) and the correct specific behaviour runs automatically. An abstract class is a base class that cannot be instantiated and can demand that subclasses fill in certain members. virtual marks a member subclasses may replace, and override is how they do it. An interface is a contract listing what a type can do, with no implementation.
The analogy: job descriptions and licences. Animal is a job description every employee shares: has a name, can speak. An abstract class is a description with a blank section every hire must complete, such as "how do you speak?". virtual is a default duty a hire may do differently. Polymorphism is one remote control that works on any TV: you press the same button and each device responds in its own way. An interface is a driving licence: it is not a job, it is an ability, and anyone in any job can hold one. A class has only one parent but can hold as many licences as it likes.
Animal[] zoo = { new Dog("Rex"), new Cat("Tom"), new Bird("Tweety") };
foreach (Animal animal in zoo)
{
Console.WriteLine(animal.Describe()); // the right override runs for each real type
if (animal is IFlyer flyer) // interfaces can be checked at runtime too
flyer.Fly();
}
abstract class Animal
{
protected Animal(string name) => Name = name;
public string Name { get; }
public abstract string Speak(); // no body: every subclass MUST provide one
public virtual string Describe() => $"{Name} says {Speak()}"; // subclasses MAY override
}
class Dog : Animal
{
public Dog(string name) : base(name) { }
public override string Speak() => "Woof";
}
class Cat : Animal
{
public Cat(string name) : base(name) { }
public override string Speak() => "Meow";
public override string Describe() => base.Describe() + " (and ignores you)";
}
interface IFlyer
{
void Fly();
}
class Bird : Animal, IFlyer // one base class, any number of interfaces
{
public Bird(string name) : base(name) { }
public override string Speak() => "Tweet";
public void Fly() => Console.WriteLine($"{Name} flaps away");
}
Output:
Rex says Woof
Tom says Meow (and ignores you)
Tweety says Tweet
Tweety flaps away
What is happening here:
-
abstract string Speak()has no body: the compiler refuses to compile any non-abstract subclass that forgets to provide one. -
virtualgives a default that subclasses can keep (Dog) or replace withoverride(Cat).base.Describe()calls the parent's version so the subclass can extend it. - The loop only knows about
Animal. WhichSpeakruns is decided at runtime by the real object's type. That is polymorphism. -
Bird : Animal, IFlyershows one base class plus an interface.animal is IFlyer flyertests for the ability. - Rule of thumb: inherit for a true "is-a" relationship (a dog is an animal). Use an interface for a "can-do" ability that unrelated types might share.
Practice question 8: shapes
Model Shape with Circle, Rectangle and Triangle subclasses and an IPrintable interface. Put shapes of mixed types in one list and compute the total area.
Solution
List<Shape> shapes = [new Circle(2), new Rectangle(3, 4), new Triangle(6, 5)];
foreach (IPrintable printable in shapes)
{
printable.Print();
}
Console.WriteLine($"Total area: {shapes.Sum(s => s.Area()):F2}");
try { _ = new Circle(-1); }
catch (ArgumentOutOfRangeException ex) { Console.WriteLine($"Rejected: {ex.ParamName}"); }
public interface IPrintable
{
void Print();
}
public abstract class Shape : IPrintable
{
public abstract string Name { get; }
public abstract double Area();
public virtual void Print() => Console.WriteLine($"{Name,-10} area = {Area():F2}");
protected static double Positive(double value, string paramName)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value, paramName);
return value;
}
}
public sealed class Circle : Shape
{
private readonly double _radius;
public Circle(double radius) => _radius = Positive(radius, nameof(radius));
public override string Name => "Circle";
public override double Area() => Math.PI * _radius * _radius;
}
public sealed class Rectangle : Shape
{
private readonly double _width, _height;
public Rectangle(double width, double height)
{
_width = Positive(width, nameof(width));
_height = Positive(height, nameof(height));
}
public override string Name => "Rectangle";
public override double Area() => _width * _height;
}
public sealed class Triangle : Shape
{
private readonly double _base, _height;
public Triangle(double @base, double height)
{
_base = Positive(@base, nameof(@base));
_height = Positive(height, nameof(height));
}
public override string Name => "Triangle";
public override double Area() => 0.5 * _base * _height;
}
Output:
Circle area = 12.57
Rectangle area = 12.00
Triangle area = 15.00
Total area: 39.57
Rejected: radius
Why this is the best way to write it:
-
The calling code never mentions
Circle,RectangleorTriangle. It loops overShapeand callsArea(). Adding aSquarelater means writing one new class and changing nothing else. -
No
if (shape is Circle) ... else if ...chain. The area formula lives inside each class, next to the data it needs. That chain is the classic sign that polymorphism should have been used. -
Area()is abstract, so forgetting it in a new shape is a compile error, not a runtime surprise. -
Print()is virtual with a sensible default, so most shapes need no code of their own but any shape can still override it. -
IPrintableis separate fromShape. Later, anInvoicethat has nothing to do with shapes can also beIPrintable, and a singleforeach (IPrintable p ...)handles both. -
A shared
Positivehelper in the base class validates dimensions once, so a negative radius is rejected at construction, as the last output line shows. -
Fields are
readonlyand classes aresealed. Shapes cannot change after construction, and nobody can subclass a leaf by accident. -
doubleis right here (unlike money), because geometry produces irrational numbers such asMath.PI * r * ranyway.
Concept 9: Enums, structs and records
What it is. An enum is a named set of fixed choices (Pending, Paid, Shipped). A struct is a small value type: assigning it copies the data. A class is a reference type: assigning it copies only the pointer. A record is a class (or struct) designed for data, with value-based equality, a readable ToString() and a non-destructive copy (with) built in.
The analogy: an enum is a restaurant menu: you pick from the listed dishes, not anything you can imagine. A struct is a sticky note: photocopy it and you have two independent notes. A class is a key to a shared locker: copy the key and both keys open the same locker, so anything one person puts in is seen by the other. A record is a passport: two passports with identical details describe the same identity, and with is renewing the passport with exactly one detail changed.
OrderStatus status = OrderStatus.Shipped;
Console.WriteLine($"{status} = {(int)status}"); // Shipped = 2
// A trap: Enum.TryParse accepts numbers that are not in the enum
Console.WriteLine(Enum.TryParse("99", out OrderStatus weird)); // True!
Console.WriteLine(Enum.IsDefined(weird)); // False: always check this too
// Copy behaviour: struct copies the value, class copies the reference
var s1 = new BoxStruct { Value = 1 };
var s2 = s1;
s2.Value = 99;
Console.WriteLine($"struct: s1={s1.Value}, s2={s2.Value}"); // 1 and 99
var c1 = new BoxClass { Value = 1 };
var c2 = c1;
c2.Value = 99;
Console.WriteLine($"class: c1={c1.Value}, c2={c2.Value}"); // 99 and 99
enum OrderStatus { Pending, Paid, Shipped, Delivered, Cancelled }
struct BoxStruct { public int Value; }
class BoxClass { public int Value; }
Output:
Shipped = 2
True
False
struct: s1=1, s2=99
class: c1=99, c2=99
What is happening here:
- An enum is an
intunderneath (Shipped = 2). Because of that,Enum.TryParse("99", ...)succeeds even though 99 is not a real status. Always follow it withEnum.IsDefined. - Changing the struct copy (
s2) lefts1alone. Changing the class "copy" (c2) changedc1too, because they are the same object. - Use a struct only for small, immutable, value-like things (a point, a coordinate, a colour). Default to a class.
Practice question 9: OrderStatus, Point and Person
Model an OrderStatus enum, a Point struct and a Person record, then compare their equality behaviour against an ordinary class.
Solution
// 1. Enum: a fixed set of named choices, with business rules built on top
Console.WriteLine($"Pending -> Paid: {OrderRules.CanMove(OrderStatus.Pending, OrderStatus.Paid)}");
Console.WriteLine($"Delivered -> Pending: {OrderRules.CanMove(OrderStatus.Delivered, OrderStatus.Pending)}");
// 2. Struct: a small value, compared by content
var a = new Point(1, 2);
var b = new Point(1, 2);
Console.WriteLine($"struct: a == b is {a == b}, a.Equals(b) is {a.Equals(b)}");
// 3. Class: compared by reference unless you override equality
var c1 = new PersonClass("Asha", 30);
var c2 = new PersonClass("Asha", 30);
Console.WriteLine($"class: c1 == c2 is {c1 == c2}");
// 4. Record: a class with value equality, ToString and 'with' built in
var r1 = new Person("Asha", 30);
var r2 = new Person("Asha", 30);
Console.WriteLine($"record: r1 == r2 is {r1 == r2}, same object: {ReferenceEquals(r1, r2)}");
Person older = r1 with { Age = 31 }; // non-destructive copy
Console.WriteLine(r1);
Console.WriteLine(older);
enum OrderStatus { Pending, Paid, Shipped, Delivered, Cancelled }
static class OrderRules
{
public static bool CanMove(OrderStatus from, OrderStatus to) => (from, to) switch
{
(OrderStatus.Pending, OrderStatus.Paid) => true,
(OrderStatus.Pending, OrderStatus.Cancelled) => true,
(OrderStatus.Paid, OrderStatus.Shipped) => true,
(OrderStatus.Paid, OrderStatus.Cancelled) => true,
(OrderStatus.Shipped, OrderStatus.Delivered) => true,
_ => false
};
}
readonly struct Point : IEquatable<Point>
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) => (X, Y) = (x, y);
public bool Equals(Point other) => X == other.X && Y == other.Y;
public override bool Equals(object? obj) => obj is Point p && Equals(p);
public override int GetHashCode() => HashCode.Combine(X, Y);
public static bool operator ==(Point left, Point right) => left.Equals(right);
public static bool operator !=(Point left, Point right) => !left.Equals(right);
}
class PersonClass
{
public string Name { get; }
public int Age { get; }
public PersonClass(string name, int age) => (Name, Age) = (name, age);
}
record Person(string Name, int Age);
Output:
Pending -> Paid: True
Delivered -> Pending: False
struct: a == b is True, a.Equals(b) is True
class: c1 == c2 is False
record: r1 == r2 is True, same object: False
Person { Name = Asha, Age = 30 }
Person { Name = Asha, Age = 31 }
Why this is the best way to write it:
-
The enum is more than a list of names.
OrderRules.CanMoveuses a switch expression over a tuple to describe the legal transitions in one readable table. Anything not listed falls into_ => false, so a new status is blocked until you decide where it fits. -
Pointis areadonly struct. Mutable structs cause confusing bugs because you end up modifying copies. Making it readonly removes the problem. -
IEquatable<Point>plusEquals,GetHashCodeand==together keep the equality rules consistent. Implementing only==and forgettingGetHashCodebreaks dictionaries and hash sets. -
The plain class says
Falsebecause by default a class compares references, not contents. TwoPersonClassobjects with identical data are different objects. -
The record says
Truein one line (record Person(string Name, int Age);). You also get a readableToString()andwithfor free, which is why records are the right default for data that just carries values. -
A rule of thumb: record for data, class for things with behaviour and identity, struct for tiny values. For a point,
readonly record struct Point(int X, int Y);gives you everything above in one line.
Concept 10: Exception handling: try/catch/finally, custom exceptions, throw vs throw ex
What it is. An exception is the runtime's way of saying "something went wrong that this code cannot handle here". try marks code that might fail, catch handles a specific failure, and finally runs no matter what. Custom exceptions are your own error types that carry meaning and data.
The analogy: a tightrope act. try is the walk across, catch is the safety net underneath, and finally is packing up the equipment whether the artist fell or not. A custom exception is a labelled alarm: "kitchen fire" is far more useful than a generic "alarm". And throw; versus throw ex; is the difference between forwarding a letter with its original postmark and re-posting it as if you had written it: with the second, nobody can tell where it really started.
try
{
Withdraw(balance: 50m, amount: 80m);
}
catch (InsufficientFundsException ex) // most specific catch first
{
Console.WriteLine($"Short by {ex.Shortfall:F2}");
}
catch (Exception ex) // general fallback last
{
Console.WriteLine($"Unexpected: {ex.Message}");
}
finally
{
Console.WriteLine("finally always runs, even after a throw");
}
// 'throw;' keeps the original stack trace. 'throw ex;' resets it.
try { Outer(keepStack: true); }
catch (Exception ex) { Console.WriteLine($"throw; still shows Inner: {ex.StackTrace!.Contains(nameof(Inner))}"); }
try { Outer(keepStack: false); }
catch (Exception ex) { Console.WriteLine($"throw ex; still shows Inner: {ex.StackTrace!.Contains(nameof(Inner))}"); }
static void Withdraw(decimal balance, decimal amount)
{
if (amount > balance) throw new InsufficientFundsException(balance, amount);
}
static void Outer(bool keepStack)
{
try { Inner(); }
catch (Exception ex)
{
Console.WriteLine($" (logging: {ex.Message})");
if (keepStack) throw; else throw ex;
}
}
static void Inner() => throw new InvalidOperationException("boom");
class InsufficientFundsException : Exception
{
public decimal Balance { get; }
public decimal Requested { get; }
public decimal Shortfall => Requested - Balance;
public InsufficientFundsException(decimal balance, decimal requested)
: base($"Balance {balance:F2} is less than requested {requested:F2}.")
{
Balance = balance;
Requested = requested;
}
}
Output:
Short by 30.00
finally always runs, even after a throw
(logging: boom)
throw; still shows Inner: True
(logging: boom)
throw ex; still shows Inner: False
What is happening here:
- Catch blocks run top to bottom, so put the most specific first and the general
Exceptionlast. Only catch what you can actually handle. -
finallyruns after success, after a handled error and even after an unhandled one. It is the place for cleanup. - A custom exception derives from
Exception, ends its name inException, and carries useful data as properties (Shortfall). The handler reacts to the data instead of parsing a message. -
throw;keeps the original stack trace.throw ex;resets it, so the trace no longer showsInner, where the failure really began. The compiler even warns about it (CA2200) when you build this code. That warning is correct. - Exceptions are for the unexpected. For things users get wrong all the time, use
TryParseand return values, as you did on Day 1.
Practice question 10: safe calculator with custom exceptions and logging
Build a safe calculator that evaluates expressions like 12 / 4. Failures must raise custom exceptions in the style of InsufficientFundsException (carrying useful data), and every failure must be logged.
Solution
using System.Globalization;
string logPath = Path.Combine(Path.GetTempPath(), "calculator.log");
File.Delete(logPath);
string[] inputs =
{
"12 + 8",
"9 / 4",
"7 / 0",
"5 ^ 2",
"hello + 1",
"1 +",
"2.5 * 4"
};
foreach (string input in inputs)
{
try
{
Console.WriteLine($"{input} = {Calculate(input)}");
}
catch (CalculatorException ex)
{
Console.WriteLine($"{input} -> error: {ex.Message}");
Log(ex);
}
}
Console.WriteLine($"{File.ReadAllLines(logPath).Length} error(s) written to the log file.");
static double Calculate(string expression)
{
string[] parts = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3)
{
throw new CalculatorException($"Expected 'number operator number' but got '{expression}'.");
}
if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double left) ||
!double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double right))
{
throw new CalculatorException("Both operands must be numbers.");
}
return parts[1] switch
{
"+" => left + right,
"-" => left - right,
"*" => left * right,
"/" when right == 0 => throw new DivideByZeroCalculatorException(left),
"/" => left / right,
_ => throw new UnknownOperatorException(parts[1])
};
}
void Log(Exception ex)
{
string line = $"{DateTime.UtcNow:O} [{ex.GetType().Name}] {ex.Message}";
File.AppendAllText(logPath, line + Environment.NewLine);
}
class CalculatorException : Exception
{
public CalculatorException(string message) : base(message) { }
public CalculatorException(string message, Exception inner) : base(message, inner) { }
}
class DivideByZeroCalculatorException : CalculatorException
{
public double Dividend { get; }
public DivideByZeroCalculatorException(double dividend)
: base($"Cannot divide {dividend} by zero.") => Dividend = dividend;
}
class UnknownOperatorException : CalculatorException
{
public string Operator { get; }
public UnknownOperatorException(string op)
: base($"Unknown operator '{op}'. Use + - * or /.") => Operator = op;
}
Output:
12 + 8 = 20
9 / 4 = 2.25
7 / 0 -> error: Cannot divide 7 by zero.
5 ^ 2 -> error: Unknown operator '^'. Use + - * or /.
hello + 1 -> error: Both operands must be numbers.
1 + -> error: Expected 'number operator number' but got '1 +'.
2.5 * 4 = 10
4 error(s) written to the log file.
Why this is the best way to write it:
-
A base
CalculatorExceptionwith specific children lets the loop catch every calculator problem with one clause, while callers who care can still catchDivideByZeroCalculatorExceptionalone. -
The exceptions carry data (
Dividend,Operator), exactly likeInsufficientFundsExceptioncarries a shortfall. A handler can use the values without parsing text. -
The divide-by-zero check is explicit on purpose. With
double,7 / 0does not throw in C#: it quietly returnsInfinity. If you rely on the runtime to complain, you get a wrong answer instead of an error. -
A
switchexpression withwhenandthrowexpressions ("/" when right == 0 => throw ...) keeps every operator, including its failure case, in one table. -
TryParsefor the operands, so bad numbers become a friendly custom error without the cost of an internalFormatException. -
Only
CalculatorExceptionis caught. If a genuine bug throws aNullReferenceException, it crashes loudly instead of being swallowed. An emptycatch (Exception)is how bugs hide for months. -
The logging lives in one
Logmethod with a UTC ISO-8601 timestamp, so changing where logs go (a file today, a service later) is a one-place edit. -
Calculateis a pure function: string in, number or exception out, with no printing. That keeps it easy to test.
Concept 11: Null handling: Nullable, nullable reference types, ?., ?? and ??=
What it is. null means "no value", and using it carelessly is the source of the most common runtime crash in .NET: NullReferenceException. Modern C# helps with nullable reference types: turn the feature on and the compiler tracks which variables may be null (string?) and which never should be (string). Nullable<T> (written int?) gives value types a "no value" state. Three operators make null easy to handle: ?. (null-conditional), ?? (null-coalescing) and ??= (null-coalescing assignment).
The analogy: a mailbox. null is "there is no mailbox at this address", which is different from an empty mailbox. The ?. operator is ringing the doorbell before you walk in: if nobody answers you stop politely instead of barging through the wall. ?? is "if nobody is home, leave the parcel with this neighbour instead". ??= is "put a default parcel in the box, but only if it is empty". int? is an integer that is allowed to answer "I do not know".
int? maybeCount = null; // Nullable<int>: a value type that can also be 'no value'
Console.WriteLine(maybeCount.HasValue); // False
Console.WriteLine(maybeCount ?? -1); // -1 (?? supplies a fallback)
string? nickname = null; // nullable reference type: the ? is a promise to check
Console.WriteLine(nickname?.Length); // (blank) ?. stops and returns null instead of crashing
Console.WriteLine(nickname?.Length ?? 0); // 0
nickname ??= "guest"; // assign only if currently null
Console.WriteLine(nickname); // guest
string name = "Asha"; // no ?: the compiler warns if you assign null to it
Console.WriteLine(name.Length); // 4
Output:
False
-1
0
guest
4
What is happening here:
-
nickname?.Lengthisnullwhennicknameis null, instead of throwing. Chain as many as you need:a?.b?.c. -
?? -1provides a fallback when the left side is null.??=assigns only when the variable is currently null. - With
<Nullable>enable</Nullable>in the project, assigningnullto a plainstringproduces a compiler warning. The?on a type is a promise to check before using it. - Avoid the
!operator (name!). It silences the compiler without making the code safe. It is you saying "trust me", and the runtime will not. - Prefer
is nullandis not nullover== null: they cannot be broken by an overloaded==.
Practice question 11: GetUserCity
Write GetUserCity(User? user) that safely navigates a nested object graph (User, then Address, then City) and never throws, even when any link in the chain is missing.
Solution
User?[] users =
{
new("Asha", new Address("Chicago", new Country("US"))),
new("Ravi", new Address(" Mumbai ", null)),
new("Meena", new Address(null, new Country("IN"))),
new("Dev", null),
null
};
foreach (User? user in users)
{
Console.WriteLine($"{user?.Name ?? "(no user)",-9} city: {GetUserCity(user),-8} country: {GetUserCountryCode(user)}");
}
static string GetUserCity(User? user)
{
string? city = user?.Address?.City;
return string.IsNullOrWhiteSpace(city) ? "Unknown" : city.Trim();
}
static string GetUserCountryCode(User? user) => user?.Address?.Country?.Code ?? "??";
record User(string Name, Address? Address);
record Address(string? City, Country? Country);
record Country(string Code);
Output:
Asha city: Chicago country: US
Ravi city: Mumbai country: ??
Meena city: Unknown country: IN
Dev city: Unknown country: ??
(no user) city: Unknown country: ??
Why this is the best way to write it:
-
One
?.chain replaces four nestedifstatements.user?.Address?.Citystops at the first null link and returns null for the whole expression. -
The result is held in a
string?variable and checked once. You do the null decision in one place instead of scattering it. -
string.IsNullOrWhiteSpacecovers three cases at once: null, empty and spaces only. That is why Meena (null city) and Ravi's padded" Mumbai "are both handled, with aTrim()for the padded one. -
The parameter is
User?and the return is a plainstring. The signature itself documents that null users are accepted and that callers will never get null back, so they need no check of their own. -
The model types use nullable members to say what is optional.
Address?andCountry?tell the reader, and the compiler, exactly which links can be missing. -
It does not throw for a null user. Whether a missing user should be an error or just "Unknown" is a design decision. Here, a display helper should be forgiving. A method that needs a user should call
ArgumentNullException.ThrowIfNull(user)instead.
Concept 12: File I/O basics: File, StreamReader/StreamWriter and Path
What it is. File I/O (input/output) is how a program keeps data after it closes. The File class has one-line helpers for common jobs. StreamReader and StreamWriter read and write a file piece by piece. Path builds and picks apart file paths safely.
The analogy: a filing cabinet. A Path is the address of a drawer. StreamWriter is a pen: if you forget to put the cap on (close the file), the ink dries and the last words never make it to the page. A using block is a pen that caps itself. Saving safely is like writing the new page on scrap paper first and swapping it into the folder only once it is complete, so a power cut can never leave a half-written page in the cabinet.
string folder = Path.Combine(Path.GetTempPath(), "day2-demo");
Directory.CreateDirectory(folder); // safe if it already exists
string path = Path.Combine(folder, "notes.txt"); // never glue paths with + "/"
File.WriteAllText(path, "first line\nsecond line\n"); // create or overwrite
File.AppendAllText(path, "third line\n"); // add to the end
foreach (string line in File.ReadLines(path)) // streams one line at a time
Console.WriteLine(line);
using (var writer = new StreamWriter(path)) // using = closes the file for you
{
writer.WriteLine("rewritten");
}
using (var reader = new StreamReader(path))
{
Console.WriteLine(reader.ReadToEnd().Trim());
}
Console.WriteLine(File.Exists(path)); // True
Console.WriteLine(Path.GetFileName(path)); // notes.txt
Console.WriteLine(Path.GetExtension(path)); // .txt
File.Delete(path);
Output:
first line
second line
third line
rewritten
True
notes.txt
.txt
What is happening here:
-
Path.Combinejoins parts with the right separator for the operating system. Never build paths with+ "/" +. -
File.WriteAllTextcreates or overwrites,File.AppendAllTextadds to the end.File.ReadLinesstreams one line at a time and is better thanReadAllLinesfor large files. -
usingcloses (and flushes) the file even if an exception is thrown. An unclosed file can stay locked and lose buffered data. - Files fail for reasons outside your control: missing, locked, read-only, disk full. Expect
FileNotFoundException,IOExceptionandUnauthorizedAccessExceptionand handle them where you can say something useful.
Practice question 12: persist the inventory
Take your Day 1 inventory. Save it to a text file and reload it on startup.
Solution
string path = Path.Combine(Path.GetTempPath(), "inventory-demo.txt");
File.Delete(path);
// First start: no file yet, so we begin empty
Dictionary<string, int> inventory = InventoryStore.Load(path);
Console.WriteLine($"Loaded {inventory.Count} item(s) at startup");
inventory["Keyboard"] = 12;
inventory["Mouse"] = 4;
InventoryStore.Save(path, inventory);
Console.WriteLine("Saved. File contents:");
Console.WriteLine(File.ReadAllText(path).Replace('\t', '|').TrimEnd());
// Simulate a damaged file, then restart the app
File.AppendAllText(path, "garbage line\nPen\tnotanumber\n");
Dictionary<string, int> reloaded = InventoryStore.Load(path);
Console.WriteLine($"Reloaded {reloaded.Count} item(s): {string.Join(", ", reloaded.Select(p => $"{p.Key}={p.Value}"))}");
public static class InventoryStore
{
public static Dictionary<string, int> Load(string path)
{
var inventory = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(path)) return inventory;
using var reader = new StreamReader(path);
int lineNumber = 0;
while (reader.ReadLine() is { } line)
{
lineNumber++;
if (string.IsNullOrWhiteSpace(line)) continue;
string[] parts = line.Split('\t');
if (parts.Length == 2 && int.TryParse(parts[1], out int quantity) && quantity >= 0)
{
inventory[parts[0]] = quantity;
}
else
{
Console.WriteLine($"Skipping bad line {lineNumber}: {line}");
}
}
return inventory;
}
public static void Save(string path, Dictionary<string, int> inventory)
{
string tempPath = path + ".tmp";
using (var writer = new StreamWriter(tempPath))
{
foreach (var (name, quantity) in inventory.OrderBy(p => p.Key))
{
writer.WriteLine($"{name}\t{quantity}");
}
}
File.Move(tempPath, path, overwrite: true); // swap in only after the write succeeded
}
}
Output:
Loaded 0 item(s) at startup
Saved. File contents:
Keyboard|12
Mouse|4
Skipping bad line 3: garbage line
Skipping bad line 4: Pen notanumber
Reloaded 2 item(s): Keyboard=12, Mouse=4
Why this is the best way to write it:
- A missing file means an empty inventory, not a crash. The very first run of any app has no data file yet, and that must be normal.
-
Every line is validated as it is read.
TryParseplus a non-negative check means a hand-edited or damaged line is skipped and reported with its line number, instead of crashing the app or loading garbage. -
The save is atomic. It writes to a
.tmpfile and only then moves it over the real one. If the program dies halfway through writing, the last good file is untouched. The temp file is fully closed before the move because theusingblock ends first. - A tab separates the fields, because product names are far more likely to contain commas than tabs. A format only works if the separator cannot appear in the data.
- Output is sorted by name, so repeated saves produce stable files that are easy to compare.
-
reader.ReadLine() is { } lineis the idiomatic loop: read, test for non-null and bind in one expression. -
Persistence is its own class.
InventoryStoreknows about files; the inventory logic from Day 1 does not. Swapping to JSON or a database later changes one class.
Basics capstone: Library Management console app
Now combine everything from Days 1 and 2 in one program. Build a menu-driven console app for a small library. Requirements:
- Books and members as classes, with
Bookinheriting intoPrintedBookandEBook - An interface for lending rules, so the limits and fees can be swapped
- An enum for book status
- Custom exceptions for domain failures (book not found, already borrowed, loan limit reached)
-
DictionaryandListstorage - File persistence: state survives a restart
- Nullable handling for optional data
- A menu-driven loop that keeps running after a bad input
Try it yourself before reading on. If you want a plan: start with the model classes, then Library with Borrow/Return, then the menu, and add the file persistence last.
Solution
using System.Globalization;
const string DataFile = "library.dat";
var library = Library.Load(DataFile, new StandardLendingRules());
Console.WriteLine($"Library ready: {library.BookCount} book(s), {library.MemberCount} member(s).");
bool running = true;
while (running)
{
Console.WriteLine();
Console.WriteLine("1) Add book 2) Add member 3) Borrow 4) Return 5) Search 6) List books 0) Save and exit");
string choice = Ask("> ");
try
{
switch (choice)
{
case "1": AddBook(); break;
case "2": AddMember(); break;
case "3": Borrow(); break;
case "4": Return(); break;
case "5": Search(); break;
case "6": ListBooks(); break;
case "0":
library.Save(DataFile);
Console.WriteLine("Saved. Goodbye!");
running = false;
break;
default:
Console.WriteLine("Unknown option.");
break;
}
}
catch (LibraryException ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
string Ask(string label)
{
Console.Write(label.EndsWith("> ") ? label : label + ": ");
return Console.ReadLine()?.Trim() ?? "0"; // end of input behaves like "exit"
}
void AddBook()
{
string kind = Ask("Type - (P)rinted or (E)Book").ToUpperInvariant();
string id = Ask("Id");
string title = Ask("Title");
string author = Ask("Author");
Book book;
if (kind == "P")
{
book = new PrintedBook(id, title, author, Ask("Shelf"));
}
else if (kind == "E")
{
if (!double.TryParse(Ask("Size in MB"), NumberStyles.Float, CultureInfo.InvariantCulture, out double mb))
{
throw new LibraryException("Size must be a number.");
}
book = new EBook(id, title, author, mb);
}
else
{
throw new LibraryException("Type must be P or E.");
}
library.AddBook(book);
Console.WriteLine($"Added {book.Describe()}");
}
void AddMember()
{
string id = Ask("Member id");
string name = Ask("Name");
string email = Ask("Email (optional)");
library.AddMember(new Member(id, name, string.IsNullOrWhiteSpace(email) ? null : email));
Console.WriteLine("Member added.");
}
void Borrow()
{
Loan loan = library.Borrow(Ask("Member id"), Ask("Book id"), Today());
Console.WriteLine($"Borrowed. Due back on {loan.DueDate:yyyy-MM-dd}.");
}
void Return()
{
decimal fee = library.Return(Ask("Book id"), Today());
Console.WriteLine($"Returned. Late fee: ${fee:F2}");
}
void Search()
{
var results = library.Search(Ask("Search text")).ToList();
Console.WriteLine(results.Count == 0 ? "No matches." : string.Join(Environment.NewLine, results.Select(b => b.Describe())));
}
void ListBooks()
{
foreach (Book book in library.AllBooks) Console.WriteLine(book.Describe());
}
static DateOnly Today() => DateOnly.FromDateTime(DateTime.Today);
// ---------------------------------------------------------------- domain
public enum BookStatus { Available, Borrowed }
public abstract class Book
{
protected Book(string id, string title, string author)
{
Id = Clean(id, nameof(id));
Title = Clean(title, nameof(title));
Author = Clean(author, nameof(author));
}
public string Id { get; }
public string Title { get; }
public string Author { get; }
public BookStatus Status { get; internal set; } = BookStatus.Available;
public abstract string Kind { get; }
public abstract int LoanDays { get; }
public abstract string ExtraData { get; } // the one field that differs per subtype
public virtual string Describe() => $"[{Id}] {Title} by {Author} ({Kind}, {Status})";
protected static string Clean(string value, string field)
{
if (string.IsNullOrWhiteSpace(value) || value.Contains('|'))
{
throw new LibraryException($"{field} is required and cannot contain '|'.");
}
return value.Trim();
}
}
public sealed class PrintedBook : Book
{
public PrintedBook(string id, string title, string author, string shelf) : base(id, title, author)
=> Shelf = Clean(shelf, nameof(shelf));
public string Shelf { get; }
public override string Kind => "Printed";
public override int LoanDays => 21;
public override string ExtraData => Shelf;
public override string Describe() => $"{base.Describe()} shelf {Shelf}";
}
public sealed class EBook : Book
{
public EBook(string id, string title, string author, double sizeMb) : base(id, title, author)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sizeMb);
SizeMb = sizeMb;
}
public double SizeMb { get; }
public override string Kind => "EBook";
public override int LoanDays => 14;
public override string ExtraData => SizeMb.ToString(CultureInfo.InvariantCulture);
public override string Describe() => $"{base.Describe()} {SizeMb:F1} MB";
}
public class Member
{
public Member(string id, string name, string? email = null)
{
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name) || id.Contains('|') || name.Contains('|'))
{
throw new LibraryException("Member id and name are required and cannot contain '|'.");
}
Id = id.Trim();
Name = name.Trim();
Email = email?.Trim();
}
public string Id { get; }
public string Name { get; }
public string? Email { get; } // optional, so it is nullable
}
public record Loan(string BookId, string MemberId, DateOnly DueDate);
// ---------------------------------------------------------------- rules
public interface ILendingRules
{
int MaxActiveLoans { get; }
DateOnly GetDueDate(Book book, DateOnly borrowedOn);
decimal CalculateLateFee(Loan loan, DateOnly returnedOn);
}
public class StandardLendingRules : ILendingRules
{
public int MaxActiveLoans => 3;
public DateOnly GetDueDate(Book book, DateOnly borrowedOn) => borrowedOn.AddDays(book.LoanDays);
public decimal CalculateLateFee(Loan loan, DateOnly returnedOn)
{
int daysLate = returnedOn.DayNumber - loan.DueDate.DayNumber;
return daysLate > 0 ? daysLate * 0.25m : 0m;
}
}
// ---------------------------------------------------------------- exceptions
public class LibraryException : Exception
{
public LibraryException(string message) : base(message) { }
}
public class NotFoundException : LibraryException
{
public NotFoundException(string what, string id) : base($"{what} '{id}' was not found.") { }
}
public class BookUnavailableException : LibraryException
{
public BookUnavailableException(string title) : base($"'{title}' is already borrowed.") { }
}
public class LendingLimitException : LibraryException
{
public LendingLimitException(string member, int limit) : base($"{member} already has {limit} active loans.") { }
}
// ---------------------------------------------------------------- the library
public class Library
{
private readonly Dictionary<string, Book> _books = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Member> _members = new(StringComparer.OrdinalIgnoreCase);
private readonly List<Loan> _loans = new();
private readonly ILendingRules _rules;
public Library(ILendingRules rules) => _rules = rules;
public int BookCount => _books.Count;
public int MemberCount => _members.Count;
public IEnumerable<Book> AllBooks => _books.Values.OrderBy(b => b.Title);
public void AddBook(Book book)
{
if (!_books.TryAdd(book.Id, book)) throw new LibraryException($"A book with id '{book.Id}' already exists.");
}
public void AddMember(Member member)
{
if (!_members.TryAdd(member.Id, member)) throw new LibraryException($"A member with id '{member.Id}' already exists.");
}
public Loan Borrow(string memberId, string bookId, DateOnly today)
{
Member member = FindMember(memberId);
Book book = FindBook(bookId);
if (book.Status != BookStatus.Available) throw new BookUnavailableException(book.Title);
if (_loans.Count(l => l.MemberId == member.Id) >= _rules.MaxActiveLoans)
{
throw new LendingLimitException(member.Name, _rules.MaxActiveLoans);
}
var loan = new Loan(book.Id, member.Id, _rules.GetDueDate(book, today));
_loans.Add(loan);
book.Status = BookStatus.Borrowed;
return loan;
}
public decimal Return(string bookId, DateOnly today)
{
Book book = FindBook(bookId);
Loan? loan = _loans.Find(l => l.BookId == book.Id);
if (loan is null) throw new LibraryException($"'{book.Title}' is not on loan.");
_loans.Remove(loan);
book.Status = BookStatus.Available;
return _rules.CalculateLateFee(loan, today);
}
public IEnumerable<Book> Search(string text) => _books.Values.Where(b =>
b.Title.Contains(text, StringComparison.OrdinalIgnoreCase) ||
b.Author.Contains(text, StringComparison.OrdinalIgnoreCase));
private Book FindBook(string id) =>
_books.TryGetValue(id.Trim(), out Book? book) ? book : throw new NotFoundException("Book", id);
private Member FindMember(string id) =>
_members.TryGetValue(id.Trim(), out Member? member) ? member : throw new NotFoundException("Member", id);
// ------------------------------------------------------------ persistence
public void Save(string path)
{
string tempPath = path + ".tmp";
using (var writer = new StreamWriter(tempPath))
{
foreach (Book b in _books.Values)
writer.WriteLine($"B|{b.Kind}|{b.Id}|{b.Title}|{b.Author}|{b.Status}|{b.ExtraData}");
foreach (Member m in _members.Values)
writer.WriteLine($"M|{m.Id}|{m.Name}|{m.Email}");
foreach (Loan l in _loans)
writer.WriteLine($"L|{l.BookId}|{l.MemberId}|{l.DueDate:yyyy-MM-dd}");
}
File.Move(tempPath, path, overwrite: true);
}
public static Library Load(string path, ILendingRules rules)
{
var library = new Library(rules);
if (!File.Exists(path)) return library;
foreach (string line in File.ReadLines(path))
{
string[] f = line.Split('|');
try
{
switch (f[0])
{
case "B":
Book book = f[1] == "Printed"
? new PrintedBook(f[2], f[3], f[4], f[6])
: new EBook(f[2], f[3], f[4], double.Parse(f[6], CultureInfo.InvariantCulture));
book.Status = Enum.Parse<BookStatus>(f[5]);
library.AddBook(book);
break;
case "M":
library.AddMember(new Member(f[1], f[2], string.IsNullOrEmpty(f[3]) ? null : f[3]));
break;
case "L":
library._loans.Add(new Loan(f[1], f[2], DateOnly.ParseExact(f[3], "yyyy-MM-dd", CultureInfo.InvariantCulture)));
break;
}
}
catch (Exception ex) when (ex is FormatException or IndexOutOfRangeException or ArgumentException or LibraryException)
{
Console.WriteLine($"Skipped unreadable line: {line}");
}
}
return library;
}
}
Sample session. The menu line is shown once and the later repeats are omitted for space. Typed input follows each prompt:
Library ready: 0 book(s), 0 member(s).
1) Add book 2) Add member 3) Borrow 4) Return 5) Search 6) List books 0) Save and exit
> 2
Member id: m1
Name: Asha
Email (optional):
Member added.
> 1
Type - (P)rinted or (E)Book: P
Id: b1
Title: Clean Code
Author: Robert Martin
Shelf: A3
Added [b1] Clean Code by Robert Martin (Printed, Available) shelf A3
> 1
Type - (P)rinted or (E)Book: E
Id: b2
Title: The Pragmatic Programmer
Author: Hunt and Thomas
Size in MB: 2.5
Added [b2] The Pragmatic Programmer by Hunt and Thomas (EBook, Available) 2.5 MB
> 3
Member id: m1
Book id: b1
Borrowed. Due back on 2026-11-17.
> 3
Member id: m1
Book id: b1
Error: 'Clean Code' is already borrowed.
> 3
Member id: m9
Book id: b2
Error: Member 'm9' was not found.
> 5
Search text: pragmatic
[b2] The Pragmatic Programmer by Hunt and Thomas (EBook, Available) 2.5 MB
> 0
Saved. Goodbye!
Restarting the app proves the persistence works. The second run loads everything back, including the loan:
Library ready: 2 book(s), 1 member(s).
1) Add book 2) Add member 3) Borrow 4) Return 5) Search 6) List books 0) Save and exit
> 6
[b1] Clean Code by Robert Martin (Printed, Borrowed) shelf A3
[b2] The Pragmatic Programmer by Hunt and Thomas (EBook, Available) 2.5 MB
> 4
Book id: b1
Returned. Late fee: $0.00
> 4
Book id: b1
Error: 'Clean Code' is not on loan.
> 0
Saved. Goodbye!
The due date depends on the day you run it: 21 days for a printed book, 14 for an ebook.
Why this is the best way to write it:
-
Each part has one job. Models hold data (
Book,Member,Loan),ILendingRulesholds policy, the exception classes hold failure meanings,Libraryholds the business logic, and the top ofProgram.csonly does menus and console input and output. There is not a single business rule in the menu code. -
Inheritance is used only where behaviour really differs.
PrintedBookandEBookdiffer in loan length, description and the one extra field.Bookis abstract, so nobody can create a generic book that has no loan length. -
Lending rules sit behind an interface and are handed to
Librarythrough its constructor. Changing the loan limit or the late fee means a newILendingRulesclass and no change toLibrary. This is a first taste of dependency injection, which you will meet on Day 4. -
todayis passed intoBorrowandReturninstead of callingDateTime.Todayinside. To test a 10-days-late return, pass a date 10 days ahead. Code that secretly reads the clock cannot be tested that way. -
Expected failures are exceptions with domain names. The menu loop has a single
catch (LibraryException), so every anticipated problem is reported in one place, while a real bug (such as aNullReferenceException) still crashes visibly. -
Dictionaries for lookups, a list for loans. Books and members are found by id, so they are
Dictionaryinstances with a case-insensitive comparer. Loans are a short ordered list.TryAddrejects duplicate ids in one step. -
The text format cannot be corrupted by user input. Fields are separated by
|, and every constructor rejects values containing|. Each line starts with a record type letter (B,M,L) so one file holds all three kinds. -
Saving is atomic and loading is tolerant. The file is written to a temp file and swapped in. Loading skips unreadable lines with an exception filter (
when (ex is FormatException or ...)) instead of refusing to start. -
Nullable handling is honest.
Emailisstring?because it is genuinely optional, empty input becomesnull,FindreturnsLoan?and the code checks fornullimmediately. -
End of input counts as "exit". If the console input ends, the loop stops cleanly instead of spinning forever on
null.
Where each concept appears
- Day 1:
const,TryParse,switch,while,Dictionary,List, methods withoutand default values, string formatting - Classes and properties:
Book,Member,Library, with private state and validating constructors - static/readonly:
readonlycollections and rules;statichelpersTodayandLoad - Inheritance, abstract, virtual/override, interface:
Bookfamily andILendingRules - Enums and records:
BookStatusandLoan - Exceptions: the
LibraryExceptionfamily and the single catch in the menu - Null handling:
Email,Loan?,?.,??and conditional expressions - File I/O:
SaveandLoadwithStreamWriter, atomic move, tolerant parsing
Stretch goals (optional): add a reservation queue, a premium member type with a higher loan limit through a second ILendingRules class, and an overdue report. The report is a perfect warm-up for LINQ on Day 3.
Five classic Day 2 mistakes
Each one follows the same pattern: what people write, why it hurts, what to write instead.
-
Public fields and public setters on everything.
public decimal Balance;lets any code set any value, so no rule you wrote can be trusted. Make the setterprivateand expose operations (Deposit,Withdraw). -
An empty
catch.catch (Exception) { }hides every failure, including real bugs, and the program carries on with bad state. Catch specific exceptions you can handle, and log or rethrow the rest. -
throw ex;instead ofthrow;. It erases the stack trace, so the log points at the catch block rather than the real failure. Usethrow;to rethrow. -
Silencing nullable warnings with
!.user!.Address!.Citycompiles cleanly and crashes the same way as before. Use?.and??and make the decision explicit. -
Building paths with
+and forgetting to close files.folder + "/" + namebreaks on other operating systems, and an unclosedStreamWritercan lose the last lines it buffered. UsePath.Combineandusing.
Key lessons
- A class guards its own data: private state, validating constructors, public methods
-
private setand get-only properties are the everyday tools of encapsulation -
constis compile-time,readonlyis assign-once,staticis shared by everything; be suspicious of mutable statics - Immutable objects never change: operations return new objects
- Program against the base type or interface, and let polymorphism pick the behaviour
- Inherit for "is-a", implement an interface for "can-do"
- Record for data, class for behaviour and identity, struct for tiny values
- Catch specific exceptions, put custom data on custom exceptions, rethrow with
throw; -
?.,??and??=turn crashes into decisions; avoid! - Close files with
using, build paths withPath.Combine, and save atomically
Summary
Day 1 gave you the vocabulary. Day 2 gives you the grammar of real programs: objects that protect themselves, hierarchies that let new behaviour plug in without touching old code, value types that behave as values, errors that carry meaning, nulls that are handled on purpose, and data that survives a restart. The Library app uses every one of them together, which is exactly how real applications work. If you can rebuild it from a blank file, you are ready for Day 3: generics, collections, delegates, events, LINQ and iterators.
More on these topics: C# posts
Top comments (0)