Skip to content

OOP

The foundation of object-oriented design: the principles OOP rests on, why inheritance gets overused, how to judge the quality of dependencies between classes, and where objects are better replaced by functions and immutable data. Interviewers check this not by definitions but by how well you explain a design decision in code.

Before this topic: The C# language.

Junior+
Answer

What it is

Principle Essence In C#
Encapsulation state is hidden and changes only through methods private fields, properties with private set
Abstraction only the essential contract is visible interface, abstract class
Inheritance a new class gets the base class members and can extend them class Circle : Shape
Polymorphism one call, different behavior depending on type virtual/override, interfaces

Why

Code that works with the Shape abstraction does not change when a new shape appears. Encapsulation stops outside code from putting the object into an invalid state, such as a square with a negative side.

Common mistake

Calling “a private field plus public get and set” encapsulation. If the setter validates nothing, outside code still changes the state however it likes.

Takeaway: encapsulation and abstraction hide details; inheritance and polymorphism let you work with different types through a shared contract.

How it works

Polymorphism is the ability of one piece of code to work with different types. There are three kinds:

Kind Mechanism in C# When it is chosen
Subtype virtual/override, interfaces at run time, by object type
Parametric generics: List<T>, Max<T> at compile time, one code for any T
Ad hoc method and operator overloading at compile time, by static argument types

Abstraction or encapsulation

  • Abstraction answers “what does the object do”: which operations the contract has.
  • Encapsulation answers “how is it hidden”: fields are closed and invariants are checked in one place.

Pitfalls

  • Overloading is not virtual. object o = "x"; Format(o) calls the object overload, not the string one: the choice is made by static type.
  • Leaking mutable state. A property returning the internal List<T> breaks encapsulation: the caller can modify it. Return IReadOnlyList<T> or a copy.
  • Inheriting for code. If a subtype cannot stand in for its base type, polymorphic code breaks — that is the Liskov substitution principle from SOLID.

Rule of thumb

First decide which contract clients see, then hide everything else.

Takeaway: polymorphism is subtype, parametric or ad hoc, and only the first is resolved at run time.

Under the hood

  • Subtypes. A virtual call goes through the object’s method table; an interface call goes through a lookup of the implementation for the concrete type. Details are in “The C# language”.
  • Generics. The JIT generates one shared body for reference types and a separate one for each value type. That is why List<int> works without boxing.
  • Overloading. The compiler picks a method by overload resolution rules and emits a concrete call in IL. Nothing is chosen at run time.
  • Encapsulation is enforced by the compiler and the runtime, but it does not protect data: reflection and UnsafeAccessor (.NET 8+) read private fields.

Trade-offs

  • Alan Kay’s definition — messages, hidden state, late binding — shows that inheritance is not required for OOP. Go and Rust do without class inheritance.
  • A virtual call is flexible but blocks inlining. Generics over value types give direct calls, but every specialization is separate machine code.
  • C# has no built-in double dispatch, that is, choosing a method by the types of two objects. It is done with Visitor, dynamic or pattern matching on a pair of values.

How to check in production

BenchmarkDotNet with [DisassemblyDiagnoser] and [MemoryDiagnoser] shows virtual calls and boxing on a hot path.

Takeaway: the “four pillars” are a handy scheme; a Senior is expected to know how the kinds of polymorphism differ and what each costs.

Example
Shape[] shapes = [new Rect(2, 3), new Square(2)];
foreach (var shape in shapes)
Console.WriteLine(shape.Area()); // 6, 4: subtype polymorphism
Console.WriteLine(Max(3, 7)); // 7: parametric — one code for any T
Console.WriteLine(Format.Of(5)); // int 5: ad hoc — overload by argument type
Console.WriteLine(Format.Of("x")); // string x
static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;
// Abstraction: what any shape can do, without details
abstract class Shape { public abstract double Area(); }
// Inheritance: Rect is a Shape with its own Area
class Rect(double w, double h) : Shape { public override double Area() => w * h; }
// Encapsulation: the field is hidden, the constructor checks the "side > 0" invariant
class Square : Shape
{
private readonly double _side;
public Square(double side)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(side);
_side = side;
}
public override double Area() => _side * _side;
}
static class Format
{
public static string Of(int x) => $"int {x}";
public static string Of(string x) => $"string {x}";
}
Middle+

Why “favor composition over inheritance”, and when is inheritance still the right call?

Section titled “Why “favor composition over inheritance”, and when is inheritance still the right call?”
Answer

