← Home
Java Core Easy
Fix: Mutable HashMap Key

The code below stores user sessions in a map keyed by a User object. After calling updateName(), the session can no longer be found. Identify the bug and fix it.

class User {
    private String name;
    private int id;

    @Override
    public int hashCode() { return name.hashCode(); }
    @Override
    public boolean equals(Object o) {
        return o instanceof User u && name.equals(u.name);
    }

    public void updateName(String n) { this.name = n; }
}

// Usage
Map<User, String> sessions = new HashMap<>();
User alice = new User("Alice", 1);
sessions.put(alice, "session-abc");
alice.updateName("Alicia");
sessions.get(alice); // returns null — why?
Answer & Explanation

Root cause: hashCode() is based on name. When updateName() mutates the field, the bucket the key hashes to changes — but the entry is still in the old bucket. get() looks in the new bucket and finds nothing.

Rule: Never use a mutable field as part of hashCode()/equals() when the object is used as a map key.

Fix 1 — base hashCode/equals on immutable id:

@Override
public int hashCode() { return Integer.hashCode(id); }
@Override
public boolean equals(Object o) {
    return o instanceof User u && id == u.id;
}

Fix 2 (preferred) — make User an immutable value object; return a new instance from any "mutation" method.

Java Core Medium
Fix: ConcurrentHashMap Race Condition

The rate-limiter below uses ConcurrentHashMap but still has a race condition under high concurrency. Find it and provide a thread-safe fix without using synchronized.

private final ConcurrentHashMap<String, AtomicInteger> counters = new ConcurrentHashMap<>();

public boolean isAllowed(String userId) {
    // BUG: two-step check-then-act is not atomic
    if (!counters.containsKey(userId)) {
        counters.put(userId, new AtomicInteger(0));
    }
    AtomicInteger count = counters.get(userId);
    return count.incrementAndGet() <= 100;
}
Answer & Explanation

Root cause: containsKey + put is a check-then-act — two threads can both see the key absent and both insert, potentially losing one counter.

Fix — use computeIfAbsent, which is atomic in ConcurrentHashMap:

public boolean isAllowed(String userId) {
    AtomicInteger count = counters.computeIfAbsent(
        userId, k -> new AtomicInteger(0)
    );
    return count.incrementAndGet() <= 100;
}

computeIfAbsent guarantees the lambda runs at most once per key even under concurrent calls. The AtomicInteger.incrementAndGet() then provides thread-safe counting within that value.

Java Core Hard
Fix: Broken Double-Checked Locking

The singleton below is broken on multi-core JVMs due to instruction reordering. Identify the problem and fix it with the correct volatile placement.

public class ConnectionPool {
    // BUG: missing volatile
    private static ConnectionPool instance;

    public static ConnectionPool getInstance() {
        if (instance == null) {             // check 1 — no lock
            synchronized (ConnectionPool.class) {
                if (instance == null) {         // check 2 — under lock
                    instance = new ConnectionPool();
                }
            }
        }
        return instance;
    }
}
Answer & Explanation

Root cause: new ConnectionPool() compiles to three micro-steps: (1) allocate memory, (2) invoke constructor, (3) assign reference. The JIT can reorder to 1→3→2. A second thread reading instance != null after step 3 but before step 2 gets a partially-constructed object.

Fix — declare instance as volatile:

private static volatile ConnectionPool instance; // volatile prevents reorder

volatile inserts a memory barrier that prevents the write to instance from being seen before the constructor completes. The rest of the DCL pattern is otherwise correct.

Even simpler alternative — use the Initialization-on-Demand Holder idiom (no volatile, no synchronized):

public class ConnectionPool {
    private static class Holder {
        static final ConnectionPool INSTANCE = new ConnectionPool();
    }
    public static ConnectionPool getInstance() { return Holder.INSTANCE; }
}
Java Core Hard
Implement: Bounded Blocking Queue

Implement a thread-safe BoundedQueue<T> with a fixed capacity using ReentrantLock and two Condition objects — one for "not full", one for "not empty". Both put and take must block when the queue is full/empty respectively.

public class BoundedQueue<T> {
    private final Object[] items;
    private int head, tail, count;

    public BoundedQueue(int capacity) { items = new Object[capacity]; }

