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
extendsto 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 hereclass 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:
- Parent static members/blocks (in order of appearance)
- Child static members/blocks
- Parent instance members/blocks
- Parent constructor
- Child instance members/blocks
- 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
| Feature | Overloading (Overload) | Overriding (Override) |
|---|---|---|
| Location | Within the same class | Between parent and child classes |
| Signature | Same method name, different parameter lists | Same method name, identical parameter lists |
| Return Type | Any | Must match or be a subtype (covariant return) |
| Exceptions | Any | Cannot throw broader checked exceptions |
| Access Modifier | Any | Cannot be more restrictive than the superclass |
| Binding Time | Compile 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
| Feature | Abstract Class | Interface |
|---|---|---|
| Keywords | abstract class | interface |
| Inheritance | Single inheritance (extends) | Multiple implementation (implements) |
| Constructors | Supported | Not supported |
| Member Variables | Any type | Only public static final constants |
| Methods | Concrete and abstract methods | Abstract methods, plus default and static methods (Java 8+) |
| Design Intent | is-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
hashCodewhenever you overrideequals. Simplify implementation usingObjects.equals()andObjects.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 + Insertin 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"); }}
// UsageOrder 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(), andtoString(). - 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 detailsclass 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 contractsclass 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()WithouthashCode(): Causes duplicate entries inHashSetand failed lookups inHashMap. -
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, orrecordfor Map keys. -
Unsafe Casting in
equals(): Direct casting withoutinstanceofchecks raises aClassCastException. 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
privateShadowing with Overriding:privatemethods 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.