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.
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.
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 type | Purpose | Example |
|---|---|---|
| Concrete class | Full implementation, can be instantiated | ArrayList, Order |
| Abstract class | Partial implementation, cannot be instantiated | AbstractList, BaseService |
| Final class | Cannot be subclassed | String, Integer |
| Record (Java 16+) | Transparent data carrier | record OrderId(String value) {} |
| Enum | Fixed set of constants | OrderStatus { PENDING, CONFIRMED, SHIPPED } |
| Sealed class (Java 17+) | Controlled inheritance hierarchy | sealed 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.
// 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 objectObject lifecycle
Object identity, equality, and hash
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.
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
| Pattern | Usage | Example |
|---|---|---|
| Default constructor | JPA, frameworks, serialization | public Customer() {} |
| All-args constructor | Full initialization | public Customer(String id, String email, String name) |
| Constructor injection | Dependency injection (Spring) | public OrderService(OrderRepository repo) |
| Builder via constructor | Many optional fields | new Order.Builder().customerId(...).items(...).build() |
| Copy constructor | Defensive copying | public 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.
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
| Modifier | Class | Package | Subclass | World | Backend use |
|---|---|---|---|---|---|
private | ✅ | ❌ | ❌ | ❌ | Fields, internal helpers |
| default (package-private) | ✅ | ✅ | ❌ | ❌ | Internal package SPI |
protected | ✅ | ✅ | ✅ | ❌ | Template method hooks |
public | ✅ | ✅ | ✅ | ✅ | API, service interfaces |
The law of Demeter (principle of least knowledge): A method should only call methods on:
- Its own instance
- Parameters passed to it
- Objects it creates
- 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.
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
| Rule | Detail |
|---|---|
| Single class inheritance | A class can extend only one superclass |
super keyword | Access parent constructor and overridden methods |
@Override annotation | Always use it — catches typos and signature mismatches |
final methods | Cannot be overridden |
final classes | Cannot be subclassed (String, Integer, etc.) |
| Abstract methods | Must be implemented by first concrete subclass |
| Constructors | Subclass 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
// 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 type | Mechanism | Decision | Example |
|---|---|---|---|
| Compile-time | Method overloading, generics | At compilation | Collections.sort(List) vs Collections.sort(List, Comparator) |
| Runtime | Method overriding via vtable | At runtime | List list = new ArrayList(); list.add(x) |
Polymorphism in action: the Strategy pattern
// 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.
// 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+)
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.
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
| Scenario | Why abstract class works |
|---|---|
| Template Method pattern | Define algorithm skeleton, let subclasses fill steps |
| Shared state | Multiple subclasses need access to same fields/config |
| Framework base classes | Spring's JdbcTemplate, servlet HttpServlet |
| Protected helper methods | Internal 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
// 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
| Aspect | Inheritance (is-a) | Composition (has-a) |
|---|---|---|
| Relationship | Tight, static at compile time | Loose, dynamic at runtime |
| Code reuse | Inherits everything (can't pick) | Delegates only what's needed |
| Encapsulation | Breaks encapsulation (subclass knows parent internals) | Preserves encapsulation |
| Flexibility | Fixed hierarchy | Swappable components |
| Testing | Harder (parent must work) | Easy (mock dependencies) |
| Change impact | Fragile base class problem | Isolated to one component |
| When to use | Genuine is-a hierarchy (rare) | Most cases (preferred) |
Practical example: strategy via composition
// 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.
| Principle | What it means | OOP pillar | Backend example |
|---|---|---|---|
| Single Responsibility | One class, one reason to change | Encapsulation | Separate OrderController, OrderService, OrderRepository |
| Open/Closed | Open for extension, closed for modification | Polymorphism | PaymentGateway interface — add new gateways without changing existing code |
| Liskov Substitution | Subtypes must be substitutable for base types | Inheritance | CreditCardPayment must not weaken Payment preconditions |
| Interface Segregation | Small, focused interfaces over large ones | Abstraction | ReadableOrderRepo vs WritableOrderRepo |
| Dependency Inversion | Depend on abstractions, not concretions | Abstraction + Polymorphism | OrderService(PaymentGateway) not OrderService(StripeGateway) |
13. Production Design Patterns
Strategy pattern
Encapsulate interchangeable algorithms behind a common interface.
// 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.
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.
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
@Autowiredon 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: nowrapbad 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
Stringis declaredfinal. What problems would arise if it were subclassable? - How do sealed classes improve switch expressions and pattern matching?