The Real Essence of OOP: State, Behavior, and Invariants
DEV Community

The Real Essence of OOP: State, Behavior, and Invariants

Introduction

When object-oriented programming (OOP) comes up, most people immediately list: Encapsulation, Inheritance, Polymorphism, Abstraction. But reciting four names doesn't mean understanding the substance - and worse, a lot of popular material explains these four properties in ways that are conceptually misleading.

Code examples below are written in Java, purely as notation - the ideas are the point, not the language.

1. From Procedural to Object-Oriented

Procedural: data and behavior live apart

public class Bank {
    static double balance = 1000; // Global/static state

    static void withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
        }
    }
}

balance is a static variable - any part of the program can read or write it directly, with no guarantee it's ever checked, so the data can end up in an invalid state at any time.

OOP: data bound to the behavior that protects it

(Examples below use java.math.BigDecimal - the standard choice for money in Java, since double accumulates rounding error that a real invariant check can't tolerate)

public class BankAccount {
    private BigDecimal balance;

    public BankAccount(BigDecimal initial) {
        if (initial.signum() < 0) {
            throw new IllegalArgumentException("Initial balance cannot be negative");
        }
        this.balance = initial;
    }

    public boolean withdraw(BigDecimal amount) {
        if (amount.signum() > 0 && amount.compareTo(balance) <= 0) {
            balance = balance.subtract(amount);
            return true;
        }
        return false;
    }

    public BigDecimal getBalance() {
        return balance;
    }
}

The point is not "hiding balance." The point is that BankAccount exists to guarantee one rule always holds - balance >= 0 - no matter who calls what, in what order. An object isn't a box that stores data. An object is a place that protects an invariant. This is the starting point the rest of the article builds on.

2. What Problem Does OOP Actually Solve?

Problem in Procedural Code OOP Mechanism
State can be mutated arbitrarily, with no guardrails Encapsulation
Logic duplicated across variants of the same concept Composition / Inheritance (conditionally)
Calling code has to know the concrete type it's handling Polymorphism
System complexity makes it hard to separate levels of detail Abstraction

Inheritance is no longer the default answer to "code duplication" - the reason follows in section 4.

3. Encapsulation - Access Control, Not Lifecycle Management

The correct definition: Encapsulation means controlling access to state and guaranteeing an invariant holds, by only allowing state changes through a controlled set of behaviors (methods).

Encapsulation is not the same thing as lifecycle management. A common conflation in OOP teaching is treating Encapsulation as if it also covers managing an object's memory or resource lifetime. It doesn't - they're different responsibilities that some languages happen to bind to the same syntax.

Encapsulation is about who can touch state and under what conditions; lifecycle management is about how long a resource is held and when it gets released. An object can have one without the other: most objects, like BankAccount above, never own an external resource at all - private fields and validated methods are the whole story, no lifecycle concern in sight.

Lifecycle management only becomes relevant for the minority of objects holding something outside the object itself - a file handle, a socket, a database connection:

public class FileLogger implements AutoCloseable {
    private final BufferedWriter writer;

    public FileLogger(BufferedWriter writer) {
        this.writer = writer;
    }

    @Override
    public void close() throws IOException {
        writer.close(); // resource cleanup - nothing to do with access control
    }
}

AutoCloseable / close() exist purely to release the resource; they say nothing about who can read or write the object's state. Notice this is a separate mechanism from private/getters/setters entirely - a good illustration that Encapsulation and lifecycle management are independent concerns, not two faces of the same thing.

Bottom line: Encapsulation is a universal OOP concept that applies to every object. Lifecycle/resource management is a narrower, separate concern that only shows up when an object actually owns an external resource.

4. Inheritance - A Tool for Modeling "is-a", Not a Default Fix for Duplication

Overriding relies on dynamic dispatch. Overriding only means something if the call is resolved by the object's actual type at runtime, not by the variable's declared type at compile time. That's what makes this work:

public class Animal {
    public void speak() {
        System.out.println("Animal sound");
    }
}

public class Dog extends Animal {
    @Override
    public void speak() {
        System.out.println("Woof!");
    }
}

Animal a = new Dog();
a.speak(); // Prints "Woof!" - resolved by the actual object, not the declared type

@Override here isn't what makes overriding happen - it's just an annotation that lets the compiler flag an error if you think you're overriding but actually got the signature wrong (e.g., a typo'd method name). It's a safety net for catching mistakes, not the mechanism itself.

Inheritance is not the default tool for "reusing logic." The common guideline in modern design: Favor Composition over Inheritance.

The classic headache:

public class User {}
public class AdminUser extends User {}
public class SuperAdminUser extends AdminUser {}

Say AdminUser can do everything a regular user can, plus manage other users. SuperAdminUser adds billing access on top. That much fits a linear hierarchy fine. The trouble starts the moment AdminUser also needs a capability that lives on a completely different, unrelated branch - say, the same view-only, read-restricted mode that a hypothetical ReadOnlyUser has. There's no clean way to "borrow" that behavior sideways: AdminUser can't extend two classes, and forcing ReadOnlyUser into the same chain just to share one capability distorts the whole hierarchy for everyone else using it.

Composition sidesteps this entirely, because capabilities become objects you attach rather than branches you inherit from:

public class User {
    private final Permissions permissions; // just a set of granted actions

    public User(Permissions permissions) {
        this.permissions = permissions;
    }

    public boolean can(Action action) {
        return permissions.allows(action);
    }
}

// Any combination of capabilities can be composed freely - no hierarchy to fight:
User admin = new User(Permissions.of(Action.MANAGE_USERS, Action.VIEW_ONLY));
User superAdmin = new User(Permissions.of(Action.MANAGE_USERS, Action.BILLING, Action.VIEW_ONLY));

