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; }
}
Interview drill-down — expect these follow-ups:
L1 → "Which SOLID principle do you find hardest to apply and why?"
LSP is the subtlest. It's not just about method signatures — it's about behavioral contracts. A classic violation: Rectangle has setWidth/setHeight. Square extends Rectangle and overrides both to always set both dimensions equally. The signature compiles, but code that relies on "setting width doesn't change height" breaks silently with a Square instance. The fix isn't a code change — it's recognizing that Square and Rectangle don't have an is-a relationship in terms of behavior, only in terms of geometry. Composition over inheritance prevents most LSP violations.
L2 → "Open/Closed principle says 'closed for modification'. But you just added a null check to an existing method. Did you violate OCP?"
No. OCP is about protecting behavior extension points, not about never touching existing code. Bug fixes, performance improvements, and defensive guards are legitimate modifications. OCP means: when you need to add a new payment type, you shouldn't need to edit the fee calculation switch statement — that's the protected extension point. The principle guides design, not a blanket prohibition on edits. Taken too literally, OCP becomes an excuse to never refactor.
L3 → "You have a service with 15 dependencies injected via constructor. Is this an SRP violation and how do you fix it?"
Yes — 15 constructor params is a clear signal the class has too many responsibilities. Diagnosis: group the dependencies by concern. If 5 relate to notifications, 4 to persistence, 3 to fee calculation — each group should be a separate service. Extract aggregated services: NotificationOrchestrator, PaymentPersistenceService, FeeEngine. The original service becomes a thin orchestrator with 3 dependencies. The tell is: if you can't describe the class in one sentence without using "and", it violates SRP.
Trap answer to avoid: "Dependency Inversion means using dependency injection frameworks like Spring." — DIP is a design principle about depending on abstractions, not concretions. Spring DI is a mechanism that makes it easier to apply DIP, but you can apply DIP without Spring (manual constructor injection) and violate DIP with Spring (injecting a concrete class directly instead of an interface). The principle and the framework are separate concepts.
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.
Interview drill-down — expect these follow-ups:
L1 → "How is Strategy different from State pattern?"
Strategy: the algorithm is chosen by the caller/client and doesn't change during the object's lifetime within a call. The object itself doesn't transition. State: the object manages its own transitions between states, and the current state determines its behavior — the object changes its own strategy implicitly. Example: an Order object that behaves differently when PENDING vs SHIPPED vs CANCELLED is State pattern — the order transitions itself. A FeeCalculator that uses different fee algorithms based on customer tier is Strategy — the caller decides which strategy to inject.
L2 → "You have 20 FeeStrategy implementations. How do you auto-register them all in Spring without a giant @Bean factory method?"
Each strategy implements FeeStrategy and carries a qualifier annotation (e.g., @Component("WIRE_FEE"), @Component("ACH_FEE")). In the service, inject all implementations: @Autowired Map<String, FeeStrategy> strategies — Spring automatically builds a map keyed by bean name. Or use a custom @FeeStrategyType("WIRE") annotation and collect with @Autowired List<FeeStrategy> allStrategies, then build the map yourself. The auto-registration pattern scales to any number of strategies without touching the service when adding new ones — pure OCP.
L3 → "Your Strategy pattern works but the strategy selection logic is in the service. If you add a new customer tier, you still need to change the service. How do you fix this?"
Move strategy selection into a StrategyResolver or factory that takes context and returns the right strategy. The resolver is the only place that knows about all tiers. The service only calls resolver.resolve(customer).calculate(txn). Now adding a new tier = adding a new Strategy implementation + updating the resolver only. For truly open-closed resolution, use a priority-ordered list: each strategy has a supports(context) method, and the resolver picks the first that returns true. New strategies self-register by implementing supports() — the resolver never changes.
Trap answer to avoid: "Strategy eliminates all conditionals." — Strategy eliminates conditionals inside the algorithm. You still need a selection mechanism to choose which strategy to use — that's often a conditional or a map lookup. The goal is to remove the algorithm's implementation from the conditional, not to pretend conditionals don't exist. Claiming "no more if statements anywhere" shows over-application of the pattern.
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.
Interview drill-down — expect these follow-ups:
L1 → "How does Spring's ApplicationContext relate to the Factory pattern?"
The ApplicationContext IS a factory — specifically, it's an Abstract Factory that assembles entire object graphs. context.getBean(PaymentService.class) is a factory method call. Spring goes further: it's also a registry (beans are singletons by default), and it handles lifecycle management (initialization, destruction). The BeanFactory interface at the core of Spring is the minimal factory; ApplicationContext adds event publishing, i18n, AOP, etc. Recognizing DI containers as Abstract Factories explains why they're so powerful: they wire together entire families of related objects.
L2 → "When would you use a static factory method over a constructor — and when does Spring's DI make both unnecessary?"
Static factory methods are valuable when: (1) the constructor name is not descriptive (LocalDate.of() vs new LocalDate()), (2) you want to return different subtypes (Collections.unmodifiableList() returns an internal type), (3) caching instances (Integer.valueOf() caches small integers). Spring DI makes explicit factory methods unnecessary for wiring — Spring is your factory. But static factories are still useful for creating value objects and DTOs that aren't Spring-managed beans. The distinction: Spring factories manage lifecycle; static factories create values.
L3 → "You need to create payment processors at runtime based on user-chosen payment method — not at Spring startup. How do you do this with Spring?"
Use a Spring-managed factory bean that looks up or creates instances at runtime. Option 1: @Lookup method injection — Spring overrides the method to return a new prototype-scoped bean each call. Option 2: inject ApplicationContext and call ctx.getBean(StripeProcessor.class) — but this is service-locator anti-pattern. Option 3 (best): use ObjectProvider<StripeProcessor> or ObjectFactory<StripeProcessor> — Spring injects a factory handle, you call .getObject() when needed. This maintains DI benefits while deferring creation.
Trap answer to avoid: "Abstract Factory is just a Factory with more factories inside." — This circular definition misses the intent. Abstract Factory ensures that a family of related objects are always used together consistently. The US payment factory always returns US-compliant gateway + US fraud checker + USD notifier. The EU factory always returns SEPA-compliant equivalents. You cannot accidentally mix a US fraud checker with an EU gateway. That consistency guarantee is the point, not just "more factories".
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.
Interview drill-down — expect these follow-ups:
L1 → "What's the risk of @EventListener vs @TransactionalEventListener?"
@EventListener fires during the active transaction — synchronously, in the same thread. If the listener throws an unchecked exception, it propagates up and rolls back the main transaction. This creates hidden coupling: the notification listener can sabotage the order placement. @TransactionalEventListener(AFTER_COMMIT) decouples them: the listener fires after the transaction commits, in a new thread. If it fails, the business transaction is already committed. Handle listener failures carefully with retry (@Retryable) or route to a DLQ. Never put critical business logic in an AFTER_COMMIT listener that might fail silently.
L2 → "A @TransactionalEventListener(AFTER_COMMIT) publishes to Kafka. If the Kafka publish fails, the event is lost. How do you handle this?"
This is the dual-write problem. The correct solution is the Outbox Pattern: instead of publishing directly to Kafka in the listener, write to an outbox table in the same DB transaction. A separate relay (Debezium or polling job) reads the outbox and publishes to Kafka. This way, Kafka publish failure doesn't cause event loss — the outbox row is durable and will be retried. @TransactionalEventListener direct-to-Kafka is only safe if you accept occasional loss and your consumers are designed to handle it via reconciliation.
L3 → "Your OrderService publishes an OrderPlacedEvent. Two unrelated listeners each do their own DB writes. Listener B depends on Listener A completing first. How do you handle ordering?"
Spring application events are synchronous by default — listeners fire in registration order or by @Order. You can use @Order(1) on Listener A and @Order(2) on Listener B. But this creates implicit coupling that should be avoided. Better: Listener B should react to its own triggering event (e.g., InventoryReservedEvent published by Listener A) forming an event chain. Each step is independent. For async listeners (@Async), ordering guarantees are lost entirely — don't use @Order with @Async. If ordering truly matters, it should be modeled as explicit sequential business logic, not emergent listener ordering.
Trap answer to avoid: "Spring ApplicationEvents replace Kafka for event-driven architecture." — ApplicationEvents are in-process only — they die with the JVM. No persistence, no delivery guarantee, no cross-service communication. Kafka is for inter-service events with durable delivery. Use ApplicationEvents for decoupling within one service (e.g., separating concerns in an order placement flow). Never use them as a substitute for a message broker in a distributed system.
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.
Real examples you can cite in interviews
Java I/O — the canonical example:
// Each layer wraps the next — Decorator in action:
Reader reader = new BufferedReader( // adds buffering
new InputStreamReader( // converts bytes → chars
new FileInputStream("data.txt"))); // raw bytes
// All three implement the same Reader interface.
// BufferedReader doesn't know if it wraps a file, network, or in-memory source.
// Add GZIPInputStream in the middle for transparent decompression — zero changes elsewhere.
Spring Security filter chain: Spring Security's filter chain is a Decorator chain at HTTP level. Each filter wraps the next and adds one concern:
// Conceptually (simplified):
// SecurityContextFilter → wraps →
// CsrfFilter → wraps →
// BearerTokenFilter → wraps →
// AuthorizationFilter → wraps →
// DispatcherServlet (the core)
// Your filter chain config IS a Decorator composition:
return http
.csrf(csrf -> csrf.disable()) // remove CsrfFilter from chain
.addFilterBefore(jwtFilter, BearerTokenAuthenticationFilter.class) // insert new decorator
.build();
This is why "adding security" in Spring doesn't require modifying controllers — you add decorators around them.
Interview drill-down — expect these follow-ups:
L1 → "How is Decorator different from Proxy pattern?"
Same structural shape (wrapping an object implementing the same interface), different intent. Proxy controls access: it adds security checks, lazy loading, remote access, or caching without adding core behavior — the client might not even know the proxy is there. Decorator adds behavior: it extends what the object does, and the client is usually aware. In Spring: @Transactional AOP proxy controls transaction boundaries (Proxy intent) — it's not adding new behavior to the method, it's wrapping it in transaction management. @Cacheable is more Decorator-like — it adds caching behavior to an existing operation.
L2 → "Spring AOP uses Decorator/Proxy via CGLIB. Can you stack multiple decorators in Spring (e.g., @Transactional + @Cacheable + @Async on the same method)?"
Yes, but ordering matters. Spring generates a proxy chain where each AOP advice is a layer. Default order: @Transactional wraps @Cacheable wraps the actual method. With @Async, the method runs in a different thread — if it's inside a @Transactional boundary, the transaction won't propagate to the new thread (transactions are thread-bound). @Async + @Transactional on the same method means: the transaction opens in the new thread, not the calling thread. Use @Order on aspects to control the chain. When stacking, always think about which wrapper is outermost and what that means for the execution context.
L3 → "Java I/O uses Decorator: new BufferedReader(new FileReader(...)). What's wrong with this API design and how would you improve it?"
The problem: the caller must know the implementation hierarchy to compose streams correctly. Wrapping order matters (BufferedReader wraps InputStreamReader wraps FileInputStream) and the API exposes internal types. It's flexible but verbose and error-prone. Better design: a factory or builder API — Files.newBufferedReader(path, charset) hides the decorator chain. The Decorator pattern is powerful but should be hidden behind a factory that provides common combinations. Expose the Decorator only where callers genuinely need to compose custom behavior; otherwise, factory methods for common use cases.
Trap answer to avoid: "I'd use inheritance instead of Decorator — it's simpler." — Inheritance leads to class explosion for every combination: LoggedOrderProcessor, LoggedFraudCheckedOrderProcessor, FraudCheckedOrderProcessor, etc. N behaviors × M combinations = N^M classes. With Decorator, each behavior is one class and combinations are composed at runtime. The "simpler" inheritance approach becomes unmanageable as soon as you have 3+ cross-cutting behaviors.
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.
Interview drill-down — expect these follow-ups:
L1 → "How do you validate required fields in a Builder?"
Add validation in the build() method: Objects.requireNonNull(id, "id is required"). For complex invariants, add the check as a private validateState() call inside build(). Throw IllegalStateException (not IllegalArgumentException — state is wrong, not the argument) with a message that names the missing field. With Lombok @Builder, add a custom build() method by writing @Builder and adding your own build() override in the class body — Lombok generates the builder fields, you control the final validation. Never accept null silently and discover it 10 call levels later.
L2 → "Lombok @Builder with @Builder(toBuilder = true) — when would you use this?"
When you have immutable value objects that need "modified copies." Common in CQRS command objects and domain events: OrderCommand updated = originalCommand.toBuilder().priority(Priority.HIGH).build(). Produces a new immutable instance with one field changed. This is the withX() pattern without manually writing every withX() method. Use it for: updating immutable domain objects, creating test data variants (baseOrder.toBuilder().status(CANCELLED).build()), and applying partial updates in event sourcing projections.
L3 → "You use Builder throughout your API. A consumer of your library needs to pass a partially-built object between methods and complete it later. Builder is not serializable by default. How do you handle this?"
Don't try to serialize the Builder — serialize the intermediate state as a plain POJO or record. Separate the "draft" state from the "complete" object: an OrderDraft record holds all optional/partial fields, is serializable, and is passed between layers. When ready to finalize, validate and convert: Order.from(draft). This is the "two-phase construction" pattern: mutable draft → immutable complete object. Trying to make Builder itself serializable or thread-safe is fighting the pattern's intent — Builders are meant to be ephemeral construction helpers, not data carriers.
Trap answer to avoid: "I always use Builder for every class — it's a best practice." — Builder is overkill for 2-3 required fields. A simple constructor is more readable: new Money(100, Currency.USD) is clearer than Money.builder().amount(100).currency(USD).build(). Over-applying Builder adds boilerplate without benefit and obscures which fields are actually required (everything in the builder is optional until you add validation). Match the tool to the need: Builder for complex objects with many optional fields, constructors for simple value objects.
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
| Layered | Hexagonal |
| Dependencies | Top-down (Controller → Service → Repo) | All point inward (Adapters → Domain) |
| DB coupling | JPA leaks into service layer | Domain knows nothing about JPA |
| Testability | Hard without Spring context | Domain unit-testable in isolation |
Interview drill-down — expect these follow-ups:
L1 → "Isn't Hexagonal Architecture over-engineering for a simple CRUD service?"
Yes. Hexagonal Architecture pays off when: business logic is complex and frequently changing, multiple adapters exist (REST + Kafka + gRPC all entering the same domain), or infrastructure needs to be swappable (migrate from PostgreSQL to MongoDB). For a thin CRUD service with no complex business rules, layered architecture (Controller → Service → Repository) is simpler, more conventional, and faster to build. Apply Hexagonal where the investment pays off. Blind application of it to every service is a form of over-engineering — the right architecture depends on the complexity of the domain being modeled.
L2 → "In Hexagonal Architecture, the domain service (OrderServiceImpl) calls an outbound port (OrderRepository). But Spring's @Repository IS the implementation. How do you wire this without the domain knowing about JPA?"
The domain defines the port interface: package domain.port.out → interface OrderRepository with findById(), save(). The JPA adapter in adapter.out.persistence implements this interface: class JpaOrderRepositoryAdapter implements domain.port.out.OrderRepository — it wraps the Spring Data JpaRepository internally. The domain service gets the interface injected by Spring. The domain package has zero JPA imports. The adapter translates between JPA entities and domain objects (the anti-corruption layer). This is the key: the adapter package depends on both the domain and the infrastructure; the domain depends on nothing but Java.
L3 → "You have a Hexagonal Architecture but your domain model is being used directly by JPA as an @Entity. What's the problem and how do you fix it?"
@Entity on a domain class leaks JPA into the domain — the class now has JPA annotations, requires a no-arg constructor, and can't have final fields (Hibernate proxying). The domain is no longer "pure Java." Fix: separate domain objects from persistence entities. The adapter layer has JPA @Entity classes (OrderJpaEntity) that map to DB tables. The adapter translates: OrderJpaEntity ↔ Order (domain). The translation layer is more code but preserves the domain's purity. The trade-off is real — for simple cases the mapping overhead isn't worth it; for complex domains with rich invariants, it's essential to avoid JPA constraints leaking into your model design.
Trap answer to avoid: "Hexagonal Architecture means putting everything in a 'hexagonal' package structure." — Hexagonal is about dependency direction, not package names. The critical rule is: all dependencies point inward toward the domain. The domain knows nothing about adapters or infrastructure. You can achieve this with any package structure. Renaming packages to 'port' and 'adapter' without enforcing the dependency rule is cosmetic, not Hexagonal Architecture.
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.
Interview drill-down — expect these follow-ups:
L1 → "How do you handle eventual consistency in the CQRS read model?"
Several strategies: (1) Design the UI to tolerate it — show "processing" or "pending" state after a write, and either poll for confirmation or use WebSocket push when the read model catches up. (2) Read-your-own-writes: after a write, the UI reads directly from the write DB (bypassing the read projection) for that specific user's next request. (3) Versioning: the write returns a version number; the read query waits until the projection has processed at least that version. (4) Accept it: for many use cases (social feeds, recommendations), slight staleness is tolerable and the simpler approach. Don't add complexity for stale data that users never notice.
L2 → "Event Sourcing requires replaying all events to rebuild state. You have 10 million events per account. Replay takes 30 minutes. How do you fix this?"
Snapshots. Periodically persist the current materialized state (e.g., every 1000 events) as a snapshot. On replay: load the latest snapshot, then replay only the events that occurred after it. With snapshots every 1000 events, worst case you replay 999 events, not 10 million. Snapshot frequency is a trade-off between storage cost and replay speed. Store snapshots in the same event store with a special snapshot event type, or in a separate projection table. The snapshot becomes the starting point; the event log remains the authoritative source of truth.
L3 → "You deployed a bug that produced 50,000 corrupted events to the event store over 2 hours. How do you recover in an Event Sourcing system?"
This is Event Sourcing's hardest operational problem. Options: (1) If the events can be identified (e.g., specific event type or correlation ID range), mark them as invalid (with a compensating event or a tombstone event) — never delete from the immutable log. Replay projections skipping the invalid events. (2) If the bug period is contiguous, use a snapshot from before the bug, then replay only the clean events up to the bug start. (3) Full rebuild: if you have the source of truth in a transaction log (Debezium + outbox), replay from there. The key lesson: Event Sourcing makes this more recoverable than CRUD (you have the full history) but recovery still requires planning and tooling for "projection rebuilds".
Trap answer to avoid: "CQRS and Event Sourcing always go together." — They're independent patterns. CQRS just separates read and write models — you can implement it with two tables in one database, no events involved. Event Sourcing stores state as events — you can use it without CQRS. They're often combined because Event Sourcing naturally produces an event stream that feeds CQRS projections, but combining them doubles the complexity. Start with CQRS alone; add Event Sourcing only if you need the audit trail or temporal queries.
Q9 · Architecture Patterns
What is the Saga pattern? How do you handle a distributed transaction that spans multiple microservices?
The problem: distributed transactions don't work with 2PC
A traditional 2-Phase Commit (2PC) across microservices couples services to a distributed transaction coordinator. Services hold locks during the prepare phase, the coordinator is a single point of failure, and network partitions leave services in uncertainty. In practice, 2PC is almost never the right answer for microservices.
The Saga pattern replaces the distributed ACID transaction with a sequence of local transactions, each publishing an event or message that triggers the next step. If a step fails, the Saga executes compensating transactions to undo the completed steps.
Choreography Saga — event-driven, decentralized
Each service reacts to events from other services and publishes its own events. No central coordinator.
// Order service: creates order, publishes event
@Transactional
public Order placeOrder(OrderRequest req) {
Order order = orderRepo.save(new Order(req, PENDING));
eventBus.publish(new OrderCreatedEvent(order.getId(), req.items()));
return order;
}
// Inventory service: listens, reserves stock
@KafkaListener(topics = "order.created")
public void onOrderCreated(OrderCreatedEvent event) {
try {
inventory.reserve(event.items());
eventBus.publish(new StockReservedEvent(event.orderId()));
} catch (InsufficientStockException e) {
eventBus.publish(new StockReservationFailedEvent(event.orderId()));
}
}
// Order service: listens for failure, compensates
@KafkaListener(topics = "stock.reservation.failed")
public void onStockFailed(StockReservationFailedEvent event) {
orderRepo.updateStatus(event.orderId(), CANCELLED); // compensate
eventBus.publish(new OrderCancelledEvent(event.orderId()));
}
Choreography fits when: You have 2–3 services, loose coupling is the priority, and the flow is simple and linear. Easy to add new participants — they just subscribe to existing events.
Orchestration Saga — central coordinator
A dedicated Saga Orchestrator (a stateful component or BPMN workflow engine) directs each step, decides what to do on failure, and explicitly manages compensation.
@Component
public class PlaceOrderSaga { // could use Axon, Temporal, or custom state machine
public void execute(OrderRequest req) {
String orderId = orderService.createOrder(req); // step 1
try {
inventory.reserve(orderId, req.items()); // step 2
try {
payment.charge(orderId, req.total()); // step 3
orderService.confirm(orderId); // step 4
} catch (PaymentException e) {
inventory.release(orderId); // compensate step 2
orderService.cancel(orderId); // compensate step 1
throw e;
}
} catch (StockException e) {
orderService.cancel(orderId); // compensate step 1
throw e;
}
}
}
Orchestration fits when: You have 4+ services, complex branching logic, strong audit requirements, or need to visualize the workflow. Temporal.io and Axon Framework are common production choices.
Choreography vs Orchestration comparison
| Aspect | Choreography | Orchestration |
| Coordination | Decentralized (events) | Centralized (orchestrator) |
| Coupling | Services coupled via event contracts | Services decoupled from each other; coupled to orchestrator |
| Visibility | Hard — flow spread across services | Easy — single place shows full flow |
| Testing | Integration tests needed for full flow | Unit-test the orchestrator; mock downstream |
| Complexity | Grows quadratically with services | Grows linearly — one place to update |
| Failure handling | Compensating events, hard to track | Orchestrator drives compensation explicitly |
Critical Saga design rules
- Compensating transactions must be idempotent — a compensation may be triggered multiple times due to retries. Design compensations to be safe to run more than once.
- Not all operations can be compensated — sending an email is irreversible. Use "countermeasures" (send a follow-up email). Design these cases upfront.
- Sagas provide eventual consistency, not isolation — another transaction can see intermediate state (a reserved but not yet paid order). Use semantic locking (mark entities as "PENDING") to prevent concurrent Sagas from interfering.
- The Outbox Pattern prevents dual-write — the local DB write and the event publish must be atomic. Save the event to an outbox table in the same local transaction; a relay process publishes it to Kafka. This is the standard Saga reliability pattern.
// Outbox table — same transaction as the business write
@Transactional
public void createOrder(Order order) {
orderRepo.save(order);
outboxRepo.save(new OutboxEvent(
"order.created",
JSON.serialize(new OrderCreatedEvent(order.getId()))
));
// Relay process (Debezium or polling) publishes outbox rows to Kafka
}
Interview drill-down — expect these follow-ups:
L1 → "What is a 'lost update' or 'dirty read' in the context of Sagas?"
Sagas have no isolation between concurrent saga instances. A "dirty read" occurs when Service B reads an object that Service A's saga has updated but not yet compensated. A "lost update" occurs when two concurrent sagas both update the same entity and one overwrites the other. Prevention: Semantic locking — add a status field (PENDING, CONFIRMED, CANCELLED). Only process an order if it's in the expected state; return an error otherwise. This effectively creates optimistic locking at the application level across the saga boundary.
L2 → "Payment succeeded but the next step (shipping reservation) failed. The payment compensation failed too — network timeout. What do you do?"
This is the "failed compensation" problem. The compensation must be retried — that's why compensations must be idempotent. The Saga must persist its state (including which compensations are pending) durably, and a background process retries failed compensations with exponential backoff until they succeed. If a compensation is truly impossible to retry (e.g., external payment provider permanently unavailable), the Saga enters a "stuck" state requiring human intervention. This is surfaced via a dead-letter queue and ops alert. Temporal.io handles this automatically via durable execution — the workflow state is checkpointed, and retries happen transparently.
L3 → "You're designing a system where 6 microservices participate in a checkout flow. An order goes through: cart validation → inventory reservation → payment → loyalty points → order confirmation → notification. What pattern do you choose and why?"
Orchestration with Temporal.io (or Axon Saga). Six services + sequential dependency + complex compensation logic + need for audit trail = Choreography becomes a maintenance nightmare (you'd need to trace events across 6 services to understand any failure). With orchestration: (1) the entire flow is visible in one workflow definition; (2) timeouts and retries are declared per step; (3) compensation is explicit and testable; (4) you can pause the workflow waiting for async payment confirmation; (5) replay: if the loyalty service has a bug, you can replay affected workflows after the fix without re-running the payment step. Key Temporal features to mention: activities (individual steps), workflows (the saga coordinator), durable timers, and the ability to query live workflow state.
Trap answer to avoid: "Sagas are eventually consistent, so they're weaker than regular transactions." — This is true but incomplete. Sagas trade isolation for availability and scalability. The right framing is: design your domain so the intermediate states (PENDING order, RESERVED stock) are valid business states, not invalid database states. If you can't tolerate any intermediate visibility, Sagas are the wrong tool — but usually in microservices, you can model around it with semantic locking.