1333 words
7 minutes
Core Java OOP Guide

The basics of OOP and record types in Java

1. The Three Pillars of OOP#

  • Encapsulation: Combines private fields with public methods to hide internal implementation details and control access.
  • Inheritance: Uses extends to promote code reuse and establish an is-a relationship.
  • Polymorphism: Combines method overriding with upcasting to enable “one interface, multiple implementations,” decoupling callers from concrete logic.

Polymorphism is the most valuable aspect of Object-Oriented Programming. Programming to an interface rather than a concrete implementation forms the foundation of design patterns like Strategy and Spring’s Dependency Injection:

interface PaymentService {
void pay(long amountInCents);
}
class AlipayService implements PaymentService {
public void pay(long amountInCents) {
System.out.println("Paid via Alipay: " + amountInCents);
}
}
class WechatPayService implements PaymentService {
public void pay(long amountInCents) {
System.out.println("Paid via WeChat Pay: " + amountInCents);
}
}
// Caller depends only on the interface; switching implementations requires zero code changes here
class CheckoutController {
private final PaymentService paymentService;
CheckoutController(PaymentService paymentService) {
this.paymentService = paymentService;
}
void checkout() {
paymentService.pay(9900);
}
}

2. Initialization Order#

Parent and child classes follow a strict initialization order. This is a favorite technical interview topic and a key concept when debugging unexpected null field values.

Execution Order:

  1. Parent static members/blocks (in order of appearance)
  2. Child static members/blocks
  3. Parent instance members/blocks
  4. Parent constructor
  5. Child instance members/blocks
  6. Child constructor
class Parent {
static { System.out.println("1 - Parent Static Block"); }
{ System.out.println("3 - Parent Instance Block"); }
Parent() { System.out.println("4 - Parent Constructor"); }
}
class Child extends Parent {
static { System.out.println("2 - Child Static Block"); }
{ System.out.println("5 - Child Instance Block"); }
Child() { System.out.println("6 - Child Constructor"); }
}
// First `new Child()` outputs: 1 2 3 4 5 6
// Subsequent `new Child()` outputs: 3 4 5 6 (static blocks execute only once)

Common Pitfall: Calling Overridable Methods in Constructors#

Calling an overridable method inside a constructor triggers execution before child fields have been initialized, causing the method to read uninitialized default values:

abstract class Base {
Base() { init(); } // Dangerous: Executes before child fields are initialized
abstract void init();
}
class Sub extends Base {
private final String config = loadConfig();
@Override
void init() {
System.out.println(config); // Prints null! config has not been assigned yet
}
}

Rule: Only call private or final methods inside constructors, or pass required state directly as constructor parameters.

3. Method Overloading vs. Overriding#

FeatureOverloading (Overload)Overriding (Override)
LocationWithin the same classBetween parent and child classes
SignatureSame method name, different parameter listsSame method name, identical parameter lists
Return TypeAnyMust match or be a subtype (covariant return)
ExceptionsAnyCannot throw broader checked exceptions
Access ModifierAnyCannot be more restrictive than the superclass
Binding TimeCompile time (Static Dispatch)Runtime (Dynamic Dispatch)
class Animal {
void speak() { System.out.println("..."); }
}
class Dog extends Animal {
@Override
void speak() { System.out.println("Woof"); }
}
Animal a = new Dog();
a.speak(); // Output: Woof
// Compilation checks the declared reference type (Animal); runtime executes the actual object type (Dog).

Tip: Always use @Override. It forces a compilation error if you misspell a method name, preventing stealthy bugs caused by unintended method overloading.

4. Abstract Classes vs. Interfaces#

FeatureAbstract ClassInterface
Keywordsabstract classinterface
InheritanceSingle inheritance (extends)Multiple implementation (implements)
ConstructorsSupportedNot supported
Member VariablesAny typeOnly public static final constants
MethodsConcrete and abstract methodsAbstract methods, plus default and static methods (Java 8+)
Design Intentis-a relationship (“what it is”)can-do behavior (“what it can do”)

Modern interfaces are highly versatile:

public interface OrderRepository {
Order findById(Long id); // Abstract method
// Java 8+: Default method (implementations are not forced to override this)
default boolean exists(Long id) {
return findById(id) != null;
}
// Java 8+: Static utility method
static OrderRepository empty() {
return id -> null;
}
}

Selection Guidelines:

  • Use an Abstract Class when classes share internal state (fields) or constructor logic.
  • Use an Interface when defining capabilities or contracts, or when requiring multiple implementation inheritance.
  • Modern Java default: Prefer interfaces paired with default methods for shared utility.

When multiple interfaces contain conflicting default methods, the implementing class must explicitly resolve the conflict:

