2. Object-Oriented Design Deep Dive — Interview Q&A

2. Object-Oriented Design Deep Dive — Interview Q&A

2.1 Abstract Classes vs Interfaces (default/static methods)

Q1: When would you choose an abstract class over an interface in Java 8+, given interfaces now support default methods? Despite default methods narrowing the gap, key differences remain:

  • State: Abstract classes can hold instance fields/state; interfaces cannot (only constants via static final).
  • Constructors: Abstract classes can have constructors to initialize shared state; interfaces cannot.
  • Single inheritance constraint: A class can extend only one abstract class but implement multiple interfaces — so if you need to share implementation state across a family of related classes, use an abstract class; if you’re defining a capability/contract that unrelated classes might implement, use an interface.

Architectural rule of thumb: interfaces define “what” (capability/contract — e.g., Comparable, Serializable), abstract classes define “what + some how” (partial implementation with shared state — e.g., an AbstractRepository providing common CRUD logic).

Q2: What problem do default methods solve, and what’s the risk they introduced? Default methods (Java 8) solved the interface evolution problem — before Java 8, adding a new method to an interface broke every existing implementation. Default methods let library designers add new behavior without breaking backward compatibility (this is precisely how the Collections framework added Stream support without breaking all existing List/Map implementations).

Risk: the diamond problem reintroduced — if a class implements two interfaces with conflicting default methods, the compiler forces an explicit override (InterfaceA.super.method() syntax) to resolve ambiguity, otherwise it’s a compile error.

Q3: Explain static methods in interfaces and a practical use case. Static interface methods (Java 8+) provide utility/factory methods scoped to the interface, without polluting the implementing classes’ namespace. Classic example: Comparator.comparing(), Comparator.naturalOrder() — these are static factory methods on the Comparator interface itself, callable as Comparator.comparing(...) without needing an instance.

Q4: Architect-level — How do you design a plugin/extension architecture using interfaces with default methods? A common pattern: define a core interface with required abstract methods (the extension contract), and add default methods for optional hooks with sensible no-op implementations — so plugin authors only override what they need.

public interface PaymentGatewayPlugin {
    PaymentResult process(PaymentRequest request); // required
    default void onPreProcess(PaymentRequest request) {} // optional hook
    default void onPostProcess(PaymentResult result) {}  // optional hook
}

This reduces boilerplate for plugin implementers while keeping the contract extensible — common in Spring Boot auto-configuration and SPI-style designs.


2.2 Composition vs Inheritance

Q1: What is the “fragile base class problem,” and how does composition avoid it? The fragile base class problem occurs when a seemingly safe change to a superclass (e.g., changing an internal method’s implementation) breaks subclasses that depend on the old behavior — because subclasses are tightly coupled to the superclass’s internals, not just its public contract. This is especially dangerous in deep inheritance hierarchies maintained by different teams over time.

Composition avoids this because the “using” class only depends on the public interface of its collaborators (injected dependencies), not their internal implementation — so changes inside a collaborator don’t ripple outward as long as the contract is honored.

Q2: Give an enterprise example where you’d refactor from inheritance to composition. Anti-pattern: class CachedUserRepository extends UserRepository overriding findById() to add caching — this breaks if UserRepository‘s internal call structure changes (e.g., if findById() starts calling a new internal method that bypasses the override).

Better (composition + Decorator pattern):

public class CachedUserRepository implements UserRepository {
    private final UserRepository delegate;
    private final Cache<Long, User> cache;
    
    public User findById(Long id) {
        return cache.get(id, () -> delegate.findById(id));
    }
}

This is explicitly how Spring’s @Cacheable and many decorator-based cross-cutting concerns (logging, retry, circuit breaking) are implemented in production systems.

Q3: Architect-level — “Favor composition over inheritance” — when is this principle wrong to apply rigidly? For genuine “is-a” taxonomic relationships with stable, well-understood contracts — e.g., Exception hierarchies, or a framework’s well-defined extension points (like AbstractController base classes provided by a framework you don’t own) — inheritance is appropriate and idiomatic. The principle is a caution against overusing inheritance for code reuse convenience rather than genuine type modeling; it’s not an absolute ban. Architecturally, ask: “Will this hierarchy need to evolve independently at each level?” If yes, composition. If the relationship is conceptually fixed and unlikely to need that flexibility, inheritance is fine.


2.3 SOLID Principles with Java Examples

