01-java-foundations

Java OOP and Design: Classes, Interfaces, Abstraction, and Composition

A deep dive into object-oriented programming in Java: classes, objects, constructors, encapsulation, inheritance, polymorphism, interfaces, abstract classes, and composition over inheritance.

March 15, 2026Updated July 6, 2026
backend-engineerjavaoopdesignarchitectureinheritancepolymorphism

Java OOP and Design

Object-oriented programming is the foundation of modern Java backend systems. This guide covers every OOP concept from first principles through production patterns — how classes, inheritance, polymorphism, interfaces, and composition shape real-world service design.


1. Object-Oriented Programming Overview

OOP is a programming paradigm built around objects — bundles of state (fields) and behavior (methods). The four pillars of OOP guide how we structure code for maintainability, scalability, and clarity.

💡

Not just theory: Every Spring Boot service, every JPA entity, every repository interface, every @Service class is an application of these four principles. Understanding them deeply is what separates engineers who can write code from engineers who can design systems.


2. Class

A class is a blueprint that defines the structure (fields) and behavior (methods) of objects. In backend systems, classes model domain entities, service components, configuration holders, and data transfer objects.

java
public class Order {
    // Fields — the state
    private final String orderId;
    private final String customerId;
    private final List<LineItem> items;
    private OrderStatus status;
 
    // Constructor — builds instances
    public Order(String orderId, String customerId, List<LineItem> items) {
        this.orderId = orderId;
        this.customerId = customerId;
        this.items = List.copyOf(items);  // defensive copy
        this.status = OrderStatus.PENDING;
    }
 
    // Behavior — methods that operate on state
    public void confirm() {
        if (status != OrderStatus.PENDING) {
            throw new IllegalStateException("Only pending orders can be confirmed");
        }
        this.status = OrderStatus.CONFIRMED;
    }
 
    public boolean isShippable() {
        return status == OrderStatus.CONFIRMED && !items.isEmpty();
    }
}

Types of classes in Java

Class typePurposeExample
Concrete classFull implementation, can be instantiatedArrayList, Order
Abstract classPartial implementation, cannot be instantiatedAbstractList, BaseService
Final classCannot be subclassedString, Integer
Record (Java 16+)Transparent data carrierrecord OrderId(String value) {}
EnumFixed set of constantsOrderStatus { PENDING, CONFIRMED, SHIPPED }
Sealed class (Java 17+)Controlled inheritance hierarchysealed interface Payment permits CreditCard, UPI {}

Production principle: Prefer small, focused classes with a single responsibility. A class that does one thing is easy to test, easy to reason about, and easy to replace.


3. Object

An object is a runtime instance of a class — a concrete allocation in heap memory with its own copy of the instance fields.

java
// Declaration and instantiation
Order order;               // declares a reference (null initially)
order = new Order(...);    // allocates object on heap, assigns reference
 
// What happens in memory:
// 1. Class loading: Order class metadata loaded into Metaspace
// 2. Heap allocation: ~24+ bytes for the object (header + fields)
// 3. Constructor runs: initializes fields
// 4. Reference stored on stack or as field in owning object

Object lifecycle

Object identity, equality, and hash

java
Order a = new Order("1", "cust1", items);
Order b = new Order("1", "cust1", items);
Order c = a;
 
a == c;         // true — same reference
a == b;         // false — different objects
a.equals(b);    // true — logical equality (if properly overridden)
⚠️

In backend services, object proliferation is a common source of GC pressure. Every new call costs allocation and eventual collection. Use caching, pooling, and value objects judiciously.


4. Constructor

Constructors are special methods that initialize a newly-allocated object. They establish invariants — guarantees about the object's state that hold for its entire lifetime.

java
public class Customer {
    private final String id;
    private final String email;
    private final String name;
 
    // Primary constructor
    public Customer(String id, String email, String name) {
        this.id = Objects.requireNonNull(id, "id must not be null");
        this.email = validateEmail(email);
        this.name = Objects.requireNonNull(name, "name must not be null");
    }
 
    // Overloaded constructor delegates to primary
    public Customer(String email, String name) {
        this(UUID.randomUUID().toString(), email, name);
    }
 