    public void put(T item) throws InterruptedException {
        // TODO: block if full, add item, signal consumer
    }

    @SuppressWarnings("unchecked")
    public T take() throws InterruptedException {
        // TODO: block if empty, remove item, signal producer
        return null;
    }
}
Answer & Explanation

Two Conditions let you wake up only the relevant waiters — notFull.signal() wakes a blocked producer, notEmpty.signal() wakes a blocked consumer. Using a single condition would require signalAll() and cause thundering-herd wakeups.

public class BoundedQueue<T> {
    private final Object[] items;
    private int head, tail, count;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull  = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    public BoundedQueue(int capacity) { items = new Object[capacity]; }

    public void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) notFull.await();
            items[tail] = item;
            tail = (tail + 1) % items.length;
            count++;
            notEmpty.signal();
        } finally { lock.unlock(); }
    }

    @SuppressWarnings("unchecked")
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) notEmpty.await();
            T item = (T) items[head];
            items[head] = null;
            head = (head + 1) % items.length;
            count--;
            notFull.signal();
            return item;
        } finally { lock.unlock(); }
    }
}
Java Core Medium
Fix: CompletableFuture Blocking the Common Pool

The pipeline below fetches user data and enriches it. Under load, the service becomes unresponsive. Explain why, then fix it so blocking I/O never touches the ForkJoinPool common pool.

public CompletableFuture<EnrichedUser> getEnriched(String id) {
    return CompletableFuture
        .supplyAsync(() -> userRepo.findById(id))   // DB call — blocks!
        .thenApply(user -> enricher.enrich(user));   // also runs on common pool
}
Answer & Explanation

Root cause: supplyAsync with no executor uses ForkJoinPool.commonPool(), which is sized to CPU cores - 1. Blocking DB calls park those threads. Since the common pool is shared JVM-wide, all CompletableFuture operations stall.

Fix — pass a dedicated executor for I/O-bound work:

private final Executor ioPool = Executors.newFixedThreadPool(20);
// Or better: a virtual-thread executor (Java 21+)
// private final Executor ioPool = Executors.newVirtualThreadPerTaskExecutor();

public CompletableFuture<EnrichedUser> getEnriched(String id) {
    return CompletableFuture
        .supplyAsync(() -> userRepo.findById(id), ioPool)   // I/O on dedicated pool
        .thenApply(user -> enricher.enrich(user));           // CPU: common pool OK
}
  • Use thenApplyAsync(fn, cpuPool) if enrichment is also heavy CPU work.
  • Keep the common pool for CPU-only, non-blocking lambdas.
Spring Medium
Fix: @Transactional Self-Invocation

The process() method calls a private @Transactional helper in the same class. Testers notice the helper's transaction is never committed separately — the outer transaction always rolls it back on error. Explain why and fix it.

@Service
public class OrderService {

    public void process(Order order) {
        validate(order);
        saveAuditLog(order);  // should always commit, even if process() fails
        chargePayment(order);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    private void saveAuditLog(Order order) {
        auditRepo.save(new AuditEntry(order));
    }
}
Answer & Explanation

Root cause: Spring's @Transactional works via a proxy. When process() calls saveAuditLog() directly (this.saveAuditLog()), it bypasses the proxy — no new transaction is started. The annotation is silently ignored.

Fix 1 — Move saveAuditLog to a separate bean:

@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveAuditLog(Order order) {
        auditRepo.save(new AuditEntry(order));
    }
}

@Service
public class OrderService {
    private final AuditService auditService; // injected → goes through proxy

    public void process(Order order) {
        validate(order);
        auditService.saveAuditLog(order); // proxy invoked — REQUIRES_NEW works
        chargePayment(order);
    }
}

Fix 2 — inject self via @Autowired OrderService self and call self.saveAuditLog(). Works but is a code smell; prefer Fix 1.

Spring Medium
Fix: @Transactional Test Hiding LazyInitializationException

All tests pass, but production throws LazyInitializationException when accessing order.getItems(). The test class is annotated with @Transactional. What is happening and how do you surface the real bug?

@SpringBootTest
@Transactional  // keeps session open for entire test — hides lazy-load bugs
class OrderServiceTest {

    @Autowired OrderService orderService;

