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.
What principles is OOP built on?
Section titled “What principles is OOP built on?”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 theobjectoverload, not thestringone: the choice is made by static type. - Leaking mutable state. A property returning the internal
List<T>breaks encapsulation: the caller can modify it. ReturnIReadOnlyList<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+) readprivatefields.
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,
dynamicor 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.
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 TConsole.WriteLine(Format.Of(5)); // int 5: ad hoc — overload by argument typeConsole.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 detailsabstract class Shape { public abstract double Area(); }
// Inheritance: Rect is a Shape with its own Areaclass Rect(double w, double h) : Shape { public override double Area() => w * h; }
// Encapsulation: the field is hidden, the constructor checks the "side > 0" invariantclass 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}";}- How is encapsulation different from data hiding? — Hiding is a means; the goal of encapsulation is to keep data and the rules for changing it in one place.
- How is overloading different from overriding? — The compiler picks an overload by static types; the runtime picks an override by object type.
- How is
privatein C# different fromprivatein Delphi? — In Delphi it is visible to the whole unit; the equivalent of C#’sprivatethere isstrict private. - Can you write object-oriented code without class inheritance? — Yes: interfaces and composition; that is how Go and Rust work.
- What is double dispatch? — Choosing a method by the types of two objects; in C#, Visitor or pattern matching on a tuple.
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?”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:
Carholds anEnginein 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
AddandAddRange, but the baseAddRangecallsAddinternally, so items are counted twice. The example below shows it. - Square and rectangle.
Square : Rectanglebreaks 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,newin 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>withprotected virtual InsertItem, notList<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
internaltypes, andsealedhelps 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
protectedfields and chains ofbase.calls.
Takeaway: a class open to inheritance is a public contract; if you did not design it for that, seal it.
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 builtclass 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 Bagclass 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.
- Why is
Square : Rectanglea bad hierarchy? — A square cannot handle the sides changing independently, so the subtype cannot replace the base type (LSP). - Why not inherit from
List<T>? — Its methods are not virtual and the whole API leaks out;Collection<T>exists for extension. - How is composition different from aggregation? — With composition the part lives and dies with its owner; with aggregation it exists independently.
- How do you delegate without boilerplate? — Default interface methods, a source generator, or a decorator through DI.
- When would you open a class for inheritance? — When it is a framework extension point, with documented
virtualmethods and their call order.
What are cohesion and coupling, and how do you assess them?
Section titled “What are cohesion and coupling, and how do you assess them?”What it is
- Cohesion — how much a class’s members belong to one task. An
OrderServicethat 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.Citybreaks 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.
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 dataConsole.WriteLine(customer.TryPay(30m)); // TrueConsole.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; }}- Does a LINQ chain violate the Law of Demeter? — No, it transforms data rather than walking the internal structure of objects.
- What is wrong with a
boolparameter on a public method? — It is control coupling: the caller drives the method’s logic; two methods are better. - Which code smells point to low cohesion? — Divergent change (a class changes for different reasons),
UtilsandManagerclasses. - Should Tell, Don’t Ask eliminate all getters? — No, Fowler warns against that: querying data is fine; the rule is to keep decisions next to the data.
- How do you spot hidden coupling? — From git history: files that often change together.
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#?”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 viaorder with { Total = 0 }. - Processing: LINQ
Where,Select,Sum. - Choosing by kind of data: a
switchexpression.
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
recordgenerates value equality,ToStringand copying viawith. Properties of a positionalrecord classareinit, 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
withmakes a shallow copy: aList<T>inside the record stays shared between the copy and the original.- A
recordwith 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
withallocates 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 indotnet-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.
Shape[] shapes = [new Rect(2, 3), new Square(2)];
// A new operation is a new function; the types stay unchangedConsole.WriteLine(shapes.Sum(Area)); // 10Console.WriteLine(string.Join(", ", shapes.Select(Name))); // rect, square
// Immutability: with creates a modified copyvar 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 argumentstatic 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;const int N = 1_000_000;
// Object style: an array of references, each object separately on the heapvar 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 arrayvar 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 elementdecimal b = 0;foreach (var p in prices) b += p; // sequential memory readsConsole.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.
- What is the expression problem? — You cannot make adding both types and operations easy at once: OOP eases the first, a
switchover data the second. - Does
recordmake an object immutable? — Positional properties of arecord classareinit, but collections inside stay mutable, andwithcopies shallowly. - How is an anemic model different from the functional style? — Its data is mutable and has no invariants, and the logic in services still mutates state.
- When would you replace polymorphism with a
switch? — When the set of variants is closed and the operations on it are many and growing. - What is functional core, imperative shell? — Logic in pure functions; I/O and state in a thin outer layer.