← Home
Design Patterns

Top 8 Senior Interview Questions

SOLID · Creational · Structural · Behavioural · Architecture Patterns

Questions

  1. SOLID principles — practical application
  2. Strategy pattern
  3. Factory / Abstract Factory
  4. Observer / Event-driven
  5. Decorator pattern
  6. Builder pattern
  7. Hexagonal Architecture (Ports & Adapters)
  8. CQRS and Event Sourcing
Q1 · Principles
Explain SOLID principles with real code examples — not textbook definitions.

S — Single Responsibility

One class, one reason to change.

// BAD: PaymentService validates, saves, AND sends email
class PaymentService {
    void process(Payment p) { validate(p); save(p); sendEmail(p); }
}

// GOOD: each class has one job
class PaymentValidator  { void validate(Payment p) { ... } }
class PaymentRepository { void save(Payment p)     { ... } }
class PaymentNotifier   { void notify(Payment p)   { ... } }

O — Open/Closed

Open for extension, closed for modification. Add behaviour without changing existing code.

// BAD: adding a new payment type means editing this switch
double getFee(Payment p) {
    switch (p.getType()) {
        case WIRE: return 15.0;
        case ACH:  return 0.5;
    }
}

// GOOD: add new FeeStrategy without touching existing code
interface FeeStrategy { double getFee(Payment p); }
class WireFeeStrategy implements FeeStrategy { ... }
class CryptoFeeStrategy implements FeeStrategy { ... } // new type — no changes elsewhere

L — Liskov Substitution

A subtype must be substitutable for its parent without breaking the program.

// VIOLATION: ReadOnlyAccount extends Account but throws on withdraw()
// Callers who expect Account.withdraw() to work will break.
// Fix: don't extend — use a separate interface or composition.

I — Interface Segregation

Don't force clients to depend on methods they don't use.

// BAD: one fat interface
interface AccountOperations {
    void deposit(); void withdraw(); void generateReport(); void auditLog();
}

// GOOD: split by client need
interface Transactional { void deposit(); void withdraw(); }
interface Reportable    { void generateReport(); }
interface Auditable     { void auditLog(); }

D — Dependency Inversion

Depend on abstractions, not concretions. High-level modules shouldn't depend on low-level modules.

// BAD: PaymentService directly depends on EmailService (concrete)
class PaymentService {
    private final EmailService email = new EmailService(); // hard-coded
}

// GOOD: depends on abstraction, injected by Spring
class PaymentService {
    private final NotificationSender sender; // interface — swap email/SMS/push freely
    public PaymentService(NotificationSender sender) { this.sender = sender; }
}
Likely follow-up: "Which SOLID principle do you find hardest to apply?" → LSP in inheritance hierarchies. Once you have deep inheritance, subtle violations creep in. Composition over inheritance is the usual escape.
Q2 · Behavioural
Strategy pattern — explain it and show a real use case.

Intent

Define a family of algorithms, encapsulate each, and make them interchangeable at runtime. Eliminates if/else or switch chains based on type.

Implementation

interface FeeStrategy {
    BigDecimal calculate(Transaction txn);
}

class StandardFee  implements FeeStrategy { ... }   // 1.5%
class PremiumFee   implements FeeStrategy { ... }   // 0.5%
class InternationalFee implements FeeStrategy { ... } // 3% + FX markup

@Service
class TransactionService {
    private final Map<CustomerTier, FeeStrategy> strategies;

    public BigDecimal process(Transaction txn, CustomerTier tier) {
        return strategies.get(tier).calculate(txn); // no if/else — just a map lookup
    }
}

When to use vs when not to

  • Use: behaviour varies by type/config at runtime, you're adding new variants frequently.
  • Don't use: only 2 variants that never change — it's over-engineering. A simple conditional is clearer.
Likely follow-up: "How is Strategy different from State pattern?" → Strategy: algorithm is chosen by the client, object doesn't change its own strategy. State: object transitions itself between states, each state changes the behaviour. Both use composition but the intent differs.
Q3 · Creational
Factory Method vs Abstract Factory — what's the difference and when do you use each?

Factory Method

Defines an interface for creating one type of object. Subclasses decide which class to instantiate.

abstract class PaymentProcessorFactory {
    public abstract PaymentProcessor create();  // subclass decides
}

class StripeProcessorFactory extends PaymentProcessorFactory {
    public PaymentProcessor create() { return new StripeProcessor(apiKey); }
}

Simple static factory (most common in Java)

class PaymentProcessorFactory {
    public static PaymentProcessor create(PaymentType type) {
        return switch (type) {
            case STRIPE -> new StripeProcessor(config);
            case PAYPAL -> new PaypalProcessor(config);
            case WIRE   -> new WireTransferProcessor(config);
        };
    }
}

