Classes & Objects in Java

Understanding class design for safe, maintainable, and evolvable code

Class Anatomy: Every Part Has a Purpose

Let's break down a well-designed Java class and understand why each piece exists:

class Order:
    """An order in the system."""

    def __init__(self, order_id, customer):
        self.order_id = order_id      # Public, mutable
        self.customer = customer      # Public, mutable
        self.items = []               # Public, mutable
        self._status = "pending"      # "Private" by convention

    def add_item(self, item):
        self.items.append(item)

    def get_total(self):
        return sum(item.price for item in self.items)

# Problems:
# - Anyone can do order.order_id = "DIFFERENT" (breaks identity)
# - Anyone can do order.items = [] (bypasses business logic)
# - Anyone can do order._status = "shipped" (bypasses validation)
/**
 * An order in the system.
 * Immutable identity, controlled state changes.
 */
public class Order {

    // === IMMUTABLE IDENTITY (set once, never change) ===
    private final String orderId;
    private final Customer customer;
    private final Instant createdAt;

    // === MUTABLE STATE (changes through controlled methods) ===
    private final List<OrderItem> items;  // final = can't reassign, but CAN modify contents
    private OrderStatus status;

    // === CONSTRUCTOR: Validates and initializes ===
    public Order(String orderId, Customer customer) {
        // Validate inputs
        if (orderId == null || orderId.isBlank()) {
            throw new IllegalArgumentException("orderId required");
        }
        Objects.requireNonNull(customer, "customer required");

        // Initialize immutable fields
        this.orderId = orderId;
        this.customer = customer;
        this.createdAt = Instant.now();

        // Initialize mutable state
        this.items = new ArrayList<>();
        this.status = OrderStatus.PENDING;
    }

    // === BEHAVIOR: Controlled state changes ===
    public void addItem(OrderItem item) {
        if (status != OrderStatus.PENDING) {
            throw new IllegalStateException("Cannot modify non-pending order");
        }
        Objects.requireNonNull(item);
        items.add(item);
    }

    public BigDecimal getTotal() {
        return items.stream()
            .map(OrderItem::getPrice)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    // === GETTERS: Read-only access ===
    public String getOrderId() { return orderId; }
    public Customer getCustomer() { return customer; }
    public OrderStatus getStatus() { return status; }

    // Return COPY to prevent external modification
    public List<OrderItem> getItems() {
        return List.copyOf(items);  // Immutable copy
    }
}

Key Pattern: Immutable Identity + Controlled Mutation

The order's identity (orderId, customer, createdAt) is immutable. Its state (items, status) can only change through methods that enforce business rules. This is impossible to guarantee in Python.

Constructors: Controlled Object Creation

In Java, constructors are your first line of defense. An object should never exist in an invalid state.

class EmailAddress:
    def __init__(self, email):
        self.email = email  # No validation!

# These all "work" - invalid objects exist
EmailAddress("")                  # Empty string
EmailAddress("not-an-email")      # Invalid format
EmailAddress(None)                # None value
EmailAddress(12345)               # Wrong type entirely

# Validation happens... somewhere? Maybe? At runtime?
public class EmailAddress {
    private static final Pattern EMAIL_PATTERN =
        Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$");

    private final String value;

    public EmailAddress(String email) {
        // Validate BEFORE the object can exist
        if (email == null || email.isBlank()) {
            throw new IllegalArgumentException("Email cannot be empty");
        }
        if (!EMAIL_PATTERN.matcher(email).matches()) {
            throw new IllegalArgumentException("Invalid email format: " + email);
        }
        this.value = email.toLowerCase();  // Normalize
    }

    public String getValue() {
        return value;
    }
}

// These FAIL at construction - invalid objects cannot exist
new EmailAddress("");              // IllegalArgumentException
new EmailAddress("not-an-email");  // IllegalArgumentException
new EmailAddress(null);            // IllegalArgumentException
new EmailAddress(12345);           // COMPILE ERROR: wrong type

// If you have an EmailAddress, you KNOW it's valid

Key Insight: "Make Illegal States Unrepresentable"

If an EmailAddress object exists, it's guaranteed valid. No need to check validity throughout your code. This is a core principle of domain-driven design and type-safe programming.

Static vs Instance: Class-Level vs Object-Level

class Counter:
    count = 0  # Class variable (shared)

    def __init__(self):
        Counter.count += 1
        self.id = Counter.count  # Instance variable

    @staticmethod
    def get_total():
        return Counter.count

    @classmethod
    def reset(cls):
        cls.count = 0
public class Counter {
    // static = belongs to the CLASS, not instances
    private static int count = 0;

    // non-static = belongs to each INSTANCE
    private final int id;

    public Counter() {
        count++;
        this.id = count;
    }

    // Static method: called on class, not instance
    public static int getTotal() {
        return count;
    }

    // Instance method: called on instance
    public int getId() {
        return this.id;
    }

    public static void reset() {
        count = 0;
    }
}

// Usage:
Counter.getTotal();      // Static: called on class
Counter c = new Counter();
c.getId();               // Instance: called on object

When to Use Static

Use Static ForUse Instance For
Utility methods that don't need stateMethods that operate on object state
Constants (static final)Object-specific data
Factory methodsBehavior that varies by instance
Shared counters/caches (with caution)Everything else

Inheritance: extends and super

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        raise NotImplementedError()

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)
        self.breed = breed

    def speak(self):  # Override - no indication required
        return "Woof!"