admin and superAdmin aren't different classes anymore - they're the same User class configured with a different Permissions object. Adding "view-only" to AdminUser is no longer a hierarchy problem; it's just handing it a different set of permissions at construction time.

Inheritance is only safe when the Liskov Substitution Principle holds. The "is-a" relationship by itself isn't the real test - plenty of things sound like "is-a" in English but break down in code. The actual condition is the Liskov Substitution Principle (LSP): anywhere the base class is used, substituting the subclass must work correctly without the caller needing to know it's a subclass. LSP is the reason Inheritance is trustworthy at all when it is; without it, "is-a" is just a feeling, not a guarantee. Dog extends Animal holds up under this test - a Dog behaves as an Animal everywhere an Animal is expected. Inheriting just to "borrow a few existing methods," without that substitutability guarantee, is a signal to switch to composition instead.

5. Polymorphism - The Core Is Subtype Polymorphism, Not Overloading

Overloading is ad-hoc polymorphism - useful, but not the core:

public class MathUtil {
    public int add(int a, int b) { return a + b; }
    public double add(double a, double b) { return a + b; }
}

This is resolved at compile time and has nothing to do with dynamic dispatch - it's just syntactic convenience.

The real power: subtype polymorphism:

public interface Vehicle {
    void move();
}

public class Car implements Vehicle {
    @Override
    public void move() {
        System.out.println("Car moves");
    }
}

public class Bike implements Vehicle {
    @Override
    public void move() {
        System.out.println("Bike moves");
    }
}

public class Fleet {
    public void moveAll(List<Vehicle> vehicles) {
        for (Vehicle v : vehicles) {
            v.move(); // No need to know whether it's a Car or a Bike
        }
    }
}

This is the essence: the same call site (v.move()) dispatches to different behavior at runtime, based on the object's concrete type - Fleet needs no changes when a Truck or Boat is added. Polymorphism lets code handle a set of different types through the same abstraction, without needing to know - or care - what the concrete type behind it is.

6. Abstraction - Much Broader Than an Interface

Interfaces and abstract classes are tools, not Abstraction itself:

public interface Shape {
    double area();
}

public class Circle implements Shape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

This example is correct but incomplete - it only illustrates one specific form called interface abstraction. A pure contract with no state (interface) and a partial implementation that can hold state (abstract class) are two different tools for achieving Abstraction - but neither one is Abstraction itself.

What Abstraction actually is:

public class UserRepository {
    public void save(User user) {
        // The caller doesn't need to know:
        // - SQL or NoSQL
        // - how transactions are opened/closed
        // - how the connection pool is configured
    }
}

repository.save(user) is a complete abstraction even without any interface - as long as the caller doesn't need the internal details. Abstraction lives in the level of detail exposed through a function's name and parameters, not in whether interface/abstract is used.

In short: interface/abstract class are tools for achieving Abstraction, especially useful when multiple interchangeable implementations are needed. But Abstraction, as a design principle, exists at every layer of code - including a plain private method.

7. Behavior-State Binding and Invariants

public class BankAccount {
    private BigDecimal balance;

    public BankAccount(BigDecimal initialBalance) {
        if (initialBalance.signum() < 0) {
            throw new IllegalArgumentException("Initial balance cannot be negative");
        }
        this.balance = initialBalance;
    }

    public boolean withdraw(BigDecimal amount) {
        if (amount.signum() > 0 && amount.compareTo(balance) <= 0) {
            balance = balance.subtract(amount);
            return true;
        }
        return false;
    }

    public BigDecimal getBalance() {
        return balance;
    }
}

The important question isn't "what does this class store?" It's: What rule does this class protect, that must always hold no matter the sequence of operations? For BankAccount, that rule is balance >= 0 - enforced not just in withdraw, but in the constructor too, since an invariant that only holds after the first call isn't really an invariant. The entire design - private, the condition checks - exists solely to serve that one rule.

A natural follow-up question: does getBalance() returning the raw value undercut any of this? Not inherently - exposing read-only state isn't a violation by itself. What matters is where decisions based on that state get made. getBalance() lets a caller look; withdraw() is still the only way to change anything, and it's still the one place enforcing the rule. Exposing a value for reading and protecting the rules that govern changing it are two separate questions.

Object = Data + Behavior + an invariant protected across its entire lifetime

8. The Cost of OOP

  • Deep inheritance hierarchies: the more extends layers, the harder to test and refactor.
  • Anemic Domain Model: extremely common in Spring-style Java codebases. A class ends up as nothing but fields and getters/setters:
public class User {
    private String email;

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    // no validation, no rule
}

Anywhere that needs to change an email now has to duplicate the validation logic itself (UserService, a controller, a batch job - whoever gets there first), because User enforces nothing. Compare that to keeping the rule where the state lives:

public class User {
    private String email;

    public void changeEmail(String newEmail) {
        if (!newEmail.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
        this.email = newEmail;
    }
}

setEmail just stores whatever it's given - the object protects no invariant, so it isn't really acting as an object in the OOP sense, just a data bag with a class-shaped label. changeEmail puts the rule back where it belongs.

  • Overusing Inheritance for code reuse instead of modeling a genuine "is-a" relationship.
  • Interface explosion - creating an interface for everything "just in case" before there's an actual need (a violation of YAGNI) - a fairly common habit in the Java/Spring ecosystem.

A counterbalancing principle worth knowing: Tell, Don't Ask - instead of if (account.getBalance().compareTo(amount) >= 0) { ... } followed by external handling, call account.withdraw(amount) so the decision logic stays close to the data it governs.

Summary

OOP Is About Responsibility, Not Classes

Comments

No comments yet. Start the discussion.