Abstract Factory

Creates families of related objects. Ensures objects from the same family are used together.

interface PaymentInfraFactory {
    PaymentGateway gateway();
    FraudChecker fraudChecker();
    NotificationSender notifier();
}

class EUPaymentFactory     implements PaymentInfraFactory { ... } // SEPA, EU fraud rules
class USPaymentFactory     implements PaymentInfraFactory { ... } // ACH, US fraud rules
class SandboxPaymentFactory implements PaymentInfraFactory { ... } // mocks for testing
Decision rule: Factory Method = create one thing. Abstract Factory = create a consistent family of related things.
Likely follow-up: "How does Spring's ApplicationContext relate to the Factory pattern?" → It IS a factory — it creates and manages beans. context.getBean(PaymentService.class) is a factory method call. Spring's DI container is an Abstract Factory that assembles entire object graphs.
Q4 · Behavioural
Observer pattern — how does it relate to event-driven architecture and Spring events?

Intent

Subject notifies multiple observers when its state changes. Observers don't know about each other. Subject doesn't know observer details — loose coupling.

Spring ApplicationEvents (in-process)

// 1. Define the event
public record OrderPlacedEvent(Order order) {}

// 2. Publish from the service
@Service
class OrderService {
    @Autowired ApplicationEventPublisher publisher;

    @Transactional
    public void placeOrder(Order order) {
        orderRepo.save(order);
        publisher.publishEvent(new OrderPlacedEvent(order));  // same transaction!
    }
}

// 3. Multiple independent listeners
@Component class NotificationListener {
    @EventListener
    public void onOrder(OrderPlacedEvent e) { notifier.send(e.order()); }
}

@Component class InventoryListener {
    @TransactionalEventListener(phase = AFTER_COMMIT) // fires only if TX commits
    public void onOrder(OrderPlacedEvent e) { inventory.reserve(e.order()); }
}

@TransactionalEventListener — the important detail

  • @EventListener: fires immediately, inside the same transaction. If transaction rolls back, the listener has already run.
  • @TransactionalEventListener(AFTER_COMMIT): fires only after the transaction successfully commits. Safe for sending emails, calling external APIs, publishing to Kafka.

Scaling to distributed: Kafka

Same pattern, different scope. In-process events → Spring ApplicationEvents. Cross-service events → Kafka topics. Kafka is the Observer pattern at microservice scale: producers publish events, multiple consumer groups subscribe independently.

Likely follow-up: "What's the risk of @EventListener vs @TransactionalEventListener?" → @EventListener fires during the transaction — if the listener throws, it rolls back the main transaction. @TransactionalEventListener(AFTER_COMMIT) decouples them: if the listener fails, the business transaction is already committed. Handle listener failures carefully (retry, DLQ).
Q5 · Structural
Decorator pattern — how does it work and when is it better than inheritance?

Intent

Attach additional responsibilities to an object dynamically by wrapping it. Alternative to subclassing — behaviours compose rather than inherit.

Implementation

interface OrderProcessor {
    OrderResult process(Order order);
}

class CoreOrderProcessor implements OrderProcessor {
    public OrderResult process(Order order) { /* core logic */ }
}

class LoggingDecorator implements OrderProcessor {
    private final OrderProcessor delegate;
    public OrderResult process(Order order) {
        log.info("Processing order {}", order.getId());
        OrderResult result = delegate.process(order);
        log.info("Done in {}ms", elapsed);
        return result;
    }
}

class FraudCheckDecorator implements OrderProcessor {
    public OrderResult process(Order order) {
        fraudService.check(order);    // throws if fraud detected
        return delegate.process(order);
    }
}

// Compose: fraud check → logging → core
OrderProcessor processor = new LoggingDecorator(
                              new FraudCheckDecorator(
                                  new CoreOrderProcessor()));

Decorator vs Inheritance

  • Inheritance: fixed at compile time. LoggedFraudCheckedOrderProcessor — class explosion with many combinations.
  • Decorator: composed at runtime. Mix and match behaviours. Add/remove without modifying classes.
You already use it: Java I/O streams (new BufferedReader(new FileReader(...))) and Spring's @Cacheable, @Transactional, @Async are all decorators via AOP.
Likely follow-up: "How is Decorator different from Proxy?" → Same structure, different intent. Proxy controls access to an object (adds security, lazy loading, remote access). Decorator adds behaviour. In practice: Spring @Transactional proxy is closer to Proxy (controlling access to a transaction); Spring @Cacheable is closer to Decorator (adding caching behaviour).
Q6 · Creational
Builder pattern — when is it essential vs overkill?

