Understanding class design for safe, maintainable, and evolvable code
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 } }
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.
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
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.
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
| Use Static For | Use Instance For |
|---|---|
| Utility methods that don't need state | Methods that operate on object state |
Constants (static final) | Object-specific data |
| Factory methods | Behavior that varies by instance |
| Shared counters/caches (with caution) | Everything else |
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
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!"; }
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)
If you override equals(), you MUST override hashCode().
Objects that are equal must have the same hash code. Violating this breaks HashMap, HashSet, etc.
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"); } } }
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.
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();
build()