    @Test
    void getOrderWithItems() {
        Order o = orderService.getOrder(1L);
        assertThat(o.getItems()).hasSize(3); // works in test, blows up in prod
    }
}
Answer & Explanation

Root cause: @Transactional on the test class wraps the entire test in one transaction and rolls it back afterward. The Hibernate session stays open, so lazy collections can be loaded at any point. In production, the transaction closes after getOrder() returns — accessing items outside the session throws LazyInitializationException.

Fix — remove @Transactional from the test, fix the real bug in the service:

// Option A: JOIN FETCH in JPQL
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Order findWithItems(@Param("id") Long id);

// Option B: @EntityGraph
@EntityGraph(attributePaths = "items")
Optional<Order> findById(Long id);

// Option C: DTO projection — fetch only what the caller needs
record OrderDto(Long id, List<String> itemNames) {}

Then clean up the test — either use @Sql/@BeforeEach for setup+teardown, or use Testcontainers so the DB resets between runs, without masking lazy-load issues.

Spring Hard
Implement: Custom Spring Boot Auto-Configuration

Create an auto-configuration class for a fictional MetricsClient that: (1) only activates when metrics.enabled=true, (2) backs off if the application already defines its own MetricsClient bean.

// Starter library provides this client:
public class MetricsClient {
    private final String endpoint;
    public MetricsClient(String endpoint) { this.endpoint = endpoint; }
}

// TODO: write MetricsAutoConfiguration
// TODO: register it in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
Answer & Explanation
@AutoConfiguration
@ConditionalOnProperty(name = "metrics.enabled", havingValue = "true")
@EnableConfigurationProperties(MetricsProperties.class)
public class MetricsAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean   // back off if app defines its own
    public MetricsClient metricsClient(MetricsProperties props) {
        return new MetricsClient(props.getEndpoint());
    }
}

@ConfigurationProperties(prefix = "metrics")
public class MetricsProperties {
    private String endpoint = "http://localhost:9090";
    // getters + setters
}

Registration — create META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (Spring Boot 3.x):

com.example.metrics.MetricsAutoConfiguration
  • @ConditionalOnProperty — only fires when metrics.enabled=true in application properties.
  • @ConditionalOnMissingBean — the library backs off if the app defines its own MetricsClient, following the "convention over configuration" principle.
Spring Hard
Fix: REQUIRES_NEW Deadlock

Under load, the application deadlocks. The outer @Transactional method holds a lock on accounts row, then calls a REQUIRES_NEW method that also tries to lock the same row. Identify the deadlock scenario and describe the fix.

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    Account from = accountRepo.findById(fromId).orElseThrow(); // locks row
    from.debit(amount);
    auditService.logTransfer(fromId, toId, amount); // REQUIRES_NEW tx starts
    accountRepo.save(from);
}

// In AuditService:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logTransfer(Long fromId, ...) {
    Account from = accountRepo.findById(fromId).orElseThrow(); // tries to lock same row!
    auditRepo.save(new AuditEntry(...));
}
Answer & Explanation

Deadlock scenario:

  • Tx1 (transfer) holds a row lock on account fromId.
  • REQUIRES_NEW suspends Tx1 and opens Tx2 (logTransfer) on the same connection thread.
  • Tx2 issues SELECT … FOR UPDATE on the same row — it waits for Tx1 to release.
  • Tx1 waits for Tx2 to finish → deadlock.

Fix — don't re-read the account in the audit method; pass the data you need:

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    Account from = accountRepo.findById(fromId).orElseThrow();
    from.debit(amount);
    accountRepo.save(from);
    // Pass values — audit service never needs to lock the account row
    auditService.logTransfer(fromId, toId, amount);
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logTransfer(Long fromId, Long toId, BigDecimal amount) {
    // Only inserts into audit table — no lock on accounts row
    auditRepo.save(new AuditEntry(fromId, toId, amount));
}
Kafka Easy
Design: Choose the Right Partition Key

For each scenario below, choose the best partition key and explain why. Bad keys cause hotspots or break ordering guarantees.

// Scenario A: Order events (placed, paid, shipped, delivered)
//   Requirement: all events for the same order must be consumed in order.
//   Options: orderId | userId | random | null