# Multiple inheritance allowed (can be complex)
class FlyingDog(Dog, FlyingMixin):
    pass
public class Animal {
    private final String name;

    public Animal(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    // Can be overridden by subclasses
    public String speak() {
        return "...";
    }
}

public class Dog extends Animal {
    private final String breed;

    public Dog(String name, String breed) {
        super(name);  // MUST call parent constructor first
        this.breed = breed;
    }

    @Override  // Explicit annotation - compiler checks this
    public String speak() {
        return "Woof!";
    }

    public String getBreed() {
        return breed;
    }
}

// Single inheritance only (use interfaces for multiple behaviors)
// class FlyingDog extends Dog, Bird  // COMPILE ERROR

The @Override Annotation

Why @Override Matters

Without @Override, if you misspell a method name, you create a NEW method instead of overriding. With @Override, the compiler checks that you're actually overriding something:

@Override
public String speek() {  // COMPILE ERROR: speek() doesn't override anything
    return "Woof!";
}

Object Equality: equals() and hashCode()

This is a common interview topic and a source of subtle bugs.

class Money:
    def __init__(self, amount, currency):
        self.amount = amount
        self.currency = currency

    def __eq__(self, other):
        if not isinstance(other, Money):
            return False
        return self.amount == other.amount and self.currency == other.currency

    def __hash__(self):
        return hash((self.amount, self.currency))

m1 = Money(100, "USD")
m2 = Money(100, "USD")
m1 == m2  # True (value equality)
m1 is m2  # False (identity)
public class Money {
    private final BigDecimal amount;
    private final String currency;

    public Money(BigDecimal amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    @Override
    public boolean equals(Object obj) {
        // 1. Same reference?
        if (this == obj) return true;

        // 2. Null or wrong type?
        if (obj == null || getClass() != obj.getClass()) return false;

        // 3. Compare fields
        Money money = (Money) obj;
        return Objects.equals(amount, money.amount)
            && Objects.equals(currency, money.currency);
    }

    @Override
    public int hashCode() {
        // MUST override if you override equals()
        return Objects.hash(amount, currency);
    }
}

Money m1 = new Money(new BigDecimal("100"), "USD");
Money m2 = new Money(new BigDecimal("100"), "USD");
m1.equals(m2);  // true (value equality)
m1 == m2;       // false (identity/reference)

The equals/hashCode Contract

If you override equals(), you MUST override hashCode(). Objects that are equal must have the same hash code. Violating this breaks HashMap, HashSet, etc.

Modern Java: Records (Java 14+)

Java heard the "too verbose" complaints. Records eliminate boilerplate for simple data classes:

from dataclasses import dataclass

@dataclass(frozen=True)  # frozen = immutable
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
p.x        # 1.0
p.x = 5.0  # Error if frozen=True
// This single line generates:
// - Constructor
// - Getters (x(), y())
// - equals()
// - hashCode()
// - toString()
// - ALL fields are final (immutable)

public record Point(double x, double y) { }

// Usage:
Point p = new Point(1.0, 2.0);
p.x();      // 1.0 (note: method, not field)
p.x = 5.0;  // COMPILE ERROR: records are immutable

// You can add validation:
public record Point(double x, double y) {
    public Point {  // "Compact constructor"
        if (Double.isNaN(x) || Double.isNaN(y)) {
            throw new IllegalArgumentException("Coordinates cannot be NaN");
        }
    }
}

When to Use Records

Records are perfect for DTOs, value objects, API responses, and any "bag of data" class. They're immutable by design and auto-generate all the boilerplate correctly.

Object Creation Patterns

The Builder Pattern (for complex objects)

public class HttpRequest {
    private final String url;
    private final String method;
    private final Map<String, String> headers;
    private final String body;
    private final Duration timeout;

    private HttpRequest(Builder builder) {
        this.url = builder.url;
        this.method = builder.method;
        this.headers = Map.copyOf(builder.headers);
        this.body = builder.body;
        this.timeout = builder.timeout;
    }

    public static Builder builder(String url) {
        return new Builder(url);
    }

    public static class Builder {
        private final String url;  // Required
        private String method = "GET";  // Default
        private Map<String, String> headers = new HashMap<>();
        private String body;
        private Duration timeout = Duration.ofSeconds(30);

        private Builder(String url) {
            this.url = url;
        }

        public Builder method(String method) {
            this.method = method;
            return this;
        }

        public Builder header(String key, String value) {
            this.headers.put(key, value);
            return this;
        }

        public Builder body(String body) {
            this.body = body;
            return this;
        }

        public Builder timeout(Duration timeout) {
            this.timeout = timeout;
            return this;
        }

        public HttpRequest build() {
            return new HttpRequest(this);
        }
    }
}

// Usage - clear, readable, IDE-friendly:
HttpRequest request = HttpRequest.builder("https://api.example.com")
    .method("POST")
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer token")
    .body("{\"key\": \"value\"}")
    .timeout(Duration.ofSeconds(60))
    .build();

Why Builders?