DEV Community

Cover image for OOP (Object-Oriented Programming) in C#
Rhuturaj Takle
Rhuturaj Takle

Posted on

OOP (Object-Oriented Programming) in C#

OOP (Object-Oriented Programming) in C

A deep-dive walkthrough of object-oriented programming as implemented in C# — covering the four pillars (Encapsulation, Inheritance, Polymorphism, Abstraction) in depth, with runnable C# examples, the language mechanics that implement each principle, common pitfalls, and the composition-over-inheritance debate that shapes how OOP is actually practiced in modern C# codebases.


Table of Contents

  1. Introduction
  2. Classes and Objects: The Foundation
  3. Encapsulation
  4. Inheritance
  5. Polymorphism
  6. Abstraction
  7. Interfaces vs. Abstract Classes in C#
  8. SOLID Principles: OOP Design Discipline
  9. Composition Over Inheritance
  10. Records and Value-Based Equality (C#'s Modern Addition)
  11. Common Pitfalls
  12. Quick Reference Table
  13. Conclusion

Introduction

Object-oriented programming organizes code around objects — bundles of state (data) and behavior (methods) — rather than around a sequence of standalone functions operating on shared data. C# is a class-based OOP language at its core (unlike, say, JavaScript's prototype-based model), which means every one of the four classical pillars — Encapsulation, Inheritance, Polymorphism, and Abstraction — maps onto a specific, first-class language feature: access modifiers, the class/: base syntax, virtual dispatch, and interfaces/abstract classes, respectively. This guide walks through each pillar with real C# code, then covers how modern C# and mainstream design practice (SOLID, composition-over-inheritance, records) refine and sometimes push back against the classical four-pillar framing.

Encapsulation → hides DATA (access modifiers: private, protected, public)
Abstraction   → hides IMPLEMENTATION DETAIL (interfaces, abstract classes)
Inheritance   → reuses code across an "is-a" hierarchy (: base class)
Polymorphism  → lets different types respond to the same call (virtual/override, interfaces)
Enter fullscreen mode Exit fullscreen mode

1. Classes and Objects: The Foundation

A class is a blueprint; an object is an instance of it

public class Account
{
    public string Owner { get; set; }
    public decimal Balance { get; set; }
}

// Each `new` creates a distinct OBJECT (instance) from the CLASS (blueprint)
var alice = new Account { Owner = "Alice", Balance = 100m };
var bob = new Account { Owner = "Bob", Balance = 250m };
Enter fullscreen mode Exit fullscreen mode

A class defines the shape — what fields and methods every instance will have — while each object created from new Account() has its own independent copy of that state. alice.Balance and bob.Balance are separate values in memory even though they came from the same class definition. Everything else in this guide is really about disciplining how that shape is defined and how instances of it interact.

Reference types, and why this matters for how objects behave in C

var a = new Account { Owner = "Alice", Balance = 100m };
var b = a;           // b now points to the SAME object as a
b.Balance = 500m;
Console.WriteLine(a.Balance); // prints 500 — a and b reference the same underlying object
Enter fullscreen mode Exit fullscreen mode

Classes in C# are reference types — a variable holds a reference to the object on the heap, not the object itself (unlike struct, which is a value type, copied on assignment). This is a foundational mechanic worth understanding before the rest of OOP makes full sense: when you pass an object into a method, or assign it to another variable, you're sharing the same underlying instance unless you deliberately copy it.


2. Encapsulation

The problem encapsulation solves: uncontrolled, unvalidated mutation of state

// ❌ Public fields let ANY caller set balance to anything, including nonsense
public class UnsafeAccount
{
    public decimal Balance;
}

var acct = new UnsafeAccount();
acct.Balance = -9999; // nothing stops this — no invariant is enforced anywhere
Enter fullscreen mode Exit fullscreen mode

With a public field, there's no single place that guarantees a Balance is ever valid — every single call site that touches it would need to remember to validate, which is exactly the kind of scattered, easy-to-forget discipline encapsulation exists to eliminate by centralizing the rule in one place.

Encapsulation via private fields and public properties/methods

public class Account
{
    private decimal _balance; // hidden from the outside world

    public decimal Balance => _balance; // read-only from outside

    public void Deposit(decimal amount)
    {
        if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount), "Deposit must be positive.");
        _balance += amount;
    }

    public void Withdraw(decimal amount)
    {
        if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal must be positive.");
        if (amount > _balance) throw new InvalidOperationException("Insufficient funds.");
        _balance -= amount;
    }
}