Q1: Walk through each SOLID principle with a Java code smell and its fix.

  • S — Single Responsibility Principle: A class should have one reason to change. Smell: OrderService that handles order validation, persistence, AND email notification. Fix: Extract OrderValidator, OrderRepository, NotificationService — each with one responsibility.
  • O — Open/Closed Principle: Open for extension, closed for modification. Smell: A switch statement on discountType that needs editing every time a new discount type is added. Fix: DiscountStrategy interface with polymorphic implementations registered/injected — new types added without touching existing code.
  • L — Liskov Substitution Principle: Subtypes must be substitutable for their base type without breaking behavior. Smell: Square extends Rectangle that overrides setWidth()/setHeight() to keep sides equal — violates caller expectations if code assumes independent width/height mutation. Fix: Don’t model Square as a Rectangle subtype; use composition or a shared Shape interface instead.
  • I — Interface Segregation Principle: Don’t force classes to implement methods they don’t use. Smell: A fat Worker interface with code(), eat(), attendMeetings() forcing a RobotWorker to implement irrelevant eat(). Fix: Split into Codable, Eatable, etc.
  • D — Dependency Inversion Principle: Depend on abstractions, not concretions. Smell: OrderService directly instantiating MySQLOrderRepository. Fix: Inject OrderRepository interface, let Spring’s IoC container wire the concrete implementation.

Q2: Architect-level — How does Spring’s IoC container operationalize the Dependency Inversion Principle at scale? Spring’s ApplicationContext acts as the central composition root: instead of classes resolving their own dependencies (tight coupling, hard to test), Spring injects abstractions (interfaces) at runtime based on configuration (@Autowired, constructor injection, @Qualifier for disambiguation). This allows swapping implementations per environment (e.g., InMemoryUserRepository for tests, JpaUserRepository for production) without touching consumer code — DIP applied at the framework level via bean definitions and profiles (@Profile).

Q3: How would you identify an Open/Closed Principle violation during a code review, and what’s your refactoring approach? Red flag: repeated modification of the same class/method every time a new business variant is added (often visible as growing if-else/switch chains, or version-control history showing the same file changed for unrelated feature additions). Refactor by extracting the varying behavior into a Strategy pattern or polymorphic hierarchy, registered via a factory or Spring’s Map<String, Strategy> bean injection — so new variants are added as new classes, not new branches in existing code.


2.4 Design Patterns (Creational, Structural, Behavioral)

Q1: Singleton — what are the safe ways to implement it in modern Java, and why is the classic double-checked locking pattern tricky? Modern safe approaches:

  1. Enum singleton — enum Singleton { INSTANCE; } — thread-safe by JVM guarantee, serialization-safe, and immune to reflection-based instantiation attacks.
  2. Static holder (Bill Pugh pattern) — lazy-loaded via a static nested class, thread-safe without synchronization overhead, leveraging the JVM’s class-loading guarantees.
  3. Spring-managed singleton — in enterprise contexts, Spring beans are singleton-scoped by default; you rarely write singleton boilerplate yourself.

Double-checked locking (if (instance == null) { synchronized(...) { if (instance == null) instance = new Singleton(); } }) is tricky because, pre-Java 5, the JVM could reorder instructions during object construction (the “partially constructed object” problem). It only became safe with the volatile keyword (which establishes a happens-before relationship), making it more error-prone than necessary compared to the alternatives above.

Q2: Factory vs Abstract Factory — explain with a Spring-relevant example.

  • Factory Method: One method creates one family of related objects based on input. Example: NotificationFactory.create(NotificationType type) returning EmailNotification or SMSNotification.
  • Abstract Factory: A factory of factories — produces families of related objects without specifying concrete classes. Example: a UIThemeFactory interface with DarkThemeFactory/LightThemeFactory implementations, each producing a consistent set of Button, Checkbox, Dialog components matching that theme.

Spring’s BeanFactory/ApplicationContext is itself an Abstract Factory implementation — it abstracts the creation of any bean type based on configuration, without the calling code knowing concrete classes.

Q3: Builder pattern — why is it preferred over telescoping constructors or setters for complex object creation? Telescoping constructors (multiple overloaded constructors for optional parameters) become unreadable beyond 3-4 parameters. Setters allow object creation in an inconsistent intermediate state and aren’t friendly to immutability. The Builder pattern:

  • Supports fluent, readable construction: User.builder().name("Bobin").email("...").build().
  • Enables immutability — the built object’s fields can be final, set only once via the builder.
  • Allows validation at build() time — catching invalid combinations before the object exists.

In modern Java, Records (Java 16+) reduce the need for Builders in simple immutable data carriers, but Builder remains essential for objects with many optional fields or complex construction validation.

Q4: Architect-level — How do Strategy and Decorator patterns combine in Spring’s cross-cutting concern design (e.g., resilience, caching)? Spring AOP-based features (@Cacheable, @Retryable, @Transactional) are essentially Decorators generated via proxies (JDK dynamic proxy or CGLIB) — wrapping the real bean with additional behavior transparently. The underlying business logic selection (e.g., which retry policy, which cache eviction strategy) often follows a Strategy pattern, injected as a bean and configured declaratively. Architecturally, this combination lets you add cross-cutting behavior (logging, security, resilience) without modifying business logic classes — a direct application of Open/Closed + Decorator + Strategy working together.