// Scenario B: User activity feed (clicks, views, purchases)
//   Requirement: even load across partitions; ordering per user is a nice-to-have.
//   Options: userId | sessionId | random | event type

// Scenario C: IoT sensor readings (1000 devices, one reading/sec)
//   Requirement: readings from the same device must stay in order.
//   Options: deviceId | timestamp | null | region
Answer & Explanation
  • Scenario A → orderId. Kafka guarantees ordering within a partition. All events for one order land in the same partition, so the consumer sees them in sequence. userId would mix orders from the same user; null/random breaks ordering.
  • Scenario B → userId. Provides reasonable distribution (assuming users are many and activity is spread) while still giving per-user ordering if needed. event type creates only a few partitions and hotspots on popular events like "click".
  • Scenario C → deviceId. Keeps each device's readings ordered. Watch out: if one device produces far more than others (hot key), use a composite key like deviceId + bucketId to spread that device across multiple partitions.
Kafka Hard
Implement: Transactional Outbox Pattern

The service below saves an order and publishes an event, but they're not atomic — a crash between the two leaves the DB committed but no event sent. Refactor it using the Outbox Pattern.

@Transactional
public void placeOrder(Order order) {
    orderRepo.save(order);
    // If the app crashes here, the event is never sent
    kafkaTemplate.send("orders", order.getId().toString(), order);
}
Answer & Explanation

Key idea: Write the event to an outbox table within the same DB transaction. A separate relay process (Debezium CDC or a scheduled poller) reads unpublished rows and sends them to Kafka, then marks them sent.

// 1. Domain layer — save order + outbox entry atomically
@Transactional
public void placeOrder(Order order) {
    orderRepo.save(order);
    OutboxEvent event = OutboxEvent.builder()
        .aggregateType("ORDER")
        .aggregateId(order.getId().toString())
        .eventType("OrderPlaced")
        .payload(toJson(order))
        .build();
    outboxRepo.save(event);  // same TX — atomic with order insert
}

// 2. Relay — runs outside the domain TX, polls every second
@Scheduled(fixedDelay = 1000)
public void relay() {
    List<OutboxEvent> pending = outboxRepo.findUnpublished();
    for (OutboxEvent e : pending) {
        kafkaTemplate.send(e.getAggregateType().toLowerCase(), e.getAggregateId(), e.getPayload());
        e.markPublished();
        outboxRepo.save(e);
    }
}
  • If the app crashes after writing the outbox but before the relay sends, the relay will pick it up on the next run — at-least-once delivery.
  • Consumers must be idempotent (deduplicate by event ID) to handle duplicates.
  • Debezium (CDC) is preferred over polling for low-latency use cases — it tails the WAL instead of polling.
Kafka Medium
Fix: At-Most-Once Consumer

The consumer below loses messages on crash — it commits the offset before processing. Identify the delivery semantic problem and fix it to achieve at-least-once delivery.

@KafkaListener(topics = "orders")
public void onMessage(ConsumerRecord<String, String> record,
                       Acknowledgment ack) {
    ack.acknowledge(); // offset committed immediately — before processing!
    processOrder(record.value());
}
Answer & Explanation

Root cause: Committing the offset before processing means that if processOrder() throws or the process crashes, the message is skipped on restart — at-most-once.

Fix — commit only after successful processing:

@KafkaListener(topics = "orders")
public void onMessage(ConsumerRecord<String, String> record,
                       Acknowledgment ack) {
    processOrder(record.value());  // process first
    ack.acknowledge();             // only commit if no exception thrown
}

Also ensure the listener container is configured with AckMode.MANUAL:

@Bean
public ConcurrentKafkaListenerContainerFactory<?,?> factory(ConsumerFactory<?,?> cf) {
    var f = new ConcurrentKafkaListenerContainerFactory<>();
    f.setConsumerFactory(cf);
    f.getContainerProperties().setAckMode(AckMode.MANUAL);
    return f;
}
Kafka Hard
Implement: Idempotent Consumer

Your at-least-once Kafka consumer may receive duplicates (e.g., after a rebalance). Implement an idempotency check so that reprocessing the same OrderPlaced event is a no-op.

@KafkaListener(topics = "orders")
public void onOrderPlaced(OrderPlacedEvent event, Acknowledgment ack) {
    // TODO: skip if already processed
    orderService.createOrder(event);
    ack.acknowledge();
}
Answer & Explanation