    private static String validateEmail(String email) {
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Invalid email: " + email);
        }
        return email;
    }
}

Constructor patterns in backend code

PatternUsageExample
Default constructorJPA, frameworks, serializationpublic Customer() {}
All-args constructorFull initializationpublic Customer(String id, String email, String name)
Constructor injectionDependency injection (Spring)public OrderService(OrderRepository repo)
Builder via constructorMany optional fieldsnew Order.Builder().customerId(...).items(...).build()
Copy constructorDefensive copyingpublic Customer(Customer other)

Prefer constructor injection over field injection in Spring services. It makes dependencies explicit, enables final fields, and simplifies testing — no reflection needed to set up mocks.

Constructor chaining with this() and super()


5. Encapsulation

Encapsulation is the principle of hiding internal state and exposing only a controlled interface. It is the foundation of maintainable backend code.

java
public class BankAccount {
    // Private state — no direct access from outside
    private Money balance;
    private final List<Transaction> ledger = new ArrayList<>();
 
    // Public behavior — controlled access to state
    public void deposit(Money amount) {
        if (amount.isNegative()) {
            throw new IllegalArgumentException("Cannot deposit negative amount");
        }
        balance = balance.add(amount);
        ledger.add(new Transaction(TransactionType.DEPOSIT, amount));
    }
 
    public Money getBalance() {
        return balance;  // Money is immutable, safe to expose
    }
 
    public List<Transaction> getLedger() {
        return Collections.unmodifiableList(ledger);  // defensive view
    }
}

Access modifier comparison

ModifierClassPackageSubclassWorldBackend use
privateFields, internal helpers
default (package-private)Internal package SPI
protectedTemplate method hooks
publicAPI, service interfaces
💡

The law of Demeter (principle of least knowledge): A method should only call methods on:

  1. Its own instance
  2. Parameters passed to it
  3. Objects it creates
  4. Its own fields (but not their fields!)

Violation: order.getCustomer().getAddress().getCity() — this couples you to the full navigation chain. Fix: order.getShippingCity() — encapsulates the traversal.


6. Inheritance

Inheritance models an is-a relationship. A subclass inherits fields and methods from a superclass and can override behavior.

java
public class Payment {
    protected final String transactionId;
    protected final Money amount;
 
    public Payment(String transactionId, Money amount) {
        this.transactionId = transactionId;
        this.amount = amount;
    }
 
    public void process() {
        // Common payment processing logic
        validate();
        charge();
        notify();
    }
 
    protected void validate() { /* base validation */ }
    protected abstract void charge();
    protected void notify() { /* send receipt */ }
}
 
public class CreditCardPayment extends Payment {
    private final String cardNumber;
 
    public CreditCardPayment(String transactionId, Money amount, String cardNumber) {
        super(transactionId, amount);
        this.cardNumber = cardNumber;
    }
 
    @Override
    protected void charge() {
        // Credit card specific charging logic
    }
}

The fragile base class problem

Deep inheritance hierarchies are brittle. A change to a superclass can silently break all subclasses — this is the fragile base class problem.

Java inheritance rules

RuleDetail
Single class inheritanceA class can extend only one superclass
super keywordAccess parent constructor and overridden methods
@Override annotationAlways use it — catches typos and signature mismatches
final methodsCannot be overridden
final classesCannot be subclassed (String, Integer, etc.)
Abstract methodsMust be implemented by first concrete subclass
ConstructorsSubclass constructors must call super(...) (implicitly or explicitly)
⚠️

Rule of thumb: If you find yourself going more than 2-3 levels deep in inheritance, stop and refactor. Deep hierarchies are a design smell that indicate you should prefer composition.


7. Polymorphism

Polymorphism ("many forms") lets a single interface work with multiple underlying implementations. It is the mechanism that makes frameworks like Spring possible.

Compile-time vs runtime polymorphism

java
// Compile-time polymorphism (method overloading)
public class Calculator {
    public int add(int a, int b) { return a + b; }
    public double add(double a, double b) { return a + b; }
    // Which method to call is decided at compile time
}
 