interface A { default void hi() { System.out.println("A"); } }
interface B { default void hi() { System.out.println("B"); } }
class C implements A, B {
@Override
public void hi() {
A.super.hi(); // Explicitly delegates to interface A's implementation
}
}

5. The equals and hashCode Contract#

This is one of the most critical structural contracts in Java. Violating it leads to lost data inside collection types like HashMap and HashSet.

The Rule: If two objects are equal according to equals(), their hashCode() values must be identical. If two objects have the same hashCode(), they are not necessarily equal according to equals().

class User {
private final Long id;
private final String name;
User(Long id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User user)) return false; // Java 16+ pattern matching
return Objects.equals(id, user.id) && Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
}

The Risk of Overriding equals Without hashCode#

Set<User> set = new HashSet<>();
set.add(new User(1L, "Tom"));
System.out.println(set.contains(new User(1L, "Tom"))); // Returns false!
// The instances are logically equal via `equals()`, but default hash codes place them in different hash buckets.

Best Practices:

  • Always override hashCode whenever you override equals. Simplify implementation using Objects.equals() and Objects.hash().
  • Ensure objects used as Map keys are immutable (mark fields final). Modifying a key’s fields post-insertion permanently breaks lookups.
  • Auto-generate these methods using your IDE shortcuts (Alt + Insert in IntelliJ).

6. Records: Concise Data Carriers (Java 16+)#

For classes that exist solely to hold data (such as DTOs, VOs, or configuration objects), hand-writing getters, equals(), hashCode(), and toString() creates unnecessary boilerplate. Java record types reduce this to a single line:

public record Order(Long id, String sku, int quantity, long amountInCents) {
// Compact constructor for validation logic
public Order {
if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
if (amountInCents < 0) throw new IllegalArgumentException("Amount cannot be negative");
}
}
// Usage
Order o = new Order(1L, "SKU-001", 2, 9900);
System.out.println(o.sku()); // "SKU-001" (Accessor naming uses field name directly, e.g., `sku()`, not `getSku()`)
System.out.println(o); // Output: Order[id=1, sku=SKU-001, quantity=2, amountInCents=9900]

Key Characteristics:

  • Implicitly final class with private final fields, making instances immutable by default.
  • Automatically implements equals(), hashCode(), and toString().
  • Can implement interfaces, but cannot extend classes (inherits implicitly from java.lang.Record).
  • Supports custom static methods, instance methods, and compact constructors.

Common Applications: API response payloads, DB query projections, compound Map keys, and multi-value returns. Supported natively by Spring Boot 3, Jackson, and modern ORMs.

7. Composition Over Inheritance#

Inheritance introduces tight coupling: changes to a superclass ripple down to all subclasses, and subclasses can breach encapsulation by relying on superclass protected details (Effective Java, Item 18).

Prefer composition (delegation) over inheritance:

// ❌ WRONG: Subclassing a third-party class binds you to its internal implementation details
class CountingHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
// If HashSet.addAll internally delegates to add(), your counter will silently double-count!
}
// ✅ RIGHT: Composition + Delegation depends strictly on public contracts
class CountingSet<E> implements Set<E> {
private final Set<E> delegate = new HashSet<>();
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return delegate.add(e);
}
// Forward all remaining methods to the delegate instance
}

When to Use Inheritance:

  • There is a clear is-a relationship.
  • You own and control the parent class (within the same module).
  • The superclass is explicitly designed and documented for extension.

8. Common Traps & Antipatterns#

  • Overriding equals() Without hashCode(): Causes duplicate entries in HashSet and failed lookups in HashMap.

  • Using Mutable Objects as Map Keys:

    List<String> key = new ArrayList<>(List.of("a"));
    Map<List<String>, String> map = new HashMap<>();
    map.put(key, "value");
    key.add("b"); // Modifying key alters its hash code!
    map.get(key); // Returns null!

    Fix: Use immutable types like String, Integer, or record for Map keys.

  • Unsafe Casting in equals(): Direct casting without instanceof checks raises a ClassCastException. Use Java 16+ pattern matching (o instanceof User user).

  • Invoking Overridable Methods in Constructors: Triggers subclass code execution before child field values have been initialized.

  • Confusing private Shadowing with Overriding: private methods are hidden from child classes. Defining a method with the same signature in a child class creates an independent method rather than an override, disabling dynamic dispatch.

  • Subclassing Merely for Code Reuse: Needing shared methods does not justify an inheritance hierarchy. Use static utilities, composition, or interface default methods instead.

Core Java OOP Guide
https://astro-nyc.pages.dev/posts/java-oop-core-concepts-guide/
Author
Hari Seldon
Published at
2026-08-17
License
CC BY-NC-SA 4.0