What it is

  • Inheritance is an “is-a” relationship: Dog : Animal. The subclass gets everything the base class has.
  • Composition is a “has-a” relationship: Car holds an Engine in a field and calls its methods.

Why favor composition

  • In C# there is one base class, but any number of component fields.
  • A component can be swapped: pass another object through the constructor, or a stub in a test.
  • Changes inside a component do not affect its user as long as its public contract stays the same.

When inheritance fits

  • The subtype fully replaces the base type: ArgumentNullException : ArgumentException.
  • The framework expects a subclass: ControllerBase, BackgroundService, Exception.

Common mistake

Inheriting to get ready-made code: class OrderList : List<Order>. Clear and RemoveAt leak out, and you cannot intercept Add because List<T> methods are not virtual.

Takeaway: inheritance is for “is-a”, composition is for “uses”.

How it works

Aspect Inheritance Composition
When bound at compile time, for good when the object is created, can be swapped
What is visible protected members and the order of self-calls only the public contract
How many one base class any number of components

Pitfalls

  • Fragile base class. A subclass overrides Add and AddRange, but the base AddRange calls Add internally, so items are counted twice. The example below shows it.
  • Square and rectangle. Square : Rectangle breaks when code changes the width independently of the height: the subtype cannot stand in for the base type. That violates the Liskov substitution principle — LSP, covered in SOLID.
  • Hiding with new. If a base method is not virtual, new in the subclass does not replace it: a call through a base-type reference runs the old version.

When to choose what

  • Reuse behavior — composition and delegation.
  • Need a subtype for polymorphism — implement an interface.
  • Extend a class designed for it — inheritance: Collection<T> with protected virtual InsertItem, not List<T>.

Rule of thumb

Inherit interfaces; reuse implementation through composition.

Takeaway: inheritance couples you to the parent’s implementation, composition only to its contract.

Under the hood

  • Interface inheritance gives a subtype: the object can go wherever the base type is expected.
  • Implementation inheritance copies behavior along with its internal dependencies. Which base methods call which virtual ones is now part of the contract.
  • That is why Bloch (“Effective Java”) says: design and document a class for inheritance, or else prohibit it. In C# that means sealed.

Cost

  • Composition adds an object and an indirect call. It is usually negligible, and the JIT can inline a non-virtual call.
  • C# has no delegation syntax: forwarding methods are written by hand or generated by a source generator.

Trade-offs

  • Framework Design Guidelines (for public libraries): do not seal a class without a reason; an unsealed class with no virtual members is cheap extensibility.
  • Application code usually has no subclasses. CA1852 suggests sealing internal types, and sealed helps the JIT devirtualize calls.
  • A decorator adds behavior through composition, without a subclass for every combination.

How to check in production

  • The Depth of Inheritance metric in Visual Studio and rule CA1501 (five or more levels by default) find deep hierarchies.
  • In code review, look for protected fields and chains of base. calls.

Takeaway: a class open to inheritance is a public contract; if you did not design it for that, seal it.

Example
var inherited = new CountingBag();
inherited.AddRange(["a", "b"]);
Console.WriteLine(inherited.Added); // 4: the base AddRange calls Add itself
var composed = new CountingWrapper(new Bag());
composed.AddRange(["a", "b"]);
Console.WriteLine(composed.Added); // 2
class Bag
{
public List<string> Items { get; } = [];
public virtual void Add(string item) => Items.Add(item);
// Implementation detail: AddRange calls Add
public virtual void AddRange(string[] items) { foreach (var i in items) Add(i); }
}
// Inheritance: the result depends on how the base class is built
class CountingBag : Bag
{
public int Added { get; private set; }
public override void Add(string item) { Added++; base.Add(item); }
public override void AddRange(string[] items)
{ Added += items.Length; base.AddRange(items); }
}
// Composition: depends only on the public contract of Bag
class CountingWrapper(Bag inner)
{
public int Added { get; private set; }
public void Add(string item) { Added++; inner.Add(item); }
public void AddRange(string[] items)
{ Added += items.Length; inner.AddRange(items); }
}

If the author of Bag rewrites AddRange without calling Add, CountingBag starts counting correctly, while CountingWrapper does not notice the change: that is what a fragile base class looks like.

Middle+

What are cohesion and coupling, and how do you assess them?

Section titled “What are cohesion and coupling, and how do you assess them?”
Answer