var acct = new Account();
acct.Deposit(100m);
acct.Withdraw(30m);
// acct.Balance = -500m; // ❌ won't even compile — Balance has no public setter
Enter fullscreen mode Exit fullscreen mode

Now every mutation of _balance goes through Deposit or Withdraw, which means every mutation is validated in exactly one place. This is encapsulation's real payoff: the class's invariants (balance never negative, amounts always positive) are guaranteed by the class itself, not by the discipline of every caller.

C#'s access modifiers, and what each one actually controls

public class Example
{
    public int Public;        // accessible from anywhere
    private int Private;      // accessible only within this class
    protected int Protected;  // accessible within this class AND subclasses
    internal int Internal;    // accessible anywhere within the same assembly
    protected internal int ProtectedInternal; // union: subclasses OR same assembly
    private protected int PrivateProtected;   // intersection: subclasses WITHIN the same assembly
}
Enter fullscreen mode Exit fullscreen mode

C# gives finer-grained control than just "public or private" — protected is what makes controlled inheritance (Section 3) possible, letting a subclass reach into a base class's internals that outside callers can't touch, while internal is commonly used to expose implementation details within a library or assembly boundary without making them part of its public API surface.

Auto-implemented properties: encapsulation with less ceremony

public class Person
{
    public string Name { get; set; }          // full get/set, still technically encapsulated
    public int Age { get; private set; }       // readable anywhere, settable only inside this class
    public Guid Id { get; } = Guid.NewGuid();   // set only at declaration or in a constructor — immutable after
}
Enter fullscreen mode Exit fullscreen mode

C#'s auto-property syntax generates a hidden backing field automatically, so you get encapsulation's benefits (a controlled access point rather than a raw public field) without hand-writing a backing field and accessor methods for the common case — worth knowing that { get; set; } alone doesn't add any real validation logic; it's genuinely equivalent to a public field until you add a body or restrict the setter, as the Age and Id examples do above.


3. Inheritance

The problem inheritance solves: duplicated logic across related types

// ❌ Without inheritance, SavingsAccount and CheckingAccount duplicate Balance,
//    Deposit, and Withdraw entirely, with no shared type to treat them uniformly
Enter fullscreen mode Exit fullscreen mode

Inheritance lets you factor out what multiple related types have in common into a single base class, so that shared logic exists in exactly one place and each derived type only needs to add or override what's genuinely different about it.

Base and derived classes

public class Account
{
    protected decimal Balance { get; set; }

    public void Deposit(decimal amount) => Balance += amount;

    public virtual void Withdraw(decimal amount)
    {
        if (amount > Balance) throw new InvalidOperationException("Insufficient funds.");
        Balance -= amount;
    }
}

public class CheckingAccount : Account
{
    public decimal OverdraftLimit { get; set; }

    public override void Withdraw(decimal amount)
    {
        if (amount > Balance + OverdraftLimit)
            throw new InvalidOperationException("Exceeds overdraft limit.");
        Balance -= amount; // allowed to go negative, up to the overdraft limit
    }
}

public class SavingsAccount : Account
{
    public decimal InterestRate { get; set; }

    public void ApplyInterest() => Balance += Balance * InterestRate;
}
Enter fullscreen mode Exit fullscreen mode

CheckingAccount and SavingsAccount both inherit Balance and Deposit from Account — that logic exists exactly once. CheckingAccount overrides Withdraw to allow a controlled overdraft, while SavingsAccount adds an entirely new method, ApplyInterest, that has no equivalent in the base class. This is the "is-a" relationship inheritance models: a CheckingAccount is an Account, with some specialized behavior.

Constructors and base()

public class Account
{
    public string Owner { get; }
    protected decimal Balance { get; set; }

    public Account(string owner, decimal openingBalance)
    {
        Owner = owner;
        Balance = openingBalance;
    }
}

public class CheckingAccount : Account
{
    public decimal OverdraftLimit { get; }

    public CheckingAccount(string owner, decimal openingBalance, decimal overdraftLimit)
        : base(owner, openingBalance) // explicitly invokes the base class's constructor
    {
        OverdraftLimit = overdraftLimit;
    }
}
Enter fullscreen mode Exit fullscreen mode

