SSourav Saha
HomeExperienceSoftware DesignSystem DesignLearningBooksToolsContact
SSourav Saha

Building scalable backend systems, distributed infrastructure, and cloud-native applications.

Navigation

  • Home
  • Experience
  • Software Design
  • System Design
  • Learning

More

  • Books
  • Tools
  • Contact

Connect

  • LinkedIn
  • Email

© 2026 Sourav Saha. All rights reserved.

Built with using Next.js

Software DesignCore OOP Principles for Scalable Systems
Software DesignOOP

Core OOP Principles for Scalable Systems

Favor composition over inheritance, program to interfaces, encapsulate correctly, and build systems with high cohesion and low coupling — with Java examples.

May 18, 20245 min read
oopjavabest-practicesarchitecture

Introduction

Object-Oriented Programming is more than classes and inheritance. The principles that separate production-grade code from tutorial code are subtle but powerful. This article covers the five OOP principles that directly impact the scalability and maintainability of real-world Java systems.


1. Favor Composition Over Inheritance

Inheritance creates tight coupling. Composition creates flexibility.

Bad Code (Inheritance)

public class Vehicle {
    public void start() { System.out.println("Starting vehicle"); }
}

public class ElectricCar extends Vehicle {
    @Override
    public void start() {
        System.out.println("Starting electric motor silently");
    }
}

public class HybridCar extends Vehicle {
    // Now needs BOTH electric and petrol behavior — inheritance breaks down
}

Good Code (Composition)

public interface Engine {
    void start();
}

public class ElectricEngine implements Engine {
    public void start() { System.out.println("Electric motor humming"); }
}

public class PetrolEngine implements Engine {
    public void start() { System.out.println("Petrol engine roaring"); }
}

public class Vehicle {
    private final Engine engine;

    public Vehicle(Engine engine) {
        this.engine = engine;
    }

    public void start() {
        engine.start();
    }
}

// Hybrid car? Just compose both engines.
Vehicle electric = new Vehicle(new ElectricEngine());
Vehicle petrol = new Vehicle(new PetrolEngine());

2. Program to Interfaces, Not Implementations

Bad Code

public class OrderService {
    private ArrayList<Order> orders = new ArrayList<>();
    private HashMap<String, Order> orderIndex = new HashMap<>();
}

Good Code

public class OrderService {
    private List<Order> orders = new ArrayList<>();
    private Map<String, Order> orderIndex = new HashMap<>();
}

Why does this matter? Because you can swap ArrayList for LinkedList or HashMap for TreeMap without changing any calling code. In large systems, this is the difference between a 1-line change and a 100-file refactor.

Real-World Example

// Bad — tightly coupled to MySQL
public class ReportService {
    private MySQLConnection connection;
}

// Good — works with any database
public class ReportService {
    private DataSource dataSource; // Interface from javax.sql
}

3. Encapsulation Done Right

Encapsulation is not just about making fields private. It is about controlling how state changes.

Bad Code

public class BankAccount {
    public double balance; // Anyone can set this to -1000
}

// Usage
account.balance = account.balance - amount; // No validation!

Good Code

public class BankAccount {
    private double balance;

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

    public void withdraw(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
        if (amount > balance) throw new InsufficientFundsException();
        this.balance -= amount;
    }

    public double getBalance() {
        return balance; // Read-only access
    }
}

Immutable Objects

The strongest form of encapsulation. Once created, state cannot change.

public record Money(double amount, String currency) {
    public Money {
        if (amount < 0) throw new IllegalArgumentException("Negative amount");
        if (currency == null || currency.isBlank()) throw new IllegalArgumentException("Currency required");
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) throw new IllegalArgumentException("Currency mismatch");
        return new Money(this.amount + other.amount, this.currency);
    }
}

Immutable objects are inherently thread-safe and eliminate an entire category of bugs.


4. High Cohesion & Low Coupling

Cohesion: How closely related are the responsibilities within a single module? Coupling: How dependent is one module on another?

Bad Code (Low Cohesion)

public class UserManager {
    public void createUser(User user) { ... }
    public void sendWelcomeEmail(User user) { ... }
    public void generateInvoice(User user) { ... }
    public void backupDatabase() { ... }
}

Good Code (High Cohesion)

public class UserService {
    public void createUser(User user) { ... }
    public User findById(String id) { ... }
    public void updateProfile(String id, ProfileUpdate update) { ... }
}

public class EmailService {
    public void sendWelcomeEmail(User user) { ... }
}

public class InvoiceService {
    public void generateInvoice(User user) { ... }
}

Each service has a clear, focused purpose. They communicate through well-defined interfaces, not by sharing internal state.


5. Tell, Don't Ask

Instead of asking an object for its data and making decisions externally, tell the object what to do.

Bad Code (Ask)

// Caller makes the decision
if (account.getBalance() >= amount) {
    account.setBalance(account.getBalance() - amount);
} else {
    throw new InsufficientFundsException();
}

Good Code (Tell)

// Object makes the decision
account.withdraw(amount); // Encapsulates the validation internally

6. Law of Demeter (Don't Talk to Strangers)

A method should only call methods on:

  • this
  • Its parameters
  • Objects it creates
  • Its direct fields

Bad Code

// Reaching deep into object graph
String city = order.getCustomer().getAddress().getCity();
double rate = order.getCustomer().getMembership().getDiscount().getRate();

Good Code

// Order provides what you need
String city = order.getShippingCity();
double rate = order.getCustomerDiscountRate();

Deep chaining creates fragile dependencies. If Address changes its structure, every caller breaks.


Interview Questions

  1. When is inheritance appropriate? When there is a true "is-a" relationship (e.g., Dog extends Animal) AND the subclass does not override base behavior in surprising ways. In practice, prefer composition.

  2. What is the difference between cohesion and coupling? Cohesion is internal (how focused a class is). Coupling is external (how dependent classes are on each other). Aim for high cohesion and low coupling.

  3. Why are immutable objects preferred in concurrent systems? They cannot be modified after creation, eliminating race conditions and the need for synchronization.


Summary

PrincipleRule
Composition over InheritanceUse "has-a" instead of "is-a"
Program to InterfacesDeclare types as interfaces, not concrete classes
EncapsulationControl state changes, use immutable objects
High Cohesion / Low CouplingFocused classes, minimal dependencies
Tell Don't AskLet objects manage their own behavior
Law of DemeterDon't chain through object graphs

Related Articles

  • The SOLID Principles Explained
  • Practical Design Patterns
  • Java & Spring Boot Best Practices
PreviousThe SOLID Principles Explained with Java ExamplesNextPractical Design Patterns for Real-World Applications