Architecture

OOP isn't old news: four ideas behind every system that survives change

Encapsulation, abstraction, inheritance and polymorphism as engineering decisions: what each one costs, what it buys, and where it makes or breaks a codebase.

The expensive problems in production are rarely syntax problems. They are rarely framework problems either. A missing await or a wrong config key gets caught in review, in tests, or in the first minutes after a deploy. The ones that cost weeks are boundary problems: a status that changed when it shouldn't have, a rule that exists in three places and disagrees with itself, a small change in one module that breaks another module nobody touched.

All of those come down to one question: who is allowed to change what? That is what object-oriented programming is about. Not class diagrams, not Animal and Dog. OOP is a set of tools for drawing boundaries in code and making them hold.

Encapsulation, abstraction, inheritance and polymorphism usually show up as definitions to recite in an interview. I find them more useful as decisions. Each one costs something, buys something, and shows its real value the day the requirements change. The examples below are in C#, but nothing here depends on the language.

Encapsulation: the object guards its own rules

Here is an order that any part of the system can edit:

C#
1// Before: anyone can put the order in an invalid state
2public class Order
3{
4    public List<OrderItem> Items { get; set; } = new();
5    public decimal Total { get; set; }
6    public OrderStatus Status { get; set; }
7}
8
9// In some handler, far away from the class
10order.Items.Add(item);
11order.Total += item.Price * item.Quantity;

Nothing in this class knows what a valid order looks like. The rule "you can't add items to a cancelled order" has to live somewhere, so it ends up in a handler, then in a second handler, then in a background job written by someone who never saw the first two. Each copy drifts a little. Sooner or later a cancelled order with a new item and a stale total reaches the warehouse.

Now the same order, guarding itself:

C#
1public class Order
2{
3    private readonly List<OrderItem> _items = new();
4    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
5    public OrderStatus Status { get; private set; } = OrderStatus.Open;
6    public decimal Total => _items.Sum(i => i.Price * i.Quantity);
7
8    public void AddItem(OrderItem item)
9    {
10        if (Status != OrderStatus.Open)
11            throw new InvalidOperationException("Only open orders accept items.");
12        if (item.Quantity <= 0)
13            throw new ArgumentOutOfRangeException(nameof(item), "Quantity must be positive.");
14        _items.Add(item);
15    }
16
17    public void Cancel()
18    {
19        if (Status == OrderStatus.Shipped)
20            throw new InvalidOperationException("A shipped order cannot be cancelled.");
21        Status = OrderStatus.Cancelled;
22    }
23}
Program.cs
1public static class Program
2{
3    public static void Main()
4    {
5        var order = new Order();
6        order.AddItem(new OrderItem("Keyboard", 250m, 1));
7        order.AddItem(new OrderItem("Mouse", 80m, 2));
8        Console.WriteLine($"Items: {order.Items.Count}, total: {order.Total:0.00}");
9
10        order.Cancel();
11        Console.WriteLine($"Status: {order.Status}");
12
13        try
14        {
15            order.AddItem(new OrderItem("Monitor", 1200m, 1));
16        }
17        catch (InvalidOperationException ex)
18        {
19            Console.WriteLine($"AddItem refused: {ex.Message}");
20        }
21
22        // order.Status = OrderStatus.Open;  // does not compile
23        Console.WriteLine($"Total is still {order.Total:0.00}");
24    }
25}
Terminal

The public setters are gone. The only ways to change an order are the operations the business actually has: add an item, cancel. Each one checks its own preconditions. Total is computed, so it can't go stale.

What it costs: more methods and less freedom. You can't set a field from a test or a migration script anymore; you go through the front door like everyone else. Some ORMs and serializers need a little configuration to work with private setters.

What it buys: the rule lives in one place. When it changes, you edit one method and every caller gets the new behavior. Code review gets easier too, because an invalid state is no longer something you hunt for in every handler. The class rules it out.

This is the pillar I'd defend hardest. In my experience, most "this should never happen" errors trace back to an object that let someone else edit its state.

Abstraction: depend on the what, not the how

Checkout needs to charge the customer. The direct way:

C#
1// Before: checkout knows every detail of one vendor
2public class CheckoutService
3{
4    public async Task<bool> PayAsync(Order order)
5    {
6        var client = new VendorPayClient(Environment.GetEnvironmentVariable("VENDOR_KEY"));
7        var response = await client.ChargeAsync(order.Total, "BRL");
8        return response.Status == "approved";
9    }
10}