Store a processed_events table (or Redis set). Before processing, check if the event ID exists. Use a unique constraint as the safety net — an INSERT that violates it means the event was already handled.

// processed_events table: (event_id VARCHAR PK, processed_at TIMESTAMP)

@KafkaListener(topics = "orders")
public void onOrderPlaced(OrderPlacedEvent event, Acknowledgment ack) {
    if (processedEventRepo.existsById(event.getEventId())) {
        log.info("Duplicate event {}, skipping", event.getEventId());
        ack.acknowledge();
        return;
    }
    try {
        orderService.createOrder(event);
        processedEventRepo.save(new ProcessedEvent(event.getEventId()));
        ack.acknowledge();
    } catch (DataIntegrityViolationException e) {
        // Lost the race — another node processed it first; safe to ack
        ack.acknowledge();
    }
}
  • The existsById check is an optimistic fast path.
  • The DataIntegrityViolationException catch handles the concurrent-insert race: if two instances got the same event, only one INSERT succeeds — the other catches the exception and acks safely.
  • Use TTL-based cleanup (e.g., delete rows older than 30 days) so the table doesn't grow unbounded.
Design Patterns Easy
Refactor: Replace if-else Chain with Strategy Pattern

The payment processor below has a growing if-else chain. Adding a new payment method requires editing this class. Refactor using the Strategy pattern so new methods can be added without modifying existing code (Open/Closed Principle).

public class PaymentService {
    public void pay(String method, BigDecimal amount) {
        if ("CREDIT_CARD".equals(method)) {
            // charge credit card
        } else if ("PAYPAL".equals(method)) {
            // call PayPal API
        } else if ("CRYPTO".equals(method)) {
            // broadcast transaction
        } else {
            throw new IllegalArgumentException("Unknown method");
        }
    }
}
Answer & Explanation
// 1. Strategy interface
public interface PaymentStrategy {
    void pay(BigDecimal amount);
}

// 2. Concrete strategies
@Component("CREDIT_CARD")
public class CreditCardStrategy implements PaymentStrategy {
    public void pay(BigDecimal amount) { /* charge card */ }
}

@Component("PAYPAL")
public class PayPalStrategy implements PaymentStrategy {
    public void pay(BigDecimal amount) { /* call PayPal API */ }
}

// 3. Context — Spring injects all strategies by name into the map
@Service
public class PaymentService {
    private final Map<String, PaymentStrategy> strategies;

    public PaymentService(Map<String, PaymentStrategy> strategies) {
        this.strategies = strategies;
    }

    public void pay(String method, BigDecimal amount) {
        PaymentStrategy strategy = strategies.get(method);
        if (strategy == null) throw new IllegalArgumentException("Unknown method: " + method);
        strategy.pay(amount);
    }
}

Adding CRYPTO now means writing one new class annotated @Component("CRYPTO") — zero changes to PaymentService.

Design Patterns Medium
Apply: Decorator Pattern for Logging + Caching

You have a UserRepository interface and a JpaUserRepository implementation. Add logging and caching as decorators without modifying JpaUserRepository or the service that uses it.

public interface UserRepository {
    Optional<User> findById(Long id);
    User save(User user);
}

public class JpaUserRepository implements UserRepository { /* JPA impl */ }

// TODO: LoggingUserRepository (decorator)
// TODO: CachingUserRepository (decorator)
Answer & Explanation
public class LoggingUserRepository implements UserRepository {
    private final UserRepository delegate;
    private static final Logger log = LoggerFactory.getLogger(LoggingUserRepository.class);

    public Optional<User> findById(Long id) {
        log.debug("findById({})", id);
        Optional<User> result = delegate.findById(id);
        log.debug("findById({}) → {}", id, result.isPresent() ? "found" : "empty");
        return result;
    }
    public User save(User u) { log.debug("save({})", u.getId()); return delegate.save(u); }
}

public class CachingUserRepository implements UserRepository {
    private final UserRepository delegate;
    private final Map<Long, User> cache = new ConcurrentHashMap<>();

