Immutability for security and stability, builders for complex configuration, refactoring a god class with IS-A/HAS-A and interface segregation, breaking and protecting singletons, deep vs shallow copies, identity-based equals, polymorphism done right, final classes, custom exceptions, and package-by-feature structure.
Published September 25, 2026
At 5–8 years, interviewers want design reasoning, not definitions. For each scenario, state the approach, the trade-off you're accepting, and how you'd enforce it (tests, types, reviews). The fundamentals behind these answers are covered in the 2–5 years and fresher tiers.
Short answer: Model it as immutable value objects:
final classes with private final fields, and no setters (or a record).List.copyOf, and copying arrays and Dates).Expose read-only views, and publish the objects safely between threads. For values that come from configuration, bind them once at startup into immutable @ConfigurationProperties records.
public record PricingPolicy(BigDecimal taxRate, Set<String> taxExemptSkus) {
public PricingPolicy {
if (taxRate.signum() < 0) throw new IllegalArgumentException("taxRate < 0");
taxExemptSkus = Set.copyOf(taxExemptSkus); // no external mutation, ever
}
}
Key points to cover:
char[] or byte arrays you can wipe, keep them out of logs and toString, and hold them in a vault or KMS.Learn it in depth → Records
Short answer: The Builder pattern, typically a static nested builder on an immutable configuration class. Callers set only what they need, with named, fluent methods. Defaults are applied in one place, and build() validates cross-field rules before returning an immutable object.
RetryPolicy policy = RetryPolicy.builder()
.maxAttempts(5)
.backoff(Duration.ofMillis(200), 2.0)
.retryOn(IOException.class, TimeoutException.class)
.build(); // validates: maxAttempts ≥ 1, multiplier ≥ 1, …
Key points to cover:
RetryPolicy.none(), RetryPolicy.standard()), and records with a compact constructor for simple cases. Builders shine with many optional parameters, or staged construction.Learn it in depth → Builder Pattern
Vehicle class that has both fly() and sail(). How do IS-A and HAS-A relationships help, and how does this relate to the single responsibility principle?Short answer: A Vehicle with fly() and sail() forces cars to carry methods they can't honour, violating SRP, LSP and ISP. Refactor to:
Flyable, Sailable, Drivable), which classes implement: an IS-A relationship with a capability.Engine or Propulsion strategy, instead of inheriting it.A seaplane can then be both Flyable and Sailable, without deep hierarchies.
interface Flyable { void fly(); }
interface Sailable { void sail(); }
abstract class Vehicle { // only what ALL vehicles share
protected final Propulsion propulsion; // HAS-A: pluggable
Vehicle(Propulsion p) { this.propulsion = p; }
}
final class Seaplane extends Vehicle implements Flyable, Sailable {
Seaplane() { super(new TurbopropPropulsion()); }
public void fly() { propulsion.thrust(); /* take-off logic */ }
public void sail() { propulsion.thrust(); /* water taxi logic */ }
}
Learn it in depth → Liskov Substitution & Interface Segregation
Short answer:
new Pizza(12, true, false, true) is unreadable.The cost is extra code, and a heavier API for small objects.
Key points to cover:
@Builder, or record-based factories, the boilerplate cost is low. Still, don't use builders for 2–3 required fields. A constructor or record is clearer.Short answer: A classic singleton can be broken by:
setAccessible(true).The defences:
readResolve() returning INSTANCE, no Cloneable, and a holder idiom or volatile double-checked locking for laziness.public enum ConfigRegistry {
INSTANCE;
private final Map<String, String> values = new ConcurrentHashMap<>();
public String get(String key) { return values.get(key); }
}
Key points to cover:
Learn it in depth → Singleton Pattern
Cloneable used?Short answer: A shallow copy duplicates the top-level object, but shares the referenced objects. Mutating a nested list through the copy affects the original. A deep copy recursively duplicates the nested mutable state too. Cloneable is a marker interface that makes Object.clone() perform a field-by-field (shallow) copy instead of throwing CloneNotSupportedException. You override clone(), make it public, and deep-copy the mutable fields yourself.
Key points to cover:
clone() is widely considered broken by design: it bypasses constructors, clashes with final fields, and requires casting. Prefer copy constructors or static factories (Order.copyOf(order)), or immutable objects, which never need copying.equals() compare user profiles by their unique identifier?Short answer: Base both equals and hashCode on the stable identifier. Handle this == o, null and the type check, and keep the behaviour consistent with how profiles are used in collections.
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof UserProfile other)) return false;
return userId != null && userId.equals(other.userId); // a transient (not yet saved) profile equals only itself
}
@Override public int hashCode() { return getClass().hashCode(); } // stable across persist, when the ID is generated late
Key points to cover:
hashCode changes when the entity is persisted, which breaks HashSets. Hence the constant hashCode above, or a natural business key (such as a UUID assigned at creation).Short answer: Define the behaviour in a supertype (an interface or abstract class), and let each subtype override it. Client code depends only on the supertype, and dynamic dispatch picks the right behaviour at runtime. New animals are added without changing existing code (open/closed).
sealed interface Animal permits Dog, Cat, Parrot { String speak(); }
record Dog() implements Animal { public String speak() { return "Woof"; } }
record Cat() implements Animal { public String speak() { return "Meow"; } }
record Parrot(String phrase) implements Animal { public String speak() { return phrase; } }
List<Animal> zoo = List.of(new Dog(), new Cat(), new Parrot("Hello"));
zoo.forEach(a -> System.out.println(a.speak()));
Key points to cover:
Short answer: Declare the class final. Its methods then can't be overridden at all, because no subclass can exist. If the class must stay extensible but some core methods must not change, keep the class non-final, and mark those methods final (the template-method style). Alternatives:
permits), which allow only known subclasses.Key points to cover:
final classes or methods. Keep beans that need AOP, and JPA entities, non-final.equals() for custom equality conditions?Short answer: Follow the contract (reflexive, symmetric, transitive, consistent, x.equals(null) == false):
instanceof pattern, or getClass()).Objects.equals (null-safe), or compareTo == 0 where the scale matters (BigDecimal).Always override hashCode over the same fields.
Key points to cover:
instanceof vs getClass(): instanceof allows subclasses to be equal to the parent, which can break symmetry if a subclass adds fields. getClass() is strict. Or make the class final.equals.Short answer: In plain Java, use an enum singleton, or the initialization-on-demand holder idiom. Both are thread-safe without explicit locking. In a Spring application, make it a singleton-scoped bean: one per context, injected, testable, and configured from @ConfigurationProperties.
public final class ConfigManager {
private ConfigManager() { load(); }
private static final class Holder { static final ConfigManager INSTANCE = new ConfigManager(); }
public static ConfigManager get() { return Holder.INSTANCE; } // lazy, thread-safe via class init
}
Key points to cover:
Short answer: The lazy if (instance == null) version is not thread-safe. The safe options:
volatile field.Also make the singleton's state safe: immutable configuration snapshots, swapped atomically on reload through an AtomicReference.
private final AtomicReference<Config> current = new AtomicReference<>(Config.load());
public Config config() { return current.get(); }
public void reload() { current.set(Config.load()); } // readers never see half-built config
Short answer: When callers must react differently to a specific business failure, or when you need structured context. For example, in payments: InsufficientFundsException(accountId, requested, available) lets the API return a 422 with a clear error code, lets the UI prompt for a top-up, and lets monitoring count business declines separately from system errors. A generic IllegalStateException carries none of that.
public final class InsufficientFundsException extends RuntimeException {
private final String accountId; private final BigDecimal requested, available;
public InsufficientFundsException(String accountId, BigDecimal requested, BigDecimal available) {
super("Account %s: requested %s, available %s".formatted(accountId, requested, available));
this.accountId = accountId; this.requested = requested; this.available = available;
}
public String errorCode() { return "INSUFFICIENT_FUNDS"; }
}
Key points to cover:
@RestControllerAdvice) to stable error codes.Short answer: Package by feature (or bounded context) first, and by layer only within a feature: com.acme.orders.{api, domain, persistence}, com.acme.payments.{…}. Make most classes package-private, and expose each feature through a small public API. Enforce the boundaries with ArchUnit tests or Spring Modulith, so modules don't reach into each other's internals.
com.acme.shop
├── orders/ (OrderController, OrderService, Order, OrderRepository — mostly package-private)
├── payments/
├── catalog/
└── shared/ (small, stable cross-cutting types only)
Common trap: top-level controller/, service/, repository/ packages for the whole application. Every feature change touches every package, everything has to be public, and boundaries erode. That's the opposite of "package by functionality".
Q: Records or Lombok? A: Records for immutable data carriers, since they're in the language, with no annotation processing. Lombok where you need mutable JPA entities, or builders on non-record classes. Many teams use both, judiciously.
Q: How do you enforce architecture rules automatically?
A: ArchUnit tests ("classes in ..domain.. must not depend on ..api..", "no cycles between features"), or Spring Modulith's ApplicationModules.verify() in the test suite.
Q: What's the "fragile base class" problem? A: Subclasses depend on the parent's implementation details, so changing the parent (even internally) can break subclasses. It's a strong reason to prefer composition, and to design classes for inheritance explicitly, or make them final.
Q: When is inheritance still the right choice? A: For a true IS-A relationship where substitutability holds, when the base class is designed for extension (template methods, documented hooks), or when a framework requires it.