A derived class's constructor must ensure the base class is properly initialized — : base(owner, openingBalance) explicitly calls Account's constructor before CheckingAccount's own constructor body runs, guaranteeing Owner and Balance are set correctly regardless of which derived type is being constructed.

sealed: explicitly forbidding further inheritance

public sealed class ImmutableAuditRecord
{
    public Guid Id { get; }
    public DateTimeOffset Timestamp { get; }
    // no further subclassing allowed — sealed classes cannot be inherited from
}
Enter fullscreen mode Exit fullscreen mode

Marking a class sealed is a deliberate design decision, not an oversight — it tells both the compiler and future maintainers that this type's behavior is meant to be complete and final, which also gives the JIT compiler more opportunity to optimize calls to it (a sealed class's methods can't be overridden, so calls don't need virtual dispatch, Section 4).


4. Polymorphism

The problem polymorphism solves: type-checking branches instead of dispatch

// ❌ Without polymorphism, calling code needs to know every concrete type
//    and branch on it explicitly — brittle, and grows worse with every new shape type
public double GetArea(object shape)
{
    if (shape is Circle c) return Math.PI * c.Radius * c.Radius;
    if (shape is Square s) return s.Side * s.Side;
    if (shape is Triangle t) return 0.5 * t.Base * t.Height;
    throw new NotSupportedException();
}
Enter fullscreen mode Exit fullscreen mode

Every time a new shape type is added, this method — and every other method with a similar type-check chain scattered through the codebase — needs to be found and updated. Polymorphism moves that responsibility onto each type itself.

Runtime polymorphism via virtual / override

public abstract class Shape
{
    public abstract double GetArea(); // no implementation here — each subclass MUST provide one
}

public class Circle : Shape
{
    public double Radius { get; set; }
    public override double GetArea() => Math.PI * Radius * Radius;
}

public class Square : Shape
{
    public double Side { get; set; }
    public override double GetArea() => Side * Side;
}

// Calling code doesn't need to know or care which concrete type it has
List<Shape> shapes = new() { new Circle { Radius = 2 }, new Square { Side = 3 } };
foreach (var shape in shapes)
{
    Console.WriteLine(shape.GetArea()); // each shape computes its OWN area correctly
}
Enter fullscreen mode Exit fullscreen mode

At runtime, calling shape.GetArea() invokes whichever concrete implementation actually matches the object's real type — this is virtual dispatch, and it's what "polymorphism" concretely means in C#: the same line of calling code (shape.GetArea()) produces different, type-appropriate behavior depending on what shape actually is underneath.

virtual and override — and the subtle bug new introduces

public class Base
{
    public virtual void Speak() => Console.WriteLine("Base speaking");
}

public class Derived : Base
{
    public override void Speak() => Console.WriteLine("Derived speaking"); // correct: participates in virtual dispatch
}

public class WrongDerived : Base
{
    public new void Speak() => Console.WriteLine("WrongDerived speaking"); // HIDES, doesn't override
}

Base b1 = new Derived();
b1.Speak(); // "Derived speaking" — correct polymorphic dispatch

Base b2 = new WrongDerived();
b2.Speak(); // "Base speaking" — the NEW method is hidden when accessed through a Base reference!
Enter fullscreen mode Exit fullscreen mode

This is a genuinely common, subtle C# bug: new (method hiding) looks similar to override in that both let a derived class provide its own version of a method, but new does NOT participate in virtual dispatch — calling the method through a base-class-typed reference invokes the base class's version, not the derived one, which is almost never what a developer intended when reaching for new instead of override.

Compile-time polymorphism: method overloading

public class Calculator
{
    public int Add(int a, int b) => a + b;
    public double Add(double a, double b) => a + b;
    public int Add(int a, int b, int c) => a + b + c;
}
Enter fullscreen mode Exit fullscreen mode

This is a second, distinct form of polymorphism C# supports — overloading — where multiple methods share a name but differ in parameter types or count, and the compiler picks the right one at compile time based on the arguments provided, as opposed to override's runtime dispatch based on the object's actual type.


5. Abstraction

The problem abstraction solves: forcing callers to understand implementation detail they shouldn't need to

