DEV Community

Cover image for OOP Beyond the Four Pillars: State, Behavior, and Invariants
Phùng Hữu Đại Khánh
Phùng Hữu Đại Khánh

Posted on

OOP Beyond the Four Pillars: State, Behavior, and Invariants

Table of Contents


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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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. One of the most important roles of an object is protecting invariants.

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 Substitutability, 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!");
    }
}
Enter fullscreen mode Exit fullscreen mode
Animal a = new Dog();
a.speak(); // Prints "Woof!" — resolved by the actual object, not the declared type
Enter fullscreen mode Exit fullscreen mode

@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 {}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the essence: the same call site (v.move()) dispatches to different behavior at runtime, based on the object's concrete typeFleet 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Protecting invariants is one of the most important responsibilities many domain objects have. In practice, this is why OOP often emphasizes keeping behavior close to the state it governs.

A related concept is identity.

Two objects may contain identical state and behavior while still represent different entities:

Customer alice1 = new Customer(1, "Alice");
Customer alice2 = new Customer(2, "Alice");
Enter fullscreen mode Exit fullscreen mode

For that reason, many classic OOP definitions describe an object as:

Identity + State + Behavior

This article focuses primarily on responsibility and invariant protection, but identity remains an important part of object-oriented thinking.

Not All Invariants Belong to a Single Object

The examples in this article focus on invariants protected by a single object.

Real systems often contain rules that span multiple objects:

  • An order cannot be shipped unless it has been paid.
  • Inventory cannot become negative.
  • A bank transfer must debit one account and credit another.

Protecting such rules often requires coordination beyond a single object and is one of the motivations behind concepts such as Aggregates in Domain-Driven Design. An Aggregate defines a consistency boundary within which invariants are guaranteed to hold.


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
  }
Enter fullscreen mode Exit fullscreen mode

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;
      }
  }
Enter fullscreen mode Exit fullscreen mode

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.


OOP Is About Responsibility, Not Classes

A useful test for whether a codebase is actually object-oriented, or just uses class as a file-organization convention: pick a business rule and ask which object owns it.

User
UserService
UserValidator
UserMapper
UserHelper
Enter fullscreen mode Exit fullscreen mode

Five classes, one entity. If the email-format rule from the example above lives in UserValidator, the discount calculation lives in UserService, and User itself is just the anemic getter/setter bag — then no single object owns any rule. The rules float between service and helper classes that operate on data rather than objects that are responsible for their own data. That's a procedural program wearing class syntax, not OOP.

The fix isn't to delete UserService — coordinating multiple objects across a use case is a legitimate job for a service layer. The fix is narrower: the rule that only concerns User's own state (is this email valid, can this balance go negative) belongs on User, not scattered across whichever class happened to need it first. Everything in sections 3 and 7 — encapsulation as invariant protection, behavior bound to state — comes down to this one habit: before writing a rule, ask which object it actually belongs to.


A Note on Rich vs. Anemic Models

The examples in this article intentionally focus on objects that own and enforce their own invariants.

That does not mean every architecture must place every business rule inside an entity.

The important question is not where a piece of code physically lives, but which part of the system is responsible for maintaining correctness.

Responsibility matters more than placement.

CRUD applications, reporting systems, data pipelines,
and some functional architectures intentionally separate
data from behavior.

Anemic models become a problem only when invariants
and business rules end up scattered with no clear owner.


Summary

Property Core Essence Not to Be Confused With
Encapsulation Access control + invariant protection Lifecycle/resource management (close(), GC)
Inheritance Subtype substitutability (LSP) The default tool for code reuse
Polymorphism Runtime dispatch through a shared abstraction Overloading (ad-hoc polymorphism)
Abstraction Hiding detail at the right level interface/abstract class (just a tool)

None of this is really about Java — swap in any language with dynamic dispatch and a garbage collector and the same points hold.
The four properties above are useful vocabulary, but they are not the essence of OOP.
The deeper idea behind object-oriented programming is responsibility.
An object owns identity and state, exposes behavior, and protects the invariants that define its correctness. Some invariants belong to individual objects, while others belong to larger parts of the system. The role of design is to place each responsibility where it can be protected most reliably.
Encapsulation, Inheritance, Polymorphism, and Abstraction are valuable tools, but they matter only insofar as they help responsibilities stay in the right place and correctness remain protected.
To go deeper: SOLID principles, Domain-Driven Design (DDD), Dependency Inversion, and Rich Domain Model vs. Anemic Domain Model.

Top comments (0)