// Runtime polymorphism (method overriding)
PaymentProcessor processor = new CreditCardProcessor();
processor.process(amount);
// Which process() runs is decided at runtime based on actual object type
Polymorphism typeMechanismDecisionExample
Compile-timeMethod overloading, genericsAt compilationCollections.sort(List) vs Collections.sort(List, Comparator)
RuntimeMethod overriding via vtableAt runtimeList list = new ArrayList(); list.add(x)

Polymorphism in action: the Strategy pattern

java
// Interface — the polymorphic contract
public interface NotificationChannel {
    void send(String recipient, String message);
}
 
// Multiple implementations
public class EmailChannel implements NotificationChannel {
    public void send(String recipient, String message) {
        // Send via SMTP
    }
}
 
public class SMSChannel implements NotificationChannel {
    public void send(String recipient, String message) {
        // Send via SMS gateway
    }
}
 
// Client — depends on abstraction, not concrete type
public class NotificationService {
    private final NotificationChannel channel;
 
    public NotificationService(NotificationChannel channel) {
        this.channel = channel;  // any implementation works
    }
 
    public void notifyUser(String email, String message) {
        channel.send(email, message);
    }
}

8. Abstraction

Abstraction means hiding implementation complexity behind a clean, understandable contract. It is what allows a developer to use List<String> without knowing whether it is backed by an array or a linked list.

Levels of abstraction in a backend service

Abstraction is not the same as indirection. Good abstraction reduces cognitive load. Bad abstraction (leaky abstraction) forces callers to understand implementation details anyway. The worst sin in backend engineering is a "simple" abstraction that leaks database or network concerns through its API.


9. Interface

An interface is a pure contract — a set of method signatures that implementing classes must fulfill. In Java, interfaces are the primary tool for achieving abstraction, polymorphism, and loose coupling.

java
// Define the contract
public interface PaymentGateway {
    PaymentResult charge(PaymentRequest request);
    PaymentResult refund(String transactionId, Money amount);
    boolean isAvailable();
}
 
// Implement the contract
public class StripeGateway implements PaymentGateway {
    @Override
    public PaymentResult charge(PaymentRequest request) {
        // Stripe-specific HTTP call
    }
 
    @Override
    public PaymentResult refund(String transactionId, Money amount) {
        // Stripe refund logic
    }
 
    @Override
    public boolean isAvailable() {
        return healthCheck();
    }
}
 
// Consumer depends only on the interface
public class CheckoutService {
    private final PaymentGateway paymentGateway;
 
    public CheckoutService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }
 
    public Receipt checkout(Cart cart) {
        PaymentRequest request = buildPaymentRequest(cart);
        PaymentResult result = paymentGateway.charge(request);
        return buildReceipt(result);
    }
}

Interface evolution (Java 8+)

java
public interface PaymentGateway {
    // Abstract method — must be implemented
    PaymentResult charge(PaymentRequest request);
 
    // Default method — optional override with fallback
    default PaymentResult refund(String transactionId, Money amount) {
        throw new UnsupportedOperationException("Refunds not supported");
    }
 
    // Static method — utility on the interface itself
    static PaymentGateway defaultGateway() {
        return new StripeGateway();
    }
 
    // Private method (Java 9+) — shared helper for default methods
    private void log(String message) {
        Logger.log("[PaymentGateway] " + message);
    }
}

Interface vs abstract class decision

💡

Java 8+ interfaces can have default and static methods, which blurs the line with abstract classes. The key remaining distinction: abstract classes can have state (fields, constructors), while interfaces cannot. Use interfaces for contracts and abstract classes when you need shared, stateful initialization.


10. Abstract Class

An abstract class sits between a concrete class and an interface. It can provide partial implementation — some methods implemented, others left abstract — and can hold state.

java
public abstract class AbstractPaymentProcessor {
    // Fields — concrete state shared by subclasses
    protected final String merchantId;
    protected final HttpClient httpClient;
    private final MetricsRegistry metrics;
 
    // Constructor — initialization logic
    protected AbstractPaymentProcessor(String merchantId, HttpClient httpClient,
                                        MetricsRegistry metrics) {
        this.merchantId = merchantId;
        this.httpClient = httpClient;
        this.metrics = metrics;
    }
 