// ❌ Without abstraction, calling code needs to know HOW a payment is processed,
//    not just THAT it should be processed
if (paymentType == "CreditCard") { /* raw card processing logic inline */ }
else if (paymentType == "PayPal") { /* raw PayPal API calls inline */ }
Enter fullscreen mode Exit fullscreen mode

Abstraction is about defining what something does without exposing how — giving callers a stable, simple contract to depend on, while the messy implementation detail behind that contract stays hidden and free to change.

Abstract classes: a partial implementation, with required gaps

public abstract class PaymentProcessor
{
    // Concrete, shared behavior every processor gets for free
    public void LogTransaction(decimal amount) => Console.WriteLine($"Processing {amount:C}");

    // Abstract — no implementation here; EVERY concrete subclass must supply one
    public abstract bool ProcessPayment(decimal amount);

    // A template method combining both, per this pattern's common real-world use
    public bool Execute(decimal amount)
    {
        LogTransaction(amount);
        return ProcessPayment(amount);
    }
}

public class CreditCardProcessor : PaymentProcessor
{
    public override bool ProcessPayment(decimal amount)
    {
        // actual card network integration detail lives here, hidden from callers
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

An abstract class can mix concrete, shared implementation (LogTransaction, Execute) with abstract members that have no body and must be implemented by any concrete subclass (ProcessPayment) — you cannot do new PaymentProcessor() directly; only a fully-implemented concrete subclass can be instantiated. This is abstraction and inheritance working together: shared behavior is reused, while the genuinely varying part is forced to be supplied by whoever specializes it.

Interfaces: pure abstraction, no implementation (mostly)

public interface IPaymentProcessor
{
    bool ProcessPayment(decimal amount);
}

public class PayPalProcessor : IPaymentProcessor
{
    public bool ProcessPayment(decimal amount)
    {
        // PayPal-specific implementation
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

An interface defines a contract — a set of members any implementing class must provide — with (traditionally) no implementation at all. It's a purer form of abstraction than an abstract class: there's no shared state or behavior to inherit, only a promise about what methods will exist and what they'll return, which is exactly what Section 6 explores as the key distinguishing factor between the two.


6. Interfaces vs. Abstract Classes in C

The classic distinguishing rule, and why it's a genuine design decision

Abstract class: models an "IS-A" relationship with SHARED STATE/BEHAVIOR —
  use when subclasses genuinely share meaningful implementation, not just a signature.
Interface: models a "CAN-DO" capability — use when unrelated types need to
  support the same operation without sharing any implementation or common ancestry.
Enter fullscreen mode Exit fullscreen mode

A CheckingAccount and SavingsAccount sharing a Balance field and Deposit logic is a case for an abstract base class — real, non-trivial state and behavior is genuinely shared. A Duck and an Airplane both being able to Fly() share no meaningful state or implementation whatsoever — that's a case for an interface (IFlyable), since forcing them into a shared base class would be a false "is-a" relationship just to get the polymorphism benefit.

Multiple interface implementation, vs. C#'s single inheritance rule

public interface IFlyable { void Fly(); }
public interface ISwimmable { void Swim(); }

// ❌ C# does NOT allow: public class Duck : Bird, WaterAnimal  — only ONE base class
// ✅ But a class CAN implement multiple interfaces:
public class Duck : Bird, IFlyable, ISwimmable
{
    public void Fly() => Console.WriteLine("Duck flying");
    public void Swim() => Console.WriteLine("Duck swimming");
}
Enter fullscreen mode Exit fullscreen mode

C# deliberately restricts a class to a single base class (avoiding the "diamond problem" ambiguity that multiple class inheritance can create in languages that allow it, like C++), but a class can implement any number of interfaces — this is often the deciding factor in choosing an interface over an abstract class: if a type genuinely needs to satisfy several unrelated contracts at once, interfaces are the only mechanism that allows it.

Default interface methods (C# 8+): interfaces can now carry some implementation

public interface ILogger
{
    void Log(string message);

    // Default implementation — implementing classes get this for free unless they override it
    void LogError(string message) => Log($"ERROR: {message}");
}
Enter fullscreen mode Exit fullscreen mode

A more recent C# addition worth knowing about: interfaces can now include a default method body, letting an interface evolve (adding a new member) without breaking every class that already implements it — this blurs the classical "interfaces have zero implementation" rule somewhat, but the core distinction (no shared state, and single inheritance still doesn't apply to interfaces) remains intact.


7. SOLID Principles: OOP Design Discipline

Why SOLID exists — the four pillars alone don't prevent bad design

Encapsulation, Inheritance, Polymorphism, and Abstraction are LANGUAGE
  MECHANISMS — they tell you what's possible, not how to use it well. SOLID
  is a set of five design guidelines for using those mechanisms in ways
  that keep a codebase maintainable as it grows.
Enter fullscreen mode Exit fullscreen mode

It's entirely possible to write technically "correct" OOP code — classes, inheritance, interfaces all present — that's still a tangled, hard-to-change mess. SOLID (a widely-used mnemonic in the C# and broader OOP community) names five specific failure patterns to design against.

S — Single Responsibility Principle

// ❌ This class has at least THREE reasons to change: validation rules,
//    persistence mechanism, and email formatting
public class UserManager
{
    public bool ValidateUser(User u) { /* ... */ return true; }
    public void SaveToDatabase(User u) { /* ... */ }
    public void SendWelcomeEmail(User u) { /* ... */ }
}

// ✅ Each class has exactly one reason to change
public class UserValidator { public bool Validate(User u) { /* ... */ return true; } }
public class UserRepository { public void Save(User u) { /* ... */ } }
public class WelcomeEmailSender { public void Send(User u) { /* ... */ } }
Enter fullscreen mode Exit fullscreen mode

A class should have one, and only one, reason to change — UserManager above would need to be modified for a validation rule change, a database migration, and an email template update, meaning three unrelated teams or concerns all collide in one file.

O — Open/Closed Principle

// ✅ Open for EXTENSION (add a new IDiscount implementation),
//    closed for MODIFICATION (never touch existing discount classes to add a new one)
public interface IDiscount { decimal Apply(decimal price); }
public class PercentageDiscount : IDiscount { public decimal Apply(decimal price) => price * 0.9m; }
public class FlatDiscount : IDiscount { public decimal Apply(decimal price) => price - 10m; }

public class Checkout
{
    public decimal CalculateTotal(decimal price, IDiscount discount) => discount.Apply(price);
}
Enter fullscreen mode Exit fullscreen mode

A class should be open for extension but closed for modification — adding a NewCustomerDiscount here means writing a new class implementing IDiscount, never editing Checkout or any existing discount class, which is precisely the flexibility Section 4's polymorphism was built to enable.

L — Liskov Substitution Principle

// ❌ Classic LSP violation: Square "is-a" Rectangle mathematically,
//    but substituting one for the other breaks caller expectations
public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }
    public int Area => Width * Height;
}

public class Square : Rectangle
{
    public override int Width { get => base.Width; set { base.Width = value; base.Height = value; } }
    public override int Height { get => base.Height; set { base.Width = value; base.Height = value; } }
}

// Code written against Rectangle assumes setting Width doesn't affect Height —
// substituting a Square silently breaks that assumption
Rectangle r = new Square();
r.Width = 5; r.Height = 10;
Console.WriteLine(r.Area); // a caller expecting 5×10=50 gets 10×10=100 instead — surprising, LSP violated
Enter fullscreen mode Exit fullscreen mode

A subclass should be substitutable for its base class without breaking the correctness of code written against the base class — this is the classic textbook example specifically because it looks like a reasonable "is-a" relationship (a square genuinely is a special rectangle, mathematically) while still violating the behavioral contract callers reasonably expect from Rectangle.

I — Interface Segregation Principle

// ❌ A fat interface forces implementers to support methods they don't need
public interface IWorker { void Work(); void Eat(); void Sleep(); }
public class RobotWorker : IWorker
{
    public void Work() { /* ... */ }
    public void Eat() => throw new NotSupportedException(); // a robot doesn't eat!
    public void Sleep() => throw new NotSupportedException();
}

// ✅ Smaller, focused interfaces — implement only what's actually relevant
public interface IWorkable { void Work(); }
public interface IFeedable { void Eat(); }
public class RobotWorker : IWorkable { public void Work() { /* ... */ } }
Enter fullscreen mode Exit fullscreen mode

Clients shouldn't be forced to depend on methods they don't use — a NotSupportedException in an interface implementation is a strong, reliable smell that the interface itself is too broad and should be split.

D — Dependency Inversion Principle

// ❌ OrderService depends directly on a CONCRETE class — hard to test, hard to swap
public class OrderService
{
    private readonly SqlServerRepository _repo = new(); // tightly coupled to one specific implementation
}

// ✅ OrderService depends on an ABSTRACTION — the concrete implementation is supplied from outside
public class OrderService
{
    private readonly IOrderRepository _repo;
    public OrderService(IOrderRepository repo) => _repo = repo; // constructor injection
}
Enter fullscreen mode Exit fullscreen mode

High-level modules shouldn't depend on low-level implementation details directly; both should depend on abstractions — this is what makes unit testing OrderService in isolation possible (substitute a fake/mock IOrderRepository) and what makes swapping the actual data store later a contained change rather than a rewrite, directly building on Section 6's interface-as-contract discussion.


8. Composition Over Inheritance

Why inheritance, used too eagerly, becomes a liability

A deep inheritance hierarchy (Animal → Bird → FlyingBird → Eagle → BaldEagle)
  tightly couples every level to the ones above it — a change to Animal can
  ripple through five levels of subclasses, and a subclass inherits EVERYTHING
  from its parent whether or not it actually makes sense (a Penguin inheriting
  Fly() from FlyingBird is the textbook example of this going wrong).
Enter fullscreen mode Exit fullscreen mode

This is one of the most common real-world OOP pitfalls: reaching for inheritance by default because it's the most familiar of the four pillars, even when the actual relationship between two types is better modeled a different way — inheritance creates a genuinely tight coupling between base and derived classes that's easy to underestimate until a hierarchy is a few levels deep and hard to change safely.

Composition: building behavior by containing objects, not extending classes

// Instead of inheriting flight behavior, a Bird HAS a flight strategy
public interface IFlightBehavior { void Fly(); }
public class CanFly : IFlightBehavior { public void Fly() => Console.WriteLine("Flying!"); }
public class CannotFly : IFlightBehavior { public void Fly() => Console.WriteLine("Can't fly."); }

public class Bird
{
    private readonly IFlightBehavior _flightBehavior;
    public Bird(IFlightBehavior flightBehavior) => _flightBehavior = flightBehavior;
    public void PerformFly() => _flightBehavior.Fly();
}

var eagle = new Bird(new CanFly());
var penguin = new Bird(new CannotFly()); // no awkward override-to-throw-an-exception needed
Enter fullscreen mode Exit fullscreen mode

This is the well-known "favor composition over inheritance" principle in practice — instead of Penguin : FlyingBird and then having to override Fly() to do something nonsensical (or throw), Bird has a flight behavior injected into it, and swapping that behavior is just passing a different object, no class hierarchy change required. Composition models "has-a" relationships, as a genuine complement to inheritance's "is-a" — not a replacement for it, but the frequently better default when the relationship isn't a clean, stable "is-a" all the way down.

When inheritance is still the right tool

Inheritance remains the right choice when the "is-a" relationship is
  genuinely stable and the subclass truly is a specialization of the base,
  not just a type that happens to share a FEW methods — Section 3's
  CheckingAccount/SavingsAccount example is a legitimate case, since every
  checking account genuinely IS an account, unconditionally, forever.
Enter fullscreen mode Exit fullscreen mode

The composition-over-inheritance principle isn't "never use inheritance" — it's "don't reach for inheritance as the default without checking whether the relationship is genuinely, stably an 'is-a' one," which Section 3's account hierarchy passes and the penguin/flying-bird example above fails.


9. Records and Value-Based Equality (C#'s Modern Addition)

Why classes' default equality is reference equality, and why that's often not what's wanted

public class Point { public int X { get; set; } public int Y { get; set; } }

var p1 = new Point { X = 1, Y = 2 };
var p2 = new Point { X = 1, Y = 2 };
Console.WriteLine(p1 == p2); // false — classes compare by REFERENCE by default, not by value
Enter fullscreen mode Exit fullscreen mode

By default, two class instances are only "equal" if they're literally the same object in memory — for a simple data-holder type where two instances with the same field values genuinely represent the same logical thing, this default is usually surprising and unwanted.

record types: immutability and value equality built in

public record Point(int X, int Y); // a record — immutable by default, value-based equality built in

var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1 == p2); // true — records compare by VALUE automatically

var p3 = p1 with { Y = 99 }; // non-destructive mutation: creates a NEW record, p1 is untouched
Enter fullscreen mode Exit fullscreen mode

Introduced in C# 9, record types are a modern, deliberate complement to classes specifically for value-oriented, immutable data — they get value-based equality, a generated ToString(), and the with expression for non-destructive updates, all without hand-writing Equals/GetHashCode overrides. Records still support inheritance and can implement interfaces, so the earlier pillars still apply to them — they're best understood as "classes with better defaults for data-centric types," not a wholesale alternative to OOP.


10. Common Pitfalls

Pitfall Why it hurts Better approach
Public fields instead of properties with validation No single place enforces an object's invariants; any code can set invalid state Private fields behind properties/methods that validate on every mutation (Section 2)
Using new (method hiding) instead of override Silently breaks polymorphic dispatch when accessed through a base-class reference Always use virtual/override for methods meant to participate in runtime polymorphism
Deep inheritance hierarchies for convenience Tight coupling across many levels; a base class change ripples unpredictably; forces unrelated behavior onto subclasses Favor composition for "has-a" relationships; reserve inheritance for genuinely stable "is-a" relationships
Fat interfaces with unrelated methods Implementers are forced to support methods irrelevant to them, often via NotSupportedException Split into smaller, focused interfaces per the Interface Segregation Principle
Subclassing purely for code reuse, ignoring behavioral correctness Violates Liskov Substitution — a subclass that "is-a" base type mathematically can still break caller assumptions Verify substitutability, not just structural/type compatibility, before subclassing
Depending on concrete classes instead of interfaces/abstractions Hard to unit test in isolation; swapping an implementation later requires a wide-reaching change Depend on abstractions (interfaces); inject concrete implementations via the constructor
Treating record and class as interchangeable Records default to value equality and immutability, which can silently change behavior if swapped in for an existing class Choose record deliberately for immutable, value-oriented data; class for identity-based, mutable objects
Overusing abstract class when types share no real implementation Forces an artificial base class purely to get polymorphism, adding coupling with no real reuse benefit Use an interface when types only share a capability/contract, not actual state or behavior

Quick Reference Table

Concept C# Mechanism Purpose
Encapsulation private fields + public properties/methods, access modifiers Centralizes state mutation so invariants are enforced in one place
Inheritance class Derived : Base, base() Reuses shared state/behavior across a genuine "is-a" hierarchy
Polymorphism (runtime) virtual / override, interfaces Lets calling code invoke type-appropriate behavior without type-checking
Polymorphism (compile-time) Method overloading Lets one method name resolve differently based on argument types at compile time
Abstraction abstract class, interface Exposes a stable contract while hiding implementation detail behind it
SOLID Design discipline, not a language feature Keeps OOP mechanisms from producing tangled, hard-to-change designs as code grows
Composition Constructor-injected interface-typed fields Models "has-a" relationships, avoiding brittle deep inheritance
record record Type(...) Value-based equality and immutability by default, for data-centric types

Conclusion

The four pillars — Encapsulation, Inheritance, Polymorphism, and Abstraction — are the language mechanisms C# gives you to build object-oriented code, but knowing the mechanisms is only the starting point; how well they're actually used is what separates maintainable OOP from a tangled mess of classes that happen to compile. Encapsulation and abstraction both hide complexity (data and implementation detail, respectively); inheritance and polymorphism both enable reuse and flexibility across related types — and modern C# practice, captured in SOLID and the composition-over-inheritance principle, exists specifically to keep those four mechanisms from being misapplied: reaching for inheritance when composition would model the relationship more honestly, building interfaces too broad for what any single implementer actually needs, or coupling high-level code directly to low-level implementation details instead of the abstractions those details sit behind.

Records are worth knowing as C#'s own acknowledgment that classical, mutable, reference-equality-by-default OOP isn't always the right fit — for genuinely value-oriented data, C# now gives you a purpose-built alternative that still plays by the same inheritance and interface rules, rather than asking you to bend class into an immutable shape it wasn't originally designed for. Understanding OOP in C# well means knowing not just what each pillar lets you do, but when reaching for it is actually the right call.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the deep-inheritance-hierarchy-that-became-unmaintainable story that taught composition-over-inheritance better than any diagram ever could.

Top comments (0)