    public Optional<User> findById(Long id) {
        return Optional.ofNullable(cache.computeIfAbsent(id, k -> delegate.findById(k).orElse(null)));
    }
    public User save(User u) {
        User saved = delegate.save(u);
        cache.put(saved.getId(), saved);   // keep cache coherent
        return saved;
    }
}

// Wire them up: logging wraps caching wraps JPA
UserRepository repo = new LoggingUserRepository(new CachingUserRepository(new JpaUserRepository(...)));
Design Patterns Medium
Map: Classify Components into Hexagonal Architecture Layers

Assign each component below to the correct Hexagonal Architecture layer: Domain, Application (Use Case), Input Adapter, or Output Adapter.

// Components to classify:
// A. OrderRestController       — REST endpoint that parses HTTP request
// B. Order (entity)            — contains business rules like order.cancel()
// C. PlaceOrderUseCase         — orchestrates: validate, save, publish event
// D. JpaOrderRepository        — reads/writes orders from PostgreSQL
// E. KafkaOrderEventPublisher  — publishes OrderPlaced to a Kafka topic
// F. OrderRepository (interface) — defines port: save(Order), findById(Long)
// G. OrderKafkaConsumer        — listens on "orders" topic, calls use case
Answer & Explanation
  • A. OrderRestController → Input Adapter — translates HTTP into a command that calls the use case.
  • B. Order (entity) → Domain — pure business logic, no framework dependencies.
  • C. PlaceOrderUseCase → Application (Use Case) — orchestrates the flow, uses ports.
  • D. JpaOrderRepository → Output Adapter — implements the OrderRepository port using JPA.
  • E. KafkaOrderEventPublisher → Output Adapter — implements an EventPublisher port using Kafka.
  • F. OrderRepository (interface) → Domain / Port — defined by the domain, implemented by adapters. The domain dictates what it needs; adapters provide it.
  • G. OrderKafkaConsumer → Input Adapter — Kafka drives the use case just like HTTP does.

Dependency rule: Adapters depend on the domain/ports. The domain never depends on adapters. This means you can swap JPA for MongoDB by writing a new output adapter — zero domain changes.

Database Medium
Fix: N+1 Query Problem

The code below fires 1 query for all orders, then N queries for each order's items. Fix it using three different approaches: JOIN FETCH, @EntityGraph, and a DTO projection.

// In OrderService — causes N+1
public List<Order> getOrdersWithItems() {
    List<Order> orders = orderRepo.findAll(); // 1 query
    orders.forEach(o -> o.getItems().size()); // N queries — lazy load
    return orders;
}
Answer & Explanation

Option A — JOIN FETCH in JPQL (1 query, returns duplicates → use DISTINCT):

@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items")
List<Order> findAllWithItems();

Option B — @EntityGraph (same as JOIN FETCH, declarative):

@EntityGraph(attributePaths = "items")
List<Order> findAll();

Option C — DTO projection (best for read-only APIs; avoids loading full entities):

record OrderSummary(Long orderId, String status, int itemCount) {}

@Query("SELECT new com.example.OrderSummary(o.id, o.status, SIZE(o.items)) FROM Order o")
List<OrderSummary> findOrderSummaries();

When to use what: JOIN FETCH/EntityGraph when you need the full entity graph. DTO projection when you only need a subset of fields — lighter, no managed-entity overhead.

Database Medium
Design: Optimal Composite Index

The query below is slow on 10M rows. Add the optimal index and explain your column ordering decision. Also explain what EXPLAIN ANALYZE output would confirm the index is used.

-- Query (runs every second from the API)
SELECT id, total, status
FROM orders
WHERE customer_id = $1
  AND status = 'PENDING'
ORDER BY created_at DESC
LIMIT 20;

-- Current index: none beyond primary key on `id`
-- TODO: CREATE INDEX ...
Answer & Explanation

Optimal index:

CREATE INDEX idx_orders_customer_status_created
    ON orders (customer_id, status, created_at DESC)
    INCLUDE (total);

Column ordering rationale (Equality → Equality → Range/Sort):

  • customer_id first — high cardinality equality filter; shrinks the result set dramatically.
  • status second — another equality filter narrows further. Low cardinality alone would be bad as the leading column, but here it follows the high-cardinality column.
  • created_at DESC third — ORDER BY can be satisfied by the index scan direction, eliminating a filesort.
  • INCLUDE (total) — covers the projection so Postgres can answer the query from the index alone (index-only scan), skipping heap lookups.