Q5: Observer pattern — how does it relate to Spring’s ApplicationEventPublisher and event-driven microservices? Observer defines a one-to-many dependency: when a subject’s state changes, all registered observers are notified. Spring implements this natively via ApplicationEventPublisher and @EventListener — publishing an event (e.g., OrderPlacedEvent) decouples the publisher from knowing which/how many listeners react (inventory update, email notification, analytics). At microservices scale, this same pattern extends externally via message brokers (Kafka, RabbitMQ) — the architectural principle (decoupled publish/subscribe) is identical; only the transport and durability guarantees change.


2.5 equals(), hashCode(), and Contract Violations

Q1: What is the equals()/hashCode() contract, and what breaks if you violate it? The contract (per Object Javadoc):

  1. If two objects are equal per equals(), they must have the same hashCode().
  2. The reverse is not required — unequal hashCodes implies unequal objects, but equal hashCodes don’t imply equal objects (hash collisions are allowed).

Violation consequence: If you override equals() without overriding hashCode(), two “equal” objects can land in different hash buckets in a HashMap/HashSet. This causes silent bugs: map.containsKey(key) returns false even though an “equal” key exists, because the lookup hashes to the wrong bucket entirely.

Q2: Why must hashCode() use only fields that are immutable, or fields that never change after the object is used as a map key? If a field used in hashCode() changes after insertion into a HashMap/HashSet, the object’s hash bucket location becomes stale — the object effectively becomes “lost” in the collection (it’s stored in bucket based on old hashCode, but lookups compute the new hashCode and search the wrong bucket). This is a classic production bug: mutable entity fields used in equals()/hashCode() for JPA entities stored in Sets.

Q3: Architect-level — How do equals()/hashCode() pitfalls manifest specifically in JPA entities, and what’s the recommended approach? Common pitfall: using the auto-generated database ID in equals()/hashCode(). Before persistence, a new entity’s ID is null; after persistence, it’s assigned — meaning the object’s hashCode changes after being added to a HashSet pre-save, causing it to become unreachable in that collection.

Recommended approaches:

  1. Use a business key (natural unique identifier, e.g., email for a User) that doesn’t change, for equals()/hashCode().
  2. If no stable business key exists, use a UUID assigned at object creation (not DB-generated), so it’s stable before and after persistence.
  3. As a last resort, override only equals() based on class identity and avoid storing un-persisted entities in hash-based collections.

Q4: What does Objects.equals() and Objects.hash() solve, and why use them over manual implementation? Objects.equals(a, b) null-safely compares two references (avoids NullPointerException from a.equals(b) when a is null). Objects.hash(field1, field2, ...) provides a concise, correct combination of multiple fields’ hashCodes (internally using the same algorithm as Arrays.hashCode), reducing boilerplate and the risk of manually getting the combination formula wrong (e.g., forgetting the standard 31 * result + field.hashCode() multiplier pattern, which matters for hash distribution quality).


2.6 Object Cloning (Shallow vs Deep Copy)

Q1: Differentiate shallow copy and deep copy with an example involving nested objects.

  • Shallow copy: Copies the top-level object’s fields, but reference fields point to the same underlying objects as the original. Mutating a nested object through the clone affects the original too.
  • Deep copy: Recursively copies nested objects as well, so the clone is fully independent.

Example:

class Address { String city; }
class Person implements Cloneable {
    String name;
    Address address;
    
    public Person clone() {
        Person copy = new Person();
        copy.name = this.name;        // primitive/immutable — safe
        copy.address = this.address;  // shallow — same reference!
        return copy;
    }
}

If clone.address.city is changed, the original person.address.city changes too — a classic shallow-copy bug.

Q2: Why is Java’s built-in Cloneable/clone() mechanism widely considered poorly designed?

  • Cloneable is a marker interface with no clone() method declared on it — clone() actually comes from Object and is protected, leading to confusing override requirements.
  • Default Object.clone() performs only a shallow copy, requiring manual override for deep copying — easy to forget for nested mutable fields.
  • It doesn’t work well with final fields (can’t reassign them in a cloned object via the typical clone pattern) and interacts awkwardly with constructors (clone bypasses constructor calls entirely, which can skip important initialization logic).

Q3: What are the recommended alternatives to Cloneable in modern Java?

  1. Copy constructors: new Person(existingPerson) — explicit, readable, and works naturally with final fields.
  2. Static factory copy methods: Person.copyOf(existing).
  3. Builder pattern with a toBuilder() method for partial copies with modifications.
  4. Records (Java 16+): Since records are immutable by design, “copying with modification” is handled idiomatically via the compact constructor or by constructing a new record instance — sidestepping the cloning problem entirely.

Q4: Architect-level — In a multi-threaded caching layer, why does deep-copy semantics matter for correctness? If a cache returns a shared reference to a cached object rather than a deep copy, one thread mutating the returned object corrupts the cached value for all other threads/requests reading from that cache — a subtle, hard-to-reproduce concurrency bug. Defensive deep-copying on cache read (or enforcing immutability on cached objects, which is the better architectural fix) prevents this entire class of bugs. This is why many caching frameworks recommend caching immutable DTOs rather than mutable domain entities.