    // Concrete method — shared behavior
    public PaymentResult process(PaymentRequest request) {
        long start = System.nanoTime();
        try {
            PaymentResult result = doCharge(request);
            metrics.recordSuccess(System.nanoTime() - start);
            return result;
        } catch (Exception e) {
            metrics.recordFailure(System.nanoTime() - start);
            throw new PaymentException("Payment failed", e);
        }
    }
 
    // Template method pattern — subclasses provide steps
    public final PaymentResult fullFlow(PaymentRequest request) {
        validate(request);           // step 1 (may be overridden)
        PaymentResult result = doCharge(request);  // step 2 (abstract)
        postProcess(result);         // step 3 (may be overridden)
        return result;
    }
 
    // Abstract method — must be implemented
    protected abstract PaymentResult doCharge(PaymentRequest request);
 
    // Hook methods — optional override
    protected void validate(PaymentRequest request) { /* default validation */ }
    protected void postProcess(PaymentResult result) { /* default post-processing */ }
}

When abstract classes shine

ScenarioWhy abstract class works
Template Method patternDefine algorithm skeleton, let subclasses fill steps
Shared stateMultiple subclasses need access to same fields/config
Framework base classesSpring's JdbcTemplate, servlet HttpServlet
Protected helper methodsInternal utilities shared by subclasses but hidden from callers

11. Composition over Inheritance

Favor composition over inheritance is one of the most cited principles in object-oriented design. Composition means an object has-a relationship to other objects, delegating work to them rather than inheriting behavior.

Inheritance coupling

java
// BAD: Inheritance creates rigid coupling
public class OrderService extends BaseService {
    // Inherits all of BaseService — can't remove unwanted methods
    // Changing BaseService may break OrderService
    // Can't easily swap database access strategy
}
 
// GOOD: Composition with dependency injection
public class OrderService {
    private final OrderRepository orderRepo;
    private final PaymentGateway paymentGateway;
    private final NotificationService notifier;
    private final OrderValidator validator;
 
    public OrderService(OrderRepository orderRepo,
                        PaymentGateway paymentGateway,
                        NotificationService notifier,
                        OrderValidator validator) {
        this.orderRepo = orderRepo;
        this.paymentGateway = paymentGateway;
        this.notifier = notifier;
        this.validator = validator;
    }
 
    public Receipt placeOrder(OrderRequest request) {
        validator.validate(request);
        Order order = orderRepo.save(request.toOrder());
        PaymentResult payment = paymentGateway.charge(order.getPayment());
        notifier.sendConfirmation(order.getCustomerEmail(), order);
        return new Receipt(order, payment);
    }
}

Inheritance vs composition comparison