This works until one of three things happens: the provider changes its SDK, the business adds a second provider for another region, or someone tries to write a test. Checkout knows the vendor's client class, its key, its currency string and the exact text of its status field. All of that is the "how". Checkout only cares about the "what": charge this order and tell me if it worked.

C#
1public interface IPaymentGateway
2{
3    Task<PaymentResult> ChargeAsync(Order order, CancellationToken ct);
4}
5
6public class CheckoutService(IPaymentGateway gateway)
7{
8    public Task<PaymentResult> PayAsync(Order order, CancellationToken ct) =>
9        gateway.ChargeAsync(order, ct);
10}
11
12// In tests: no network, no sandbox account, full control
13public class FakePaymentGateway(PaymentResult result) : IPaymentGateway
14{
15    public Task<PaymentResult> ChargeAsync(Order order, CancellationToken ct) =>
16        Task.FromResult(result);
17}
Program.cs
1public static class Program
2{
3    public static void Main()
4    {
5        var order = new Order { Total = 410m };
6
7        var yes = new FakePaymentGateway(new PaymentResult(true, "Approved"));
8        var approved = new CheckoutService(yes).PayAsync(order, default).Result;
9        Console.WriteLine($"Approved: {approved.Approved}, {approved.Message}");
10
11        var no = new FakePaymentGateway(new PaymentResult(false, "Card declined"));
12        var declined = new CheckoutService(no).PayAsync(order, default).Result;
13        Console.WriteLine($"Approved: {declined.Approved}, {declined.Message}");
14    }
15}
Terminal

What it costs: one interface, plus one adapter class per provider. It also costs a decision about what the interface should say. A lazy abstraction that mirrors the vendor SDK method by method buys nothing; the coupling just moved one file over. Write the interface in your domain's words (Order, PaymentResult), not the provider's.

What it buys: switching or adding a provider becomes a new adapter, not a change to checkout. Tests stop needing a network, a sandbox account and retries for flaky responses. With the fake above you can cover the declined path, the error path and the happy path in milliseconds. In practice, that is usually the difference between those paths being tested and not being tested at all.

Inheritance: the most powerful pillar and the most misused

Inheritance is the only pillar that ties a child to its parent's implementation, not just to its contract. That is where its power comes from, and also where the trouble starts. Discounts are a classic place to see it:

C#
1// Before: every new combination is a new subclass
2public class Discount
3{
4    public virtual decimal Apply(Order order) => order.Total;
5}
6
7public class SeasonalDiscount : Discount
8{
9    public override decimal Apply(Order order) => base.Apply(order) * 0.9m;
10}
11
12public class SeasonalVipDiscount : SeasonalDiscount
13{
14    public override decimal Apply(Order order) => base.Apply(order) - 20m;
15}
16
17// Next request: a coupon that stacks with seasonal but not with VIP?

Each class looks reasonable on its own. The tree does not. SeasonalVipDiscount depends on exactly how its parent does the math. If someone changes SeasonalDiscount to round its result, every VIP order changes price, and nothing in the VIP class shows it. Then the business asks for a coupon that stacks with the seasonal discount but not with VIP, and the hierarchy has no place for it. You either duplicate a branch or add a flag to the base class that half the children ignore.

This is the fragile base class problem. The way out is usually composition: small objects that each do one thing, combined at runtime.

C#
1public interface IDiscount { decimal Apply(decimal amount, Order order); }
2
3public record PercentOff(decimal Percent) : IDiscount
4{
5    public decimal Apply(decimal amount, Order order) => amount * (1 - Percent / 100);
6}
7
8public record FixedOff(decimal Value) : IDiscount
9{
10    public decimal Apply(decimal amount, Order order) => Math.Max(0, amount - Value);
11}
12
13public class DiscountPolicy(IEnumerable<IDiscount> discounts)
14{
15    public decimal Apply(Order order) =>
16        discounts.Aggregate(order.Total, (amount, d) => d.Apply(amount, order));
17}
18
19// Seasonal VIP: new DiscountPolicy([new PercentOff(10), new FixedOff(20)])
Program.cs
1public static class Program
2{
3    public static void Main()
4    {
5        var order = new Order { Total = 200m };
6
7        var inherited = new SeasonalVipDiscount();
8        Console.WriteLine($"Inheritance:       {inherited.Apply(order):0.00}");
9
10        var seasonalVip = new DiscountPolicy([new PercentOff(10), new FixedOff(20)]);
11        Console.WriteLine($"Seasonal + VIP:    {seasonalVip.Apply(order):0.00}");
12
13        // A combination the class tree had no place for
14        var withCoupon = new DiscountPolicy([new PercentOff(10), new PercentOff(5)]);
15        Console.WriteLine($"Seasonal + coupon: {withCoupon.Apply(order):0.00}");
16    }
17}
Terminal