What it is

  • Cohesion — how much a class’s members belong to one task. An OrderService that also sends emails and renders PDFs has low cohesion.
  • Coupling — how much a class knows about and depends on others. A class that creates new SqlConnection(...) itself is tightly coupled to SQL Server.

Why

With high cohesion and loose coupling a change stays in one class, and the class is easy to test on its own.

How to use

  • One class, one task; data and the methods that work on it live together.
  • Dependencies come through the constructor as abstractions.
  • Pass a method only what it needs, not the whole object.

Common mistake

Assuming interfaces automatically loosen coupling. An interface that mirrors every implementation detail couples just as tightly.

Takeaway: strong cohesion inside a class, loose coupling between classes.

How it works

Classic kinds of coupling (Myers, Constantine), from strongest to weakest:

Kind Example in C#
Content reflection changes another class’s private field
Common several classes write to a mutable static field
Control a flag changes behavior: Save(order, sendEmail: true)
Stamp the whole Order is passed when only Id is needed
Data only the needed values are passed

The worst cohesion is coincidental: Utils, Helpers. The best is functional: all members serve one task.

Law of Demeter and Tell, Don’t Ask

  • An object’s method calls methods only on the object itself, its parameters, objects it creates, and the object’s fields. order.Customer.Address.City breaks the rule: the code knows the whole structure.
  • LINQ chains and fluent builders are usually not counted as violations: they return the same type or new data rather than reaching into internals.
  • Tell, Don’t Ask: instead of “read the balance, check it, write it back” — wallet.TryWithdraw(amount).

Pitfalls

  • Feature Envy: a method uses another class’s data more than its own. It belongs in that class.
  • Shotgun surgery: one change requires edits in many classes — a sign of tight coupling.

Rule of thumb

Put behavior next to the data it changes.

Takeaway: the weaker the kind of coupling and the shorter the call chains, the more local your changes.

How to assess

Metric What it shows
Class Coupling (VS), CA1506 number of unique types a type or method depends on; default threshold 95 for a type, 40 for a method
LCOM (Chidamber, Kemerer) how much a class’s methods share its fields; growth means weak cohesion
Ca, Ce and instability (Martin) a module’s incoming and outgoing dependencies, I = Ce / (Ca + Ce)

CA1506 is off by default; enable it in .editorconfig.

Connascence

Meilir Page-Jones described dependency as connascence: two elements are connascent if changing one requires changing the other. Weak forms are name and type; strong forms are parameter position, meaning of “magic” values, algorithm, and timing. The rule: the stronger the form, the closer together the elements should be.

Trade-offs

  • Every interface and event loosens coupling at the cost of indirection: the code gets harder to read and debug.
  • DRY across modules creates coupling: a shared class starts changing for two different consumers.
  • Asynchronous communication removes temporal coupling, but not coupling to the data format.

How to check in production

  • Git history analysis: files that change in the same commits are coupled in hidden ways.
  • Architecture tests (NetArchTest, ArchUnitNET) forbid dependencies between layers and modules in CI.

Takeaway: metrics point at suspicious spots; the change history shows the real coupling.

Example
var customer = new Customer(new Wallet(100m));
// Ask: the caller knows how Customer and Wallet are built and decides itself
// if (customer.Wallet.Balance >= 30m) customer.Wallet.Balance -= 30m;
// Tell: ask for an action; the rule lives next to the data
Console.WriteLine(customer.TryPay(30m)); // True
Console.WriteLine(customer.TryPay(80m)); // False: 70 left
class Customer(Wallet wallet)
{
// Law of Demeter: Customer talks only to its own field
public bool TryPay(decimal amount) => wallet.TryWithdraw(amount);
}
class Wallet(decimal balance)
{
public decimal Balance { get; private set; } = balance;
public bool TryWithdraw(decimal amount)
{
if (amount <= 0 || amount > Balance) return false;
Balance -= amount;
return true;
}
}
Senior+

Where is OOP not the best choice, and how is it combined with the functional style in C#?

Section titled “Where is OOP not the best choice, and how is it combined with the functional style in C#?”
Answer

What it is

  • Object style — data and methods together; the object guards its own state.
  • Functional style — data is separate and immutable; functions take values and return new ones.
  • Pure function — the result depends only on the arguments, and there are no side effects: no writes to fields, files or a database.

Why

A pure function is easy to test: give input, check output, no mocks. Immutable data is safe to pass between threads.