AspectInheritance (is-a)Composition (has-a)
RelationshipTight, static at compile timeLoose, dynamic at runtime
Code reuseInherits everything (can't pick)Delegates only what's needed
EncapsulationBreaks encapsulation (subclass knows parent internals)Preserves encapsulation
FlexibilityFixed hierarchySwappable components
TestingHarder (parent must work)Easy (mock dependencies)
Change impactFragile base class problemIsolated to one component
When to useGenuine is-a hierarchy (rare)Most cases (preferred)

Practical example: strategy via composition

java
// Instead of subclassing for every pricing variation:
public class PricingService {
    private final PricingStrategy strategy;
 
    public PricingService(PricingStrategy strategy) {
        this.strategy = strategy;
    }
 
    public Money calculatePrice(Order order) {
        return strategy.compute(order);
    }
}
 
// Strategies are composed — not inherited
@FunctionalInterface
public interface PricingStrategy {
    Money compute(Order order);
}
 
// Each strategy is a separate, testable, swappable component
PricingStrategy volumeDiscount = order ->
    order.getTotal().applyDiscount(order.getQuantity() > 100 ? 0.15 : 0);
 
PricingStrategy seasonalPricing = order -> {
    if (isPeakSeason()) return order.getTotal().multiply(1.2);
    return order.getTotal();
};
💡

When inheritance is appropriate: Genuine taxonomic hierarchies where subclasses truly are specializations of the parent AND the parent's implementation is stable. Example: RuntimeException extends Exception extends Throwable. But even the JDK prefers composition in most new APIs — look at java.util.function and the Streams API.


12. SOLID Principles in Practice

The SOLID principles are the practical application of OOP design to create maintainable backend systems.

PrincipleWhat it meansOOP pillarBackend example
Single ResponsibilityOne class, one reason to changeEncapsulationSeparate OrderController, OrderService, OrderRepository
Open/ClosedOpen for extension, closed for modificationPolymorphismPaymentGateway interface — add new gateways without changing existing code
Liskov SubstitutionSubtypes must be substitutable for base typesInheritanceCreditCardPayment must not weaken Payment preconditions
Interface SegregationSmall, focused interfaces over large onesAbstractionReadableOrderRepo vs WritableOrderRepo
Dependency InversionDepend on abstractions, not concretionsAbstraction + PolymorphismOrderService(PaymentGateway) not OrderService(StripeGateway)

13. Production Design Patterns

Strategy pattern

Encapsulate interchangeable algorithms behind a common interface.

java
// File storage abstraction
public interface FileStore {
    void save(String path, byte[] data);
    byte[] read(String path);
}
 
// Local file system
public class LocalFileStore implements FileStore { /* ... */ }
 
// S3-compatible storage
public class S3FileStore implements FileStore { /* ... */ }
 
// Usage — swap storage layer without changing business logic
public class DocumentService {
    private final FileStore fileStore;
    public DocumentService(FileStore fileStore) { this.fileStore = fileStore; }
}

Template Method pattern

Define the skeleton of an algorithm in a base class, letting subclasses override specific steps.

java
public abstract class DataExportJob {
    public final void execute() {
        List<Data> data = fetchData();        // abstract
        String formatted = format(data);       // abstract
        writeOutput(formatted);                // abstract
        cleanup();                             // hook (optional)
    }
 
    protected abstract List<Data> fetchData();
    protected abstract String format(List<Data> data);
    protected abstract void writeOutput(String content);
    protected void cleanup() { /* default: no-op */ }
}

Factory pattern

Encapsulate object creation logic, especially when the concrete type depends on runtime configuration.

java
public class PaymentGatewayFactory {
    private final Map<PaymentMethod, PaymentGateway> gateways;
 
    public PaymentGatewayFactory(List<PaymentGateway> gatewayList) {
        this.gateways = gatewayList.stream()
            .collect(Collectors.toMap(PaymentGateway::supportedMethod, Function.identity()));
    }
 
    public PaymentGateway forMethod(PaymentMethod method) {
        PaymentGateway gateway = gateways.get(method);
        if (gateway == null) throw new IllegalArgumentException("Unsupported: " + method);
        return gateway;
    }
}

14. Production Observations

  • Interfaces are your most powerful tool for decoupling. Every external dependency (database, queue, HTTP client) should be behind an interface in your domain layer.
  • Constructor injection makes dependencies explicit and enables immutable services. Avoid @Autowired on fields.
  • Deep inheritance is a liability. If you have more than 3 levels, refactor to composition.
  • Records (Java 16+) are excellent for value objects, DTOs, and immutable data carriers — they automatically provide equals(), hashCode(), toString(), and a canonical constructor.
  • Sealed classes (Java 17+) give you controlled inheritance — the compiler knows all permitted subclasses, enabling exhaustive pattern matching.
  • Prefer @Component + constructor injection over extending base classes in Spring. Your services should be plain classes, not inheriting framework base classes.

Interview Questions

  • What are the four pillars of OOP? Explain each with a real-world backend example.
  • What is the difference between a class and an object?
  • How does Java achieve polymorphism? What is the difference between compile-time and runtime polymorphism?
  • Why is white-space: nowrap bad for table headers? (wrong context — but shows you read carefully!)
  • What is encapsulation, and why does it matter in backend systems?
  • When would you use an abstract class instead of an interface?
  • What is the fragile base class problem? How does composition solve it?
  • Explain the Liskov Substitution Principle with a Java example.
  • Why is constructor injection preferred over field injection in Spring?
  • What is the Template Method pattern? Give a backend example.
  • Explain why String is declared final. What problems would arise if it were subclassable?
  • How do sealed classes improve switch expressions and pattern matching?