What it costs: a bit more wiring. The combination now lives in configuration or in a factory, so you have to look there to know which discounts apply to an order.

What it buys: each rule is tested alone. New combinations are data, not new classes. And the dependency is out in the open: a policy lists the discounts it uses, instead of inheriting them through base.Apply where nobody looks.

I still use inheritance. It fits when the child really is a specialization that keeps every promise the parent makes, and when the hierarchy is shallow and owned by one team. Framework base classes like ControllerBase or BackgroundService are good examples. When I catch myself reaching for inheritance just to reuse a few lines, I take it as a sign that I want composition.

Polymorphism: replace the type switch with objects

Shipping cost is another rule that grows over time. It usually starts like this:

C#
1// Before: every new method means editing this switch (and its copies)
2public decimal ShippingCost(Order order) =>
3    order.ShippingMethod switch
4    {
5        ShippingMethod.Flat => 15m,
6        ShippingMethod.Express => 15m + order.Weight * 2.5m,
7        ShippingMethod.Pickup => 0m,
8        _ => throw new NotSupportedException(order.ShippingMethod.ToString())
9    };

One switch is fine. The problem is that it never stays alone. The same check on ShippingMethod shows up in the delivery estimate, in label printing, in the checkout screen. Adding a new option means finding every one of them, and with a _ default arm in each, the compiler won't help you.

C#
1public interface IShippingRule
2{
3    decimal CostFor(Order order);
4}
5
6public class FlatRateRule : IShippingRule
7{
8    public decimal CostFor(Order order) => 15m;
9}
10
11public class ExpressRule : IShippingRule
12{
13    public decimal CostFor(Order order) => 15m + order.Weight * 2.5m;
14}
15
16// A new PickupRule is a new class, not an edit here
17public decimal ShippingCost(Order order, IShippingRule rule) => rule.CostFor(order);
Program.cs
1public static class Program
2{
3    // The one place that maps a method to its rule (like a DI registration)
4    private static readonly Dictionary<ShippingMethod, IShippingRule> Rules = new()
5    {
6        [ShippingMethod.Flat] = new FlatRateRule(),
7        [ShippingMethod.Express] = new ExpressRule(),
8        [ShippingMethod.Pickup] = new PickupRule(),
9    };
10
11    public static void Main()
12    {
13        foreach (var method in Rules.Keys)
14        {
15            var order = new Order { ShippingMethod = method, Weight = 4m };
16            var cost = Shipping.ShippingCost(order, Rules[method]);
17            Console.WriteLine($"{method,-8} {cost:0.00}");
18        }
19    }
20}
Terminal

Each shipping method is now an object that knows its own cost. Adding pickup means writing a PickupRule and registering it. Something still has to map the order's shipping method to a rule, but that becomes one lookup (a dictionary or a DI registration) instead of a switch in every feature. When the delivery estimate needs the same distinction, it becomes another method on the rule, not another switch. The code that calculates shipping stays untouched. That is the payoff: a new rule does not touch old code, so the rules that already work have no reason to break.

What it costs: more types, and the logic is spread across files instead of sitting in one switch you can read top to bottom. For a closed set of two or three cases that never change, the switch is clearer, and I'd keep it. I move to objects when the set keeps growing, or when the same switch shows up in a second place.

From OOP to design

None of these four ideas is new. They are still central because most of what we call good design is built directly on top of them.

  • SOLID. The O (open/closed) is polymorphism: IShippingRule is open to new rules and closed to edits. The L (Liskov substitution) is what keeps inheritance honest: a child must keep every promise the parent makes. The D (dependency inversion) is abstraction: checkout depends on IPaymentGateway, not on a vendor.
  • Strategy. IShippingRule and IDiscount are the Strategy pattern. It is polymorphism with a name.
  • Dependency injection. A DI container exists to hand an IPaymentGateway to a class that shouldn't care which one it got. Without the abstraction, there is nothing to inject.
  • DDD aggregates. An aggregate is encapsulation at the domain level. Order guarding its items and its status is a small aggregate root: the only door into a consistency boundary.

That is why I don't treat OOP as an old topic. Frameworks and architecture styles change every few years. The question underneath stays the same: where does this rule live, and who is allowed to change it? The four pillars are still the most direct answer we have in code.

Which of the four is missing in the codebase you work on today, right where it hurts most?