How to use

  • Data: record Order(int Id, decimal Total), changes via order with { Total = 0 }.
  • Processing: LINQ Where, Select, Sum.
  • Choosing by kind of data: a switch expression.

Common mistake

Writing a “calculator” class with fields that must be set before calling Calculate(). The call order becomes a hidden rule; a function with parameters is simpler and more honest.

Takeaway: objects for state with rules, functions for computations over data.

When to choose what

Task Style
Entity with invariants: order, account object with methods
Calculation, transformation, validation pure function over a record
Collection processing pipeline LINQ
Handling a fixed set of variants switch expression over records

How it works

  • record generates value equality, ToString and copying via with. Properties of a positional record class are init, so they cannot change after creation.
  • Mutable logic stays at the edges: a controller reads data, calls the pure core and saves the result. Gary Bernhardt called this functional core, imperative shell.

Anemic model

An anemic model is entities as “bags of properties” with all logic in services. That is not the functional style: the data is mutable and invariants are unprotected. See Architecture styles for details.

Pitfalls

  • with makes a shallow copy: a List<T> inside the record stays shared between the copy and the original.
  • A record with a collection property compares the collection by reference, not by contents.

Rule of thumb

Entities with rules are classes, data and messages are records, computations are pure functions.

Takeaway: C# lets you mix styles; what matters is keeping mutable state in a few explicit places.

Under the hood

The expression problem (Wadler, 1998) is how to add both new data variants and new operations without editing existing code:

To add Class hierarchy record and switch
A new type a new class, old code untouched edit every switch
A new operation a new method in every class, or Visitor a new function

Visitor switches an OOP hierarchy into “functional” mode. Over an open hierarchy the C# compiler cannot prove a switch exhaustive, so a _ arm is needed. Union types in C# 15 (.NET 11, November 2026) declare a closed set of variants with exhaustiveness checking.

Cost

  • Immutability means copies: every with allocates a new object. ImmutableList<T> is tree-based, so indexing is O(log n).
  • A graph of small objects means references and memory scattered across the heap. On a hot path an array of structs, or separate arrays per field, is read sequentially and fits the CPU cache better.

Trade-offs

  • The data-oriented approach gives up encapsulation for performance, so keep it to hot spots found by a profiler.
  • For data that is read often and changed rarely there is FrozenDictionary (.NET 8+): slower to build, faster to read.

How to check in production

  • The cost of copies shows up in BenchmarkDotNet with [MemoryDiagnoser] and in the allocation rate in dotnet-counters.
  • Find hot paths with dotnet-trace or PerfView, not by eye.

Takeaway: pick the style by the axis of change: growing types — polymorphism, growing operations — data and functions, every nanosecond counts — data-oriented.

Example
Shape[] shapes = [new Rect(2, 3), new Square(2)];
// A new operation is a new function; the types stay unchanged
Console.WriteLine(shapes.Sum(Area)); // 10
Console.WriteLine(string.Join(", ", shapes.Select(Name))); // rect, square
// Immutability: with creates a modified copy
var wide = new Rect(2, 3) with { W = 4 };
Console.WriteLine(wide); // Rect { W = 4, H = 3 }
Console.WriteLine(wide == new Rect(4, 3)); // True: value equality
// Pure functions: the result depends only on the argument
static double Area(Shape s) => s switch
{
Rect r => r.W * r.H,
Square q => q.Side * q.Side,
_ => throw new ArgumentOutOfRangeException(nameof(s)) // a new type means editing every switch
};
static string Name(Shape s) => s switch { Rect => "rect", Square => "square", _ => "?" };
abstract record Shape;
sealed record Rect(double W, double H) : Shape;
sealed record Square(double Side) : Shape;
Data-oriented: an array of objects vs an array of values · Senior
const int N = 1_000_000;
// Object style: an array of references, each object separately on the heap
var orders = new OrderObj[N];
for (int i = 0; i < N; i++) orders[i] = new OrderObj(i, i % 7);
// Data-oriented: the field the loop needs sits contiguously in its own array
var prices = new decimal[N];
for (int i = 0; i < N; i++) prices[i] = i % 7;
decimal a = 0;
foreach (var o in orders) a += o.Price; // follows a reference for each element
decimal b = 0;
foreach (var p in prices) b += p; // sequential memory reads
Console.WriteLine(a == b); // True
sealed record OrderObj(int Id, decimal Price);

The result is the same; only a measurement shows the speed difference — BenchmarkDotNet on a realistic data size.