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); }Source:https://dev.to/manoharij/c-basics-day-2-classes-inheritance-interfaces-exceptions-null-handling-and-file-io-with-5b0g