Problem it solves

Constructor with many parameters is error-prone (easy to swap args of same type), hard to read, and forces you to pass nulls for optional fields.

// HARD to read: what do these booleans mean?
new Order("ORD-1", customerId, items, address, true, false, null, priority);

Builder implementation

Order order = Order.builder()
    .id("ORD-1")
    .customerId(customerId)
    .items(items)
    .shippingAddress(address)
    .expressDelivery(true)
    .priority(Priority.HIGH)
    .build();  // validate required fields here, throw if missing

When to use

  • Object has 4+ parameters, especially with optional ones.
  • You want immutable objects (all fields set before construction, no setters).
  • Multiple valid configurations of the same class.

When it's overkill

  • 2–3 parameters, all required → constructor is cleaner.
  • Mutable objects with setters → just use setters.
Lombok: @Builder generates the builder automatically. @Builder(toBuilder = true) lets you copy and modify an existing instance — useful for immutable update patterns.
Likely follow-up: "How do you validate required fields in a Builder?" → Add validation in the build() method: check nulls, throw IllegalStateException with a clear message. This ensures you can never construct an invalid object — fail fast at the boundary.
Q7 · Architecture
What is Hexagonal Architecture (Ports & Adapters)? How do you apply it?

The idea

Core business logic sits at the centre, isolated from external concerns (DB, HTTP, Kafka, external APIs). The domain defines ports (interfaces for what it needs). External systems plug in via adapters (implementations of those interfaces).

Folder structure

src/
  domain/
    model/          Order, Customer, Payment    // pure business objects
    port/
      in/           OrderService (interface)    // what drives the domain
      out/          OrderRepository, PaymentGateway  // what domain needs
    service/        OrderServiceImpl            // business logic — no frameworks

  adapter/
    in/
      web/          OrderController             // REST → calls domain port
      kafka/        OrderEventConsumer          // Kafka → calls domain port
    out/
      persistence/  JpaOrderRepository          // implements domain's out port
      external/     StripePaymentAdapter        // implements PaymentGateway

Key benefit

  • Test business logic with zero infrastructure — just instantiate the domain service with mock ports.
  • Swap DB (PostgreSQL → MongoDB), or messaging (Kafka → RabbitMQ) without touching domain logic.
  • Domain code has no Spring, JPA, or Kafka imports — it's pure Java.

vs Layered Architecture

LayeredHexagonal
DependenciesTop-down (Controller → Service → Repo)All point inward (Adapters → Domain)
DB couplingJPA leaks into service layerDomain knows nothing about JPA
TestabilityHard without Spring contextDomain unit-testable in isolation
Likely follow-up: "Isn't this over-engineering for a simple CRUD service?" → Yes. Hexagonal shines when business logic is complex and changes frequently. For a simple CRUD service, layered architecture is fine. Apply it where the investment pays off — don't add ports/adapters just for the pattern's sake.
Q8 · Architecture Patterns
What is CQRS? What is Event Sourcing? When do you use them and when do you avoid them?

CQRS — Command Query Responsibility Segregation

Separate the write model from the read model.

// Write side: normalised, ACID, strongly consistent
orderCommandService.placeOrder(command);   // writes to orders DB

// Read side: denormalised, optimised for queries, eventually consistent
orderQueryService.getOrderSummary(id);     // reads from read-optimised view

Sync between them via events (Kafka). Write side publishes OrderPlaced → read side updates its projection.

Event Sourcing

Store all state changes as immutable events. Current state = replay all events from the beginning (or from a snapshot).

// Account event store:
AccountCreated    { accountId, ownerId, timestamp }
MoneyDeposited    { accountId, amount, timestamp }
MoneyWithdrawn    { accountId, amount, timestamp }
AccountFrozen     { accountId, reason, timestamp }

// Current balance = sum of all deposits - sum of all withdrawals
// Full audit trail is free — it's the source of truth.

When to use CQRS

  • Read and write loads are dramatically different (heavy reads vs complex writes).
  • You need multiple read models of the same data (dashboard, mobile, API).
  • Microservices that need data from multiple domains in one query.

When NOT to use

  • Simple CRUD — the complexity is not justified.
  • When strong consistency is required for reads (users expect to see their write immediately).
Complexity warning: CQRS + Event Sourcing together is significant complexity. Schema evolution of events is hard. Snapshot strategies add more code. Use only where the audit trail and temporal queries justify the investment.
Likely follow-up: "How do you handle eventual consistency in the read model?" → Design the UI to tolerate it (show "pending" state, refresh after confirmation). For critical reads after writes, read directly from the write DB with a short-circuit path. Use read-after-write consistency only where genuinely needed.