What to look for in EXPLAIN ANALYZE: Index Scan using idx_orders_customer_status_created (not Seq Scan), Index Cond showing both predicates applied, and ideally Heap Fetches: 0 confirming index-only scan.

Database Medium
Implement: Optimistic Locking with Retry

Add optimistic locking to the Inventory entity to prevent lost updates, and add a @Retryable retry policy on the service method so transient OptimisticLockingFailureExceptions are retried up to 3 times.

@Entity
public class Inventory {
    @Id private Long id;
    private int stock;
    // TODO: add version field
}

@Service
public class InventoryService {
    @Transactional
    public void reserve(Long productId, int qty) {
        Inventory inv = inventoryRepo.findById(productId).orElseThrow();
        if (inv.getStock() < qty) throw new InsufficientStockException();
        inv.setStock(inv.getStock() - qty);
        // TODO: add retry
    }
}
Answer & Explanation
@Entity
public class Inventory {
    @Id private Long id;
    private int stock;
    @Version
    private Long version;   // JPA auto-increments; UPDATE fails if mismatch
}

@Service
public class InventoryService {

    @Retryable(
        retryFor = OptimisticLockingFailureException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 50, multiplier = 2)
    )
    @Transactional
    public void reserve(Long productId, int qty) {
        Inventory inv = inventoryRepo.findById(productId).orElseThrow();
        if (inv.getStock() < qty) throw new InsufficientStockException();
        inv.setStock(inv.getStock() - qty);
    }

    @Recover
    public void reserveFallback(OptimisticLockingFailureException ex, Long productId, int qty) {
        throw new ServiceUnavailableException("Could not reserve after retries");
    }
}

How it works: JPA appends WHERE version = ? to every UPDATE. If another transaction incremented the version first, 0 rows are updated and JPA throws OptimisticLockingFailureException. Spring-Retry catches it, reloads the entity, and retries.

Remember: add @EnableRetry to a configuration class.

Database Hard
Fix: Database Deadlock via Lock Ordering

Two concurrent transfers deadlock: Thread A locks account 1 then tries account 2; Thread B locks account 2 then tries account 1. Describe the deadlock and fix it with a consistent lock ordering strategy.

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    // Locks rows in arbitrary order — can deadlock
    Account from = accountRepo.lockById(fromId); // SELECT … FOR UPDATE
    Account to   = accountRepo.lockById(toId);
    from.debit(amount);
    to.credit(amount);
}

// Concurrent calls:
// Thread A: transfer(1, 2, 100)  → locks 1, waiting for 2
// Thread B: transfer(2, 1, 50)   → locks 2, waiting for 1  ← deadlock
Answer & Explanation

Fix — always acquire locks in a consistent order (e.g., ascending by ID):

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    // Always lock the lower ID first — total ordering eliminates deadlock
    Long firstId  = Math.min(fromId, toId);
    Long secondId = Math.max(fromId, toId);

    Account first  = accountRepo.lockById(firstId);
    Account second = accountRepo.lockById(secondId);

    Account from = fromId.equals(firstId) ? first : second;
    Account to   = toId.equals(firstId)   ? first : second;
    from.debit(amount);
    to.credit(amount);
}

Now both threads will always try to lock account 1 before account 2. Thread B blocks on account 1 while Thread A holds it — no circular wait, no deadlock.

Alternative: Use a single-row "account lock" table and a SELECT … FOR UPDATE SKIP LOCKED queue pattern to serialize transfers at the application layer.

Testing Easy
Write: @WebMvcTest Slice Test for Input Validation

Write a @WebMvcTest test that verifies the controller rejects a POST /orders request with a missing customerId (returns 400) and accepts a valid request (returns 201). Mock the service layer.

@RestController
public class OrderController {
    private final OrderService orderService;

    @PostMapping("/orders")
    @ResponseStatus(HttpStatus.CREATED)
    public OrderDto create(@Valid @RequestBody CreateOrderRequest req) {
        return orderService.createOrder(req);
    }
}

record CreateOrderRequest(
    @NotNull Long customerId,
    @NotEmpty List<String> items
) {}

// TODO: write the @WebMvcTest
Answer & Explanation
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;
    @MockBean  OrderService orderService;

    @Test
    void rejectsMissingCustomerId() throws Exception {
        mvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""{"items": ["book"]}"""))  // customerId missing
           .andExpect(status().isBadRequest());
    }

    @Test
    void acceptsValidRequest() throws Exception {
        OrderDto dto = new OrderDto(99L, "CREATED");
        given(orderService.createOrder(any())).willReturn(dto);

        mvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""{"customerId": 1, "items": ["book"]}"""))
           .andExpect(status().isCreated())
           .andExpect(jsonPath("$.id").value(99));
    }
}

@WebMvcTest loads only the web layer (controllers, filters, @ControllerAdvice). @MockBean replaces the service. This keeps the test fast — no Spring context, no DB. Validation logic is exercised without starting a full server.

Testing Medium
Refactor: Replace H2 with Testcontainers PostgreSQL

The test below uses an in-memory H2 database. Migrate it to Testcontainers so it runs against a real PostgreSQL instance. Make the container reusable across tests in the same JVM process.

// application-test.properties (current — H2):
// spring.datasource.url=jdbc:h2:mem:testdb
// spring.datasource.driver-class-name=org.h2.Driver

@SpringBootTest
@ActiveProfiles("test")
class OrderRepositoryTest {
    @Autowired OrderRepository repo;

    @Test
    void savesOrder() {
        Order saved = repo.save(new Order(1L, "PENDING"));
        assertThat(saved.getId()).isNotNull();
    }
}
Answer & Explanation
// 1. Add dependency: org.testcontainers:postgresql

// 2. Shared container base class (starts once, reused across test classes)
public abstract class PostgresTestBase {
    @Container
    static final PostgreSQLContainer<?> PG =
        new PostgreSQLContainer<>("postgres:16").withReuse(true);

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",       PG::getJdbcUrl);
        registry.add("spring.datasource.username",   PG::getUsername);
        registry.add("spring.datasource.password",   PG::getPassword);
        registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
    }
}

// 3. Test extends the base
@SpringBootTest
@Testcontainers
class OrderRepositoryTest extends PostgresTestBase {
    @Autowired OrderRepository repo;

    @Test
    void savesOrder() {
        Order saved = repo.save(new Order(1L, "PENDING"));
        assertThat(saved.getId()).isNotNull();
    }
}

withReuse(true) keeps the container running between test class loads — the Docker container starts once per JVM. @DynamicPropertySource overrides datasource properties before the Spring context starts, so Flyway/Liquibase runs against the real Postgres schema.

Testing Hard
Write: Test Async Kafka Consumer with Awaitility

Write an integration test that: (1) publishes an OrderPlaced event to Kafka, (2) waits for the consumer to process it asynchronously, (3) asserts the order was saved to the database. Use Awaitility to avoid flaky Thread.sleep() calls.

@KafkaListener(topics = "orders")
public class OrderConsumer {
    private final OrderRepository repo;

    public void onEvent(OrderPlacedEvent event) {
        repo.save(new Order(event.getOrderId(), "RECEIVED"));
    }
}

// TODO: write the integration test
Answer & Explanation
@SpringBootTest
@Testcontainers
class OrderConsumerIntegrationTest {

    @Container
    static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
    }

    @Autowired KafkaTemplate<String, OrderPlacedEvent> template;
    @Autowired OrderRepository repo;

    @Test
    void savesOrderOnEvent() {
        String orderId = UUID.randomUUID().toString();
        template.send("orders", orderId, new OrderPlacedEvent(orderId));

        Awaitility.await()
            .atMost(10, TimeUnit.SECONDS)
            .pollInterval(200, TimeUnit.MILLISECONDS)
            .untilAsserted(() ->
                assertThat(repo.findById(orderId)).isPresent()
                                                  .get()
                                                  .extracting(Order::getStatus)
                                                  .isEqualTo("RECEIVED")
            );
    }
}

Awaitility.await().untilAsserted() polls the assertion every 200ms up to 10 seconds — no hard sleeps, no flaky tests. The KafkaContainer from Testcontainers spins up a real Kafka broker so you're testing the actual consumer deserialization and offset commit.