Java + Angular Full Stack — Explained Simply

For: a 10+ year developer interviewing for a Full Stack role (Java backend + Angular frontend) — matching a JD about technical design, Java & Angular enhancements, JUnit testing, code reviews, debugging & performance, and Scrum participation.

Every answer follows the same four steps

1. In simple words — the idea in plain English, with an everyday comparison 2. How it works — the mechanism, step by step 3. Code — short, commented, runnable 4. Say this — the exact sentence to use in the room

How to use it

Read the blue box first. If that makes sense, you already understand the concept — the rest is detail you can layer on. The green box is what actually earns marks: a short, confident sentence that shows you have used the thing, not just read about it. Amber boxes are the traps interviewers use to separate 4-year candidates from 10-year candidates.

The last four sections are written/coding rounds: data structures in Java, Stream API programs, Spring Boot tasks and Angular tasks — each with the thinking, the code, the output and the complexity.

Type in the search box to filter instantly. Click Expand all then print (Ctrl/Cmd+P) to get a PDF.

1. Core Java & OOP

The warm-up. Short, precise answers score better than long ones here.

Explain the four OOP pillars with a real example.
In simple words

Think of a car. Encapsulation = the engine is under the bonnet; you use the pedal, you can't touch the pistons. Abstraction = you know pressing the pedal makes it go, you don't need to know how combustion works. Inheritance = an electric car is still a car, it reuses most of what a car is. Polymorphism = you can drive a petrol car or an electric car with the same pedal, and each responds in its own way.

How it works in code

  • Encapsulation — make fields private, expose behaviour through methods. This protects your rules. A BankAccount keeps balance private so nobody outside can make it negative.
  • Abstraction — an interface names what can be done, hiding how. PaymentGateway.charge(order) — the caller doesn't know it's Stripe or an internal ledger.
  • Inheritance — a subclass reuses the parent. Useful, but overused; prefer composition.
  • Polymorphism — one variable type, many actual behaviours at runtime.
// Polymorphism doing real work: adding a new rule means adding a bean,
// NOT editing an if/else chain somewhere.
public interface Validator { void validate(Request r); }

@Service
public class RequestService {
    private final List<Validator> validators;   // Spring injects ALL implementations

    public void handle(Request r) {
        for (Validator v : validators) v.validate(r);   // each one behaves differently
    }
}
Say this

"I lean on interfaces plus composition rather than deep inheritance. In one legacy module we had a 5-level class hierarchy where a change in the base class broke three subclasses. We flattened it into strategy beans injected as a list — more classes, but each change became local and safe."

Abstract class vs interface — when do you choose each?
In simple words

An interface is a job description: "anyone who can do these things qualifies". An abstract class is a half-built machine: it already contains some working parts and some state, and you finish the missing pieces.

The differences that actually matter

Abstract classInterface
Can hold data (fields)?YesNo — only constants
Constructor?YesNo
How many can a class have?One onlyMany
Method bodies?YesYes, via default / static (Java 8+)

How I decide

  • Am I describing a capability that unrelated classes may have? → interface (Auditable, PaymentGateway). Interfaces are also what makes code easy to mock in tests.
  • Do subclasses genuinely share state and half the logic? → abstract class (e.g. AbstractJobRunner holding a retry counter and a template method).
Likely follow-up

"Why were default methods added in Java 8?" — So an interface could gain new methods without breaking every existing implementation. That's exactly how Collection.stream() was added: millions of classes implement Collection, and none of them had to change.

Say this

"Interface by default, abstract class only when there's real shared state. In practice 90% of my abstractions are interfaces because that's what Spring proxies and what keeps unit tests free of a database."

Overloading vs overriding — and how does Java decide which method to call?
In simple words

Overloading = same method name, different inputs — like "print(a photo)" and "print(a document)". The compiler decides at build time by looking at the parameter types.

Overriding = a child class replaces the parent's version of a method. The JVM decides at run time by looking at what the object really is.

The classic trick question

class A { void f(Object o){ System.out.println("A/Object"); } }
class B extends A { void f(String s){ System.out.println("B/String"); } }

A a = new B();
a.f("hi");     // prints "A/Object"  -- surprising!

Why? f(String) in B does not override f(Object) — it overloads it. Overload resolution happens at compile time, and at compile time the variable is declared as type A, which only has f(Object). So f(Object) is chosen and there's nothing to override at runtime.

Overriding rules

  • Same signature; return type must be the same or a subtype (covariant).
  • Access can widen (protectedpublic), never narrow.
  • Cannot throw broader checked exceptions.
  • private, static and final methods cannot be overridden (a static method is hidden, not overridden).
Say this

"Overloading is compile-time and uses the declared type; overriding is runtime and uses the actual object. That distinction is why an overloaded method can surprise you when you assign a subclass to a parent-typed variable."

Why must you override hashCode() whenever you override equals()?
In simple words

Imagine a library with numbered shelves. hashCode() tells you which shelf to look on; equals() checks which exact book on that shelf. If two identical books get sent to different shelves, you'll search the wrong shelf and conclude the book isn't in the library — even though it is.

That is exactly what happens to HashMap and HashSet when hashCode is inconsistent with equals.

The contract

  1. If a.equals(b) is true, then a.hashCode() == b.hashCode() must be true.
  2. The reverse is not required — two different objects may share a hash code (a collision), and that's fine.

Break rule 1 and map.get(key) returns null for a key that is "present", and a Set quietly holds duplicates.

public final class Sku {
    private final String code;
    private final int rev;

    @Override public boolean equals(Object o){
        if (this == o) return true;                    // same object - fast path
        if (!(o instanceof Sku other)) return false;   // pattern matching (Java 16+)
        return rev == other.rev && code.equals(other.code);
    }

    @Override public int hashCode(){
        return Objects.hash(code, rev);                // SAME fields as equals()
    }
}
// Or just: public record Sku(String code, int rev) {}  -- both generated for you.
Real production bug

Using a mutable field inside hashCode, then changing that field after the object is already in a HashSet. The object moves to a "shelf" nobody looks at: set.contains(obj) returns false, set.remove(obj) does nothing, and the set grows forever — a slow memory leak. Fix: use immutable keys, or a record.

Say this

"Same fields in both methods, and those fields should be immutable. I've debugged a leak caused by mutating a key after it was added to a set."

Why is String immutable, and what is the String pool?
In simple words

A String in Java can never be changed after it's created. Every "modification" actually makes a new String. Think of it like a printed page: you can't edit it, you print a new page.

The String pool is a shared drawer of printed pages. If you ask for the text "java" and that page already exists in the drawer, Java hands you the existing one instead of printing a duplicate.

Why the designers made it immutable

  • Security — a file path or JDBC URL cannot be changed after your security check has passed.
  • Caching the hash — because the content never changes, the hash code is computed once and reused. That's why String is the ideal HashMap key.
  • Thread safety for free — nothing can change, so nothing can race.
  • Sharing (the pool) — sharing one instance across the whole JVM is only safe if it can't be mutated.
String a = "java";              // goes into the pool
String b = "java";              // SAME object from the pool
String c = new String("java");  // forced new object on the heap

a == b;            // true   (same reference)
a == c;            // false  (different objects)
a.equals(c);       // true   (same content)  <-- always compare with equals
a == c.intern();   // true   (intern() returns the pooled instance)

StringBuilder vs StringBuffer vs +

Using + inside a loop creates a new String on every iteration — O(n²) work and lots of garbage. Use StringBuilder (not synchronised, fast). StringBuffer is the old synchronised version and is almost never the right choice today.

// BAD in a loop
String s = ""; for (String x : list) s += x;          // creates n Strings

// GOOD
StringBuilder sb = new StringBuilder();
for (String x : list) sb.append(x);
String s = sb.toString();
Say this

"Immutability buys thread safety, hash caching and safe pooling. The practical cost is that string concatenation in a loop is O(n²), which is why StringBuilder exists."

What is a record and when do you use it?
In simple words

A record is a class whose only job is to carry data. You declare the fields once, and Java writes the constructor, the getters, equals, hashCode and toString for you. It is Java's built-in replacement for the 60-line "POJO with Lombok" you used to write.

public record OrderDto(String id, BigDecimal total, Instant placedAt) {

    // "compact constructor" - the place to put validation
    public OrderDto {
        if (total.signum() < 0) throw new IllegalArgumentException("total cannot be negative");
    }
}

OrderDto o = new OrderDto("O-1", new BigDecimal("99.00"), Instant.now());
o.total();                 // note: total(), not getTotal()
o.equals(otherDto);        // compares all fields - generated for you

Rules to remember

  • All fields are final — the object is immutable.
  • The class is implicitly final — you cannot extend a record.
  • Accessors are named after the field (total()), not JavaBean style.

Use / don't use

Use for: DTOs, API request and response payloads, value objects like Money, map keys, returning multiple values from a method.

Don't use for: a JPA @Entity — Hibernate needs a no-arg constructor and mutable fields for dirty-checking and proxies.

Say this

"Records removed most of our Lombok usage on the DTO layer, which dropped an annotation-processing dependency from the build. We kept Lombok on entities where builders and setters are still needed."

Explain static vs instance members and the initialization order.
In simple words

static belongs to the class — one copy shared by everyone, created once when the class is first loaded. Non-static belongs to the object — a fresh copy for every new.

Analogy: a school has one principal (static) but each student has their own roll number (instance).

Exact order when you call new Child() for the first time

  1. Parent's static fields and static blocks — once ever.
  2. Child's static fields and static blocks — once ever.
  3. Parent's instance fields and instance blocks, then the parent constructor.
  4. Child's instance fields and instance blocks, then the child constructor.

Steps 3–4 repeat for every object you create; steps 1–2 never repeat.

The trap

Calling an overridable method from a constructor. The child's override runs during step 3 — before the child's own fields are initialised — so it sees null or 0. Make such methods private or final.

class Parent { Parent(){ init(); }  void init(){} }
class Child extends Parent {
    private String name = "abc";
    @Override void init(){ System.out.println(name.length()); }  // NullPointerException!
}
Say this

"Static loads once at class initialization, instance runs per object, parent before child. The practical consequence is never to call an overridable method from a constructor."

final, finally, finalize — explain all three.
In simple words

Three unrelated things with confusingly similar names:

  • final = "this cannot change" (a keyword).
  • finally = "run this no matter what happens" (a block).
  • finalize = an old, broken cleanup hook the garbage collector might call before deleting an object. Effectively dead — deprecated since Java 9 and removed from use in Java 18+.
final int MAX = 10;              // value can't be reassigned
final void audit(){}             // method can't be overridden
final class Money {}             // class can't be extended

try { risky(); }
catch (Exception e) { log.error("failed", e); }
finally { cleanup(); }           // runs whether or not an exception was thrown

Why finalize() is dead — and what replaced it

You never knew when it would run (maybe never), it delayed garbage collection, and it could even resurrect a dying object. The modern replacements are try-with-resources for anything AutoCloseable, and java.lang.ref.Cleaner for native resources.

// resources are closed automatically, in REVERSE order, even if the body throws
try (var conn = ds.getConnection();
     var ps   = conn.prepareStatement(SQL)) {
    ps.executeQuery();
}   // no finally block needed
Say this

"finalize is obsolete — I use try-with-resources, and Cleaner for the rare native handle. It's also worth knowing that a return inside finally silently swallows the exception from the try block, which is a code-review blocker for me."

Is Java pass by value or pass by reference?
In simple words

Java is always pass by value — but for objects, the "value" being copied is the address of the object, not the object itself.

Analogy: you give a friend a photocopy of your house address. They can visit your house and rearrange the furniture (mutation — you see the change). But if they scribble a different address on their copy, your house is unaffected (reassignment — you see nothing).

void mutate(List<String> list){
    list.add("x");              // caller SEES this - same object was modified
}

void reassign(List<String> list){
    list = new ArrayList<>();   // caller sees NOTHING - only the local copy changed
    list.add("y");
}

void changeNumber(int n){ n = 99; }   // caller sees nothing - primitives copy the value
Say this

"Pass by value always. For objects the reference is copied, so I can mutate the object but reassigning the parameter has no effect outside the method."

How do you make a class truly immutable?
In simple words

Immutable = once created, it can never change. The benefit is huge: it's automatically thread-safe (nothing can change, so no two threads can conflict), it's safe to cache, safe to share, and safe as a map key.

The catch most people miss: making the field final isn't enough if the field points to something mutable. A final reference to an ArrayList still lets anyone add to that list.

The five rules

  1. Make the class final (or use a record) so nobody can subclass and add mutability.
  2. All fields private final.
  3. No setters and no method that changes state.
  4. Defensive copy mutable objects on the way in and on the way out.
  5. Don't let this escape during construction (e.g. registering a listener in the constructor).
public final class Shipment {
    private final String id;
    private final List<String> items;
    private final Date dispatchedAt;

    public Shipment(String id, List<String> items, Date dispatchedAt){
        this.id = id;
        this.items = List.copyOf(items);              // COPY IN: caller can't modify our list
        this.dispatchedAt = new Date(dispatchedAt.getTime());   // Date is mutable - copy it
    }

    public List<String> items(){ return items; }      // already unmodifiable
    public Date dispatchedAt(){ return new Date(dispatchedAt.getTime()); }  // COPY OUT
}
Say this

"Final class, final private fields, no setters, and defensive copies both directions. The payoff is that I never have to reason about thread safety for that class again."

Autoboxing and the Integer cache bug.
In simple words

Java automatically converts between int (a plain number) and Integer (an object wrapping a number). To save memory, the JVM keeps a ready-made cache of Integer objects for the small values -128 to 127. Anything outside that range gets a brand-new object every time.

That's why comparing wrapper objects with == "works" for small numbers and mysteriously fails for large ones.

Integer a = 127, b = 127;
System.out.println(a == b);     // true   - both come from the cache

Integer c = 128, d = 128;
System.out.println(c == d);     // false  - two separate objects!

System.out.println(c.equals(d)); // true  - ALWAYS use equals for wrappers

Two more autoboxing dangers

  • NullPointerException on unboxing. Integer x = map.get("missing"); int y = x; → NPE, because null can't become an int. Very common with Map.get().
  • Garbage in hot loops. Boxing a million values creates a million objects. Use IntStream/primitive arrays on hot paths.
Say this

"Never compare wrappers with ==. The -128..127 cache makes the bug pass in unit tests with small numbers and fail in production with real IDs — I've seen exactly that."

Inner class vs static nested class — why does it matter?
In simple words

A static nested class is just a normal class that happens to live inside another for organisation. A non-static inner class secretly holds a reference back to the outer object — like a child who always keeps their parent's phone number.

That hidden reference is the problem: as long as the inner object is alive, the whole outer object cannot be garbage collected.

class Screen {                       // imagine this holds 50MB of data
    class Listener { }               // INNER: secretly holds "Screen.this"
    static class Helper { }          // STATIC NESTED: holds nothing
}

// Memory leak: the registry keeps the Listener forever,
// so the 50MB Screen can never be collected.
registry.add(screen.new Listener());

The four kinds

  • Static nested — default choice, no hidden reference.
  • Inner (non-static) — only when it genuinely needs the outer instance.
  • Anonymous — one-off implementation; mostly replaced by lambdas.
  • Local — declared inside a method; captured variables must be effectively final.

A lambda is better than an anonymous class here: it does not create a class file per instance (it uses invokedynamic) and it only captures this if the body actually uses it.

Say this

"I default to static nested. A non-static inner class holds an implicit reference to the outer instance, which is a classic memory-leak source when the inner object outlives the outer one."

Why are arrays "broken" but generics safe? (covariance)
In simple words

Java lets you treat a String[] as an Object[]. That sounds convenient, but it lets you put an Integer into an array that is really full of Strings — and the compiler won't stop you. The mistake only blows up at runtime.

Generics deliberately refuse to do this, which is why the same mistake is caught at compile time.

// ARRAYS are covariant -> unsafe
Object[] arr = new String[2];
arr[0] = 42;              // compiles fine... throws ArrayStoreException at RUNTIME

// GENERICS are invariant -> safe
List<Object> list = new ArrayList<String>();   // COMPILE ERROR - caught immediately

Because generics are invariant, you need wildcards (? extends / ? super) to get flexibility back safely — that's the PECS question in the Collections section.

Say this

"Arrays are covariant and check at runtime; generics are invariant and check at compile time. Generics chose safety, and wildcards give back the flexibility where it's provably safe."

What is the diamond problem and how does Java handle it?
In simple words

If a class implements two interfaces and both provide a working version of the same method, which one wins? Java refuses to guess — it makes your code fail to compile and forces you to choose.

interface A { default String hi(){ return "A"; } }
interface B { default String hi(){ return "B"; } }

class C implements A, B {
    // Without this override, C does NOT compile.
    @Override public String hi(){ return A.super.hi(); }   // explicit choice
}

Resolution rules, in order

  1. A method inherited from a class beats one from an interface.
  2. The most specific sub-interface wins (if B extends A, B's version wins).
  3. Otherwise → compile error, you must override and pick.
Say this

"Java avoids C++'s ambiguity by making it a compile error and giving you Interface.super.method() to disambiguate explicitly."

== vs equals(), and the BigDecimal money trap.
In simple words

== asks "are these the same object?" (or for primitives, "the same number?"). equals() asks "do these have the same content?" — as defined by the class.

Analogy: two identical twins are equals but not ==.

The BigDecimal trap you must know for any finance interview

new BigDecimal("1.0").equals(new BigDecimal("1.00"));       // FALSE! scale differs
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) == 0;  // true - use this

BigDecimal.equals() compares the value and the scale. For "is this the same amount of money", always use compareTo(...) == 0. This also means Set<BigDecimal> can hold both 1.0 and 1.00 as separate entries.

Money rule

Never use double or float for currency — they're binary fractions, so 0.1 + 0.2 gives 0.30000000000000004. Use BigDecimal with an explicit RoundingMode, or store minor units (paise/cents) in a long.

Say this

"For money I use BigDecimal with an explicit scale and RoundingMode, and I compare with compareTo, never equals — the scale-sensitivity of equals has caused real reconciliation bugs."

2. Collections & Generics

The most-asked backend area. HashMap internals get asked in almost every interview.

How do you choose the right collection?
In simple words

Four families: a List is an ordered shopping list (duplicates allowed, positions matter). A Set is a bag of unique items. A Map is a dictionary: look up a value by its key. A Queue/Deque is a line of people — things enter one end and leave the other.

Decision table

What you needUseWhy
Access by position, mostly readingArrayListBacked by an array — instant get(i), CPU-cache friendly
Add/remove at both ends, or a stack/queueArrayDequeFaster than LinkedList and Stack
Unique items, order doesn't matterHashSetInstant contains()
Unique items, keep insertion orderLinkedHashSetPredictable output — great for de-duplicating
Sorted data / range queriesTreeMap, TreeSetRed-black tree; gives headMap, ceiling, first
Map shared across threadsConcurrentHashMapNo single global lock
Producer/consumer handoffLinkedBlockingQueueBlocks when full/empty — natural back-pressure
Many reads, very rare writesCopyOnWriteArrayListListener lists — iteration never throws
Keys are enum valuesEnumMap, EnumSetArray/bit-vector backed — much faster and smaller
Say this

"I also size collections when I know the volume — new ArrayList<>(expectedSize) and new HashMap<>(expected/0.75f + 1) avoid repeated array copies and rehashing in hot code."

Explain HashMap internals — buckets, collisions, resize, treeify.
In simple words

A HashMap is an array of buckets. When you put a key in, Java computes the key's hash to decide which bucket it belongs to, then stores the entry there. Looking it up is instant because it goes straight to that bucket instead of scanning everything.

Two different keys can land in the same bucket (a collision). Those entries form a small chain inside the bucket, and Java then compares them with equals() to find the right one.

Step by step: what put(key, value) does

  1. Call key.hashCode().
  2. Spread the bits: hash = h ^ (h >>> 16). This mixes the high bits down so that even a poor hashCode spreads across buckets.
  3. Find the bucket: index = (table.length - 1) & hash. This works as a fast modulo because the table length is always a power of two.
  4. Empty bucket → store it. Occupied → walk the chain, compare with equals(); replace if found, append if not.
  5. If a single bucket's chain reaches 8 entries and the table is at least 64 long, that chain converts into a red-black tree (Java 8+). Worst case lookup goes from O(n) to O(log n). It converts back below 6.
  6. When size > capacity × 0.75 (the load factor), the table doubles and entries are redistributed.
// simplified view of the index calculation
int h    = key.hashCode();
int hash = h ^ (h >>> 16);              // spread
int idx  = (table.length - 1) & hash;   // bucket index
Follow-up: why not use HashMap from multiple threads?

Before Java 8, a concurrent resize could link entries into a circular list, and a later get() would spin forever at 100% CPU — a famous production hang. Java 8 fixed the infinite loop, but you still get lost updates and a wrong size(). Use ConcurrentHashMap.

Say this

"Array of buckets, hash spread to pick the bucket, chain on collision, treeify at 8 for O(log n) worst case, resize at 75% load. Two keys that are equal must hash the same or the whole structure stops working."

How does ConcurrentHashMap stay thread-safe without locking everything?
In simple words

Old Hashtable put one big lock on the entire map — like a supermarket with a single checkout: only one customer at a time, everyone else queues.

ConcurrentHashMap instead locks only the individual bucket being written to — like one checkout per aisle. Different threads writing to different buckets never block each other, and reads never lock at all.

How it actually works (Java 8+)

  • Reads are lock-free. Nodes are volatile, so a reader always sees a consistent, published value.
  • Writing into an empty bucket uses CAS (compare-and-set) — an atomic CPU instruction, no lock at all.
  • Writing into an occupied bucket synchronises on that bucket's first node only.
  • Resizing is cooperative — several threads help move buckets instead of one thread blocking everyone.
  • size() uses a striped counter (like LongAdder) so counting doesn't become the bottleneck.
// atomic "read-modify-write" without any external lock
map.compute(key, (k, v) -> v == null ? 1 : v + 1);      // safe counter

// load-once cache idiom - only ONE thread runs the expensive load per key
map.computeIfAbsent(key, k -> expensiveLoad(k));
Gotcha

The function you pass to computeIfAbsent runs while holding the bucket lock. Keep it short, and never modify the same map inside it — that can deadlock.

Say this

"Per-bucket locking plus CAS instead of one global lock, and lock-free reads. I use computeIfAbsent for a load-once cache and compute for atomic counters, because a get-then-put pair is still a race even on a thread-safe map."

Compare HashMap, Hashtable, ConcurrentHashMap, LinkedHashMap and TreeMap.
TypeThread-safe?null key/value?OrderLookup
HashMapNo1 null key, many null valuesNoneO(1)
HashtableYes — one global lock (legacy)NoneNoneO(1), slow under load
ConcurrentHashMapYes — per-bucketNoneNoneO(1), scales
LinkedHashMapNoYesInsertion or access orderO(1)
TreeMapNoNo null keySortedO(log n)
Why LinkedHashMap is interesting

It keeps a doubly-linked list threaded through the entries. Switch it to access order and it becomes an LRU cache in five lines — a very common follow-up question.

// LRU cache: capacity 100, evicts the least-recently-USED entry
Map<String, Data> lru = new LinkedHashMap<>(16, 0.75f, true) {   // true = access order
    @Override protected boolean removeEldestEntry(Map.Entry<String, Data> eldest){
        return size() > 100;
    }
};
Say this

"HashMap for general use, LinkedHashMap when output order matters or I need a quick LRU, TreeMap for range queries, ConcurrentHashMap for anything shared. Hashtable is legacy and I'd flag it in review."

What is ConcurrentModificationException and how do you avoid it?
In simple words

Imagine reading a list of names out loud while someone rips pages out of the same list. Java detects this and stops you immediately rather than giving wrong results. That's a fail-fast iterator.

Important: this happens on a single thread too — the most common cause is removing from a list inside a for-each loop over that same list.

How the detection works

The collection keeps a counter called modCount, incremented on every structural change. The iterator records that number when it starts and checks it on every next(). If it changed, it throws.

// WRONG - throws ConcurrentModificationException
for (String s : list) {
    if (s.isBlank()) list.remove(s);
}

// RIGHT - option 1: removeIf (clearest)
list.removeIf(String::isBlank);

// RIGHT - option 2: the iterator's own remove()
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (it.next().isBlank()) it.remove();      // updates modCount correctly
}

// RIGHT - option 3: collect into a new list
List<String> kept = list.stream().filter(s -> !s.isBlank()).toList();

Fail-safe collections

CopyOnWriteArrayList and ConcurrentHashMap never throw this — they iterate over a snapshot or tolerate concurrent updates. The trade-off is that you may not see the very latest data.

Say this

"Fail-fast is a bug detector, not a thread-safety feature — it fires on one thread too. I use removeIf or the iterator's remove, and concurrent collections when multiple threads are genuinely involved."

Comparable vs Comparator, and sorting by multiple fields.
In simple words

Comparable is the class's own natural order — the class says "this is how I sort by default" (numbers ascending, strings alphabetically). There's only one.

Comparator is an external rule you supply at the moment of sorting — "sort employees by department, then by salary descending". You can have as many as you like.

// Comparable: built into the class
public class Employee implements Comparable<Employee> {
    public int compareTo(Employee o){ return this.id.compareTo(o.id); }  // natural order
}

// Comparator: chained, readable, null-safe
employees.sort(
    Comparator.comparing(Employee::getDepartment)                    // 1st key
              .thenComparing(Employee::getSalary, Comparator.reverseOrder())  // 2nd, desc
              .thenComparing(Employee::getName, String.CASE_INSENSITIVE_ORDER));

// handle possible nulls
Comparator<Employee> byManager =
    Comparator.comparing(Employee::getManager, Comparator.nullsLast(Comparator.naturalOrder()));
The overflow bug

Writing return a.getX() - b.getX(); looks fine but overflows for large ints and produces a comparator that isn't transitive. Java then throws "Comparison method violates its general contract!" — usually only in production, with large data. Always use Integer.compare(a, b).

Say this

"Comparable for the one natural order, Comparator for everything else, chained with thenComparing. And never subtract to compare — Integer.compare avoids the overflow bug that breaks TimSort's contract."

ArrayList vs LinkedList — why do you almost never use LinkedList?
In simple words

ArrayList = a row of numbered lockers side by side. Jumping to locker 500 is instant. Inserting in the middle means shifting lockers along.

LinkedList = a treasure hunt: each item holds a note pointing to the next. Inserting is easy once you're standing there, but reaching item 500 means following 500 notes.

Why ArrayList usually wins even for inserts

Shifting elements in an ArrayList uses System.arraycopy, a highly optimised block memory move — extremely fast. LinkedList's nodes are scattered across the heap, so every step is a potential CPU cache miss, and every element costs two extra pointers of memory.

OperationArrayListLinkedList
get(i)O(1)O(n)
add at endO(1) amortisedO(1)
add/remove in middleO(n) but very fast memory moveO(n) to find, O(1) to link
Memory per elementJust the referenceReference + 2 pointers + node object
Say this

"ArrayList by default. If I need queue or deque behaviour I use ArrayDeque, not LinkedList. In real benchmarks LinkedList loses even at insert-heavy workloads because of cache locality."

What are generics and what is type erasure?
In simple words

Generics let you say "this is a list of Strings" so the compiler catches mistakes and you don't need casts.

Type erasure means the generic information exists only at compile time. After compiling, List<String> and List<Integer> are both just List — the type argument is erased and the compiler inserts casts for you. This was done so Java 5 generic code could run on older JVMs and interoperate with old non-generic code.

What erasure costs you

  • No new T() and no new T[10] — the type isn't known at runtime.
  • No x instanceof List<String> — only instanceof List.
  • You cannot overload f(List<String>) and f(List<Integer>) — after erasure both are f(List), a duplicate method.
  • No primitives as type arguments — hence IntStream, Integer boxing, etc.
// Getting the type back at runtime: pass a token
<T> T read(String json, Class<T> type){ return mapper.readValue(json, type); }

// Or capture the full generic type via an anonymous subclass (what Jackson/Spring do)
new TypeReference<List<OrderDto>>() {};
new ParameterizedTypeReference<List<OrderDto>>() {};
Say this

"Generics are compile-time only — erased afterwards. That's why you need a Class token or a TypeReference whenever a framework has to reconstruct the type at runtime, like deserialising a List of DTOs."

Explain PECS (? extends vs ? super).
In simple words

PECS = Producer Extends, Consumer Super.

If a collection gives you things (a producer), use ? extends T — you can safely read T out, but you can't put anything in, because you don't know the exact subtype.

If a collection receives things (a consumer), use ? super T — you can safely put T in, but reading gives you only Object.

Analogy: from a crate labelled "some kind of fruit" you can safely take out a fruit, but you can't add an apple — it might be a crate of oranges. Into a crate labelled "anything that can hold fruit" you can safely add an apple, but you don't know what you'd pull out.

// copies FROM a producer INTO a consumer
static <T> void copy(List<? extends T> source, List<? super T> dest){
    for (T item : source) dest.add(item);
}

List<Integer> ints  = List.of(1, 2, 3);
List<Number>  nums  = new ArrayList<>();
copy(ints, nums);        // works ONLY because of the wildcards

// Why you can't add to "? extends"
List<? extends Number> l = new ArrayList<Integer>();
l.add(3.14);             // COMPILE ERROR - it might be a List<Integer>

You'll recognise this in the JDK: Collections.copy(List<? super T> dest, List<? extends T> src) and Stream.map(Function<? super T, ? extends R>).

Say this

"Producer extends, consumer super. It's what lets a method accept a List<Integer> where it logically wants numbers, without breaking type safety."

Arrays.asList() vs List.of() vs new ArrayList<>() — the differences that cause bugs.
In simple words

All three look like "make a list", but they behave very differently when you try to change them. Two of them are traps.

Arrays.asList(a,b)List.of(a,b)new ArrayList<>(...)
Backed byThe original arrayImmutable internal storageIts own array
add()/remove()❌ UnsupportedOperation❌ UnsupportedOperation✅ works
set(i, v)⚠️ allowed — writes through to the array!❌ UnsupportedOperation✅ works
Allows nullsYes❌ throws NPEYes
// TRAP 1: fixed size
List<String> l = Arrays.asList("a","b");
l.add("c");                       // UnsupportedOperationException

// TRAP 2: primitive array becomes a list of ONE element
int[] nums = {1,2,3};
List<int[]> wrong = Arrays.asList(nums);           // size 1 !!
List<Integer> right = Arrays.stream(nums).boxed().toList();   // size 3

// Need a modifiable list from a fixed one:
List<String> modifiable = new ArrayList<>(List.of("a","b"));
Say this

"List.of for a true immutable list, new ArrayList<>(List.of(...)) when it must be modifiable. I avoid Arrays.asList because it's a fixed-size view that writes through to the source array."

How do you make a collection thread-safe?
In simple words

Four options, best to worst: use a purpose-built concurrent collection; make it immutable so there's nothing to protect; confine it to one thread; or wrap it in a lock. Wrapping is the crude option most people reach for first.

// 1. BEST - concurrent collections
Map<K,V> m = new ConcurrentHashMap<>();
List<Listener> ls = new CopyOnWriteArrayList<>();   // many reads, rare writes
BlockingQueue<Task> q = new ArrayBlockingQueue<>(1000);

// 2. Immutable - publish a new instance instead of mutating
List<String> safe = List.copyOf(source);

// 3. Synchronized wrapper - coarse, and iteration still needs manual locking!
List<String> sync = Collections.synchronizedList(new ArrayList<>());
synchronized (sync) { for (String s : sync) { ... } }   // REQUIRED, easy to forget
The point most candidates miss

A thread-safe collection makes each individual call atomic — it does not make your sequence of calls atomic.

// STILL A RACE even on ConcurrentHashMap:
if (!map.containsKey(k)) map.put(k, v);      // two threads can both pass the check

// Correct - one atomic operation:
map.putIfAbsent(k, v);
map.computeIfAbsent(k, key -> build(key));
Say this

"Concurrent collections give you atomic operations, not atomic transactions. Any check-then-act sequence still needs a single atomic method like putIfAbsent, or an explicit lock."

Iterator vs ListIterator vs Spliterator.
In simple words

Iterator walks forward through a collection, one item at a time. ListIterator can also go backwards and edit as it goes (lists only). Spliterator is the one built for parallelism: as well as walking, it can split itself in half so two threads can process different halves.

// Spliterator's key method
Spliterator<String> s1 = list.spliterator();
Spliterator<String> s2 = s1.trySplit();     // s1 keeps half, s2 gets the other half

Spliterator also reports characteristics (SIZED, ORDERED, DISTINCT, IMMUTABLE) that let the stream engine skip work — for example, if the source is already DISTINCT, .distinct() becomes free.

Say this

"Spliterator is why ArrayList.parallelStream() performs well and LinkedList.parallelStream() doesn't — an array splits into equal halves instantly, a linked list has to be walked to split at all."

3. Lambdas & Streams (concepts)

Concepts here; Section 21 has 25 solved Stream programs for the coding round.

What is a functional interface and a lambda?
In simple words

A functional interface is an interface with exactly one method to implement. Because there's only one, Java can let you write just the method body — that short form is a lambda.

Before Java 8 you wrote 5 lines of anonymous class to pass behaviour around. A lambda is the same thing in one line.

// Before Java 8
list.sort(new Comparator<String>() {
    @Override public int compare(String a, String b){ return a.length() - b.length(); }
});

// With a lambda - identical meaning
list.sort((a, b) -> Integer.compare(a.length(), b.length()));

// With a method reference - shorter still
list.sort(Comparator.comparingInt(String::length));

The built-in ones you use daily

InterfaceMethodPlain meaningUsed by
Function<T,R>R apply(T)Take one thing, return anothermap
Predicate<T>boolean test(T)Yes/no questionfilter
Consumer<T>void accept(T)Do something, return nothingforEach
Supplier<T>T get()Produce a value on demandorElseGet
BinaryOperator<T>T apply(T,T)Combine two into onereduce

They compose: p1.and(p2), p.negate(), f.andThen(g).

Say this

"A lambda isn't an anonymous class under the hood — the compiler emits an invokedynamic call site and LambdaMetafactory links it at first use. That means no extra class file per lambda, and non-capturing lambdas are cached as a single instance."

What are the four kinds of method reference?
In simple words

A method reference is a lambda whose body does nothing but call one existing method. s -> s.toUpperCase() becomes String::toUpperCase.

String::toUpperCase      // 1. instance method of whatever object arrives
System.out::println      // 2. instance method of ONE specific object
Integer::parseInt        // 3. static method
ArrayList::new           // 4. constructor
String[]::new            // bonus: array constructor, used in toArray()
Say this

"I use them when they make code clearer, and fall back to an explicit lambda when the reference obscures which argument goes where."

Intermediate vs terminal operations — what does "lazy" mean?
In simple words

A stream is like an assembly line that doesn't switch on until someone asks for the finished product.

Intermediate operations (filter, map, sorted) just describe a step and return a new stream — they run nothing. A terminal operation (collect, forEach, count, findFirst) starts the machine.

Because of this, Java can make one single pass over the data doing all the steps together, and can stop early as soon as it has the answer.

List.of("a", "bb", "ccc").stream()
    .peek(s -> System.out.println("looking at " + s))
    .filter(s -> s.length() > 1)
    .findFirst();

// OUTPUT:
// looking at a
// looking at bb        <-- stops here! "ccc" is never touched

Notice each element flows through all the steps before the next element starts — the stream does not do "all the filtering, then all the mapping".

Three rules
  • A stream can be consumed once. Reusing it throws IllegalStateException.
  • peek is for debugging only — the JVM is allowed to skip it.
  • Never modify the source collection while streaming it.
Say this

"Laziness gives you fusion — one pass instead of one per operator — and short-circuiting, so findFirst on a million-element stream may only touch two elements."

map vs flatMap — explain the difference clearly.
In simple words

map transforms one thing into one thing. flatMap transforms one thing into many things and then flattens them into a single stream.

Analogy: you have 3 boxes, each containing several books. map gives you 3 lists of books. flatMap tips all the boxes out and gives you one pile of books.

List<Order> orders = ...;          // each Order has List<OrderLine> lines

// map -> a stream of LISTS (usually not what you want)
List<List<OrderLine>> nested = orders.stream().map(Order::getLines).toList();

// flatMap -> a single flat stream of all lines
List<String> allSkus = orders.stream()
        .flatMap(o -> o.getLines().stream())   // 1 order -> many lines, flattened
        .map(OrderLine::getSku)               // 1 line -> 1 sku
        .distinct()
        .sorted()
        .toList();

// Optional has it too - avoids Optional<Optional<String>>
Optional<String> city = findUser(id).flatMap(User::getAddress).map(Address::getCity);
Say this

"Whenever the mapping function itself returns a collection or an Optional, I need flatMap — otherwise I end up with a stream of streams."

Explain Collectors, especially groupingBy with a downstream collector.
In simple words

collect() is the step that turns a stream back into something you can hold — a List, a Map, a String, a total.

groupingBy is the SQL GROUP BY of Java. The optional downstream collector is the second argument that says "and once you've grouped them, do this to each group" — count them, sum them, map them to another field.

// 1. Group employees by department -> Map<String, List<Employee>>
Map<String, List<Employee>> byDept =
    emps.stream().collect(groupingBy(Employee::getDept));

// 2. Group and COUNT -> Map<String, Long>
Map<String, Long> countByDept =
    emps.stream().collect(groupingBy(Employee::getDept, counting()));

// 3. Group and keep only names -> Map<String, List<String>>
Map<String, List<String>> namesByDept =
    emps.stream().collect(groupingBy(Employee::getDept,
                          mapping(Employee::getName, toList())));

// 4. Group into a SORTED map and sum salaries
Map<String, BigDecimal> salaryByDept =
    emps.stream().collect(groupingBy(Employee::getDept, TreeMap::new,
        reducing(BigDecimal.ZERO, Employee::getSalary, BigDecimal::add)));

// 5. Split into exactly two groups by a yes/no test
Map<Boolean, List<Employee>> split =
    emps.stream().collect(partitioningBy(e -> e.getSalary().doubleValue() > 50000));

// 6. Build a Map keyed by something unique
Map<String, Employee> byEmail = emps.stream()
    .collect(toMap(Employee::getEmail,
                   e -> e,
                   (existing, duplicate) -> existing,   // what to do on a duplicate key
                   LinkedHashMap::new));                 // which Map implementation

// 7. Statistics and joining
IntSummaryStatistics stats = emps.stream().mapToInt(Employee::getAge).summaryStatistics();
// stats.getMin() / getMax() / getAverage() / getSum() / getCount()
String csv = emps.stream().map(Employee::getName).collect(joining(", ", "[", "]"));
Two traps interviewers love
  • toMap throws IllegalStateException on a duplicate key unless you supply the third merge argument.
  • toMap also throws NullPointerException if a value is null — groupingBy doesn't. This bites when mapping a nullable column.
Say this

"groupingBy with a downstream collector is the workhorse — group-and-count, group-and-sum, group-and-map. And I always supply the merge function to toMap because production data always has the duplicate you didn't expect."

reduce vs collect — what's the difference?
In simple words

reduce combines values by producing a new value each step — good for numbers. collect pours items into a container that gets filled up — good for lists, maps, strings.

Analogy: reduce is adding up a bill in your head (one running total). collect is putting shopping into a bag.

// reduce - immutable, produces a new BigDecimal at each step
BigDecimal total = lines.stream()
        .map(Line::getAmount)
        .reduce(BigDecimal.ZERO, BigDecimal::add);

// collect - mutable container, no intermediate objects
String names = emps.stream().map(Employee::getName).collect(joining(", "));
The classic anti-pattern

stream.reduce("", String::concat) is O(n²) — it builds a brand-new String at every step. Use Collectors.joining(), which appends into a single StringBuilder.

Say this

"reduce for immutable folds like summing money, collect for anything that accumulates into a container — collect avoids creating n intermediate objects."

When should you use a parallel stream — and when is it a mistake?
In simple words

.parallelStream() splits the data into chunks and processes them on several CPU cores at once. It sounds like free speed, but there's a catch: all parallel streams in the whole JVM share one thread pool (the common ForkJoinPool). If you block that pool, you slow down everything else in your application.

Use it when — all of these are true

  • The data set is large (roughly 10,000+ elements).
  • The work per element is CPU-heavy (calculation, parsing), not waiting on I/O.
  • The source splits evenly — array, ArrayList, IntStream.range.
  • Your lambda has no shared mutable state and no side effects.
  • Combining results is cheap.

Don't use it when

  • The work is a database call or an HTTP call (blocking). You'd starve the shared pool.
  • N is small — the coordination overhead costs more than it saves.
  • The source is a LinkedList, Files.lines, or Stream.iterate — these split badly.
  • You're already inside a web request being served by 200 concurrent threads — you already have parallelism.
// If you must parallelise blocking work, use your OWN pool - never the common one
ForkJoinPool pool = new ForkJoinPool(8);
List<Result> out = pool.submit(() ->
        ids.parallelStream().map(client::fetch).toList()).get();
pool.shutdown();

// Better in Java 21: virtual threads
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) { ... }
Say this

"A team once wrapped a blocking REST call in parallelStream. Under load it saturated the common ForkJoinPool and unrelated sorts elsewhere in the JVM stalled. We replaced it with a bounded executor sized to the downstream's connection limit."

How should Optional be used — and misused?
In simple words

Optional is a box that either contains a value or is empty. Its purpose is to make "this might not exist" visible in the method signature, so callers can't forget to handle it — instead of returning null and hoping.

// Chain safely - no null checks anywhere
String city = repo.findById(id)
        .map(User::getAddress)
        .map(Address::getCity)
        .filter(c -> !c.isBlank())
        .orElse("Unknown");

// Throw a meaningful exception when absent
Order o = repo.findById(id).orElseThrow(() -> new OrderNotFoundException(id));

// Two branches
repo.findById(id).ifPresentOrElse(this::process, this::handleMissing);
orElse vs orElseGet — a real performance bug
opt.orElse(expensiveDefault());     // expensiveDefault() runs ALWAYS, even if present
opt.orElseGet(() -> expensiveDefault());  // runs only when empty

Where NOT to use it

  • As a field — it isn't Serializable and adds an object per field.
  • As a method parameter — use an overload instead.
  • In entities or JSON DTOs.
  • Calling .get() without checking — that's just a NullPointerException with extra steps.
Say this

"Optional is a return type, not a general-purpose null replacement. Used well it removes a whole class of NPEs from the service layer."

4. Modern Java (9 → 21)

Most candidates stop at Java 8. Knowing 17 and 21 is an easy way to stand out, and the JD asks you to stay current.

What are virtual threads (Java 21) and why do they matter?
In simple words

A normal Java thread is backed by an operating system thread — expensive (~1MB of stack each), so a server can realistically hold a few thousand. When such a thread waits for a database reply, that whole expensive thread sits idle doing nothing.

A virtual thread is managed by the JVM instead of the OS. When it blocks on I/O, the JVM parks it and reuses the underlying OS thread for someone else. Threads become almost free, so you can have millions.

Analogy: instead of one waiter standing frozen at each table until the food is ready, one waiter serves many tables and simply moves on while the kitchen works.

// Old world: bounded pool, blocking = wasted thread
ExecutorService old = Executors.newFixedThreadPool(200);

// New world: one virtual thread per task, blocking is cheap
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Report>> futures = ids.stream()
        .map(id -> exec.submit(() -> reportClient.fetch(id)))   // blocking call - fine!
        .toList();
}   // close() waits for all tasks to finish

// Spring Boot 3.2+ - one property makes Tomcat use virtual threads
// spring.threads.virtual.enabled=true

Why this changes architecture

The reason teams adopted reactive frameworks like WebFlux was to avoid blocking threads. Virtual threads give you that scalability with ordinary, readable, debuggable imperative code — normal stack traces, normal debugger, normal try/catch.

Caveats to mention
  • They don't help CPU-bound work — you still only have N cores.
  • Pinning: in JDK 21, blocking inside a synchronized block pins the virtual thread to its carrier thread. Prefer ReentrantLock (largely fixed in JDK 24).
  • Your bottleneck moves: with 10,000 virtual threads and a 20-connection DB pool, the pool is now the limit.
  • Don't pool virtual threads — create one per task.
Say this

"Virtual threads make thread-per-request scale again. The interesting consequence is that the connection pool, not the thread pool, becomes your capacity limit — so sizing moves to the database side."

Sealed classes and pattern matching for switch.
In simple words

sealed lets a class or interface say: "only these specific types are allowed to implement me". Because the list is closed, the compiler knows every possibility — so a switch over them can be checked for completeness at build time.

The payoff: add a new event type, and every switch that forgot to handle it fails to compile instead of failing in production.

public sealed interface PaymentEvent
        permits Authorised, Captured, Refunded, Failed { }

public record Authorised(String txnId, BigDecimal amount) implements PaymentEvent {}
public record Failed(String txnId, String reason)         implements PaymentEvent {}

String describe(PaymentEvent e) {
    return switch (e) {                                    // no 'default' needed
        case Authorised a when a.amount().signum() == 0 -> "zero-value auth";
        case Authorised a  -> "authorised " + a.amount();
        case Captured  c   -> "captured";
        case Refunded  r   -> "refunded";
        case Failed(String id, String reason) -> "failed: " + reason;  // record pattern
    };
}

Notice three modern features together: pattern matching (case Authorised a — no cast needed), guards (when), and record deconstruction (Failed(String id, String reason) pulls the fields out directly).

Say this

"Sealed types give Java proper algebraic data types. In a payments state machine that meant adding a new state was a compile error everywhere it wasn't handled — the compiler became the reviewer."

Switch expressions, text blocks, and other quality-of-life features.
In simple words

A switch expression returns a value directly, has no fall-through, and must cover every case. A text block is a multi-line string written naturally, without \n and + everywhere.

// switch EXPRESSION - returns a value, no break, no fall-through bugs
int days = switch (month) {
    case FEB -> isLeapYear ? 29 : 28;
    case APR, JUN, SEP, NOV -> 30;
    default -> 31;
};

// text block - what you write is what you get
String sql = """
        SELECT o.id, o.total
          FROM orders o
         WHERE o.status = :status
         ORDER BY o.created_at DESC
        """;

Other additions worth naming

// Collections (immutable)   Java 9
List.of(), Set.of(), Map.of(), List.copyOf(c)

// String                    Java 11
" x ".strip();  "".isBlank();  "ab".repeat(3);  text.lines().toList();

// Stream                    Java 9-16
stream.takeWhile(p);  stream.dropWhile(p);  stream.toList();  Stream.ofNullable(x);

// Optional
opt.or(() -> other);  opt.ifPresentOrElse(a, b);  opt.stream();

// Files / HttpClient        Java 11
Files.readString(path);   HttpClient.newHttpClient().send(req, ofString());

// Sequenced collections     Java 21
list.getFirst();  list.getLast();  list.reversed();  map.firstEntry();

// var                       Java 10 - local variables only, still statically typed
var repo = new CustomerRepository();
Say this

"On var my review rule is: allow it when the right-hand side already names the type, reject it when it hides the type, like var result = service.process(x)."

What is structured concurrency?
In simple words

When you fire off 3 parallel tasks today, you must remember to wait for all of them, cancel the others if one fails, and collect the errors. Structured concurrency makes that automatic by treating the group as one unit of work with a defined scope — like a try-with-resources block for concurrency.

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User>  user  = scope.fork(() -> userService.find(id));
    Subtask<Perms> perms = scope.fork(() -> permService.find(id));

    scope.join();              // wait for both
    scope.throwIfFailed();     // if either failed, the OTHER is cancelled automatically

    return new Profile(user.get(), perms.get());
}

Compare with the old pattern: submit two futures, remember to cancel the second in a finally, and handle two separate exception paths. Most codebases got that wrong.

Say this

"It removes the leaked-task and forgotten-cancellation class of bugs, and thread dumps finally show the real parent-child relationship between tasks."

How do you stay current with Java and Angular? (a JD bullet)
Answer with a routine, not a platitude

"I follow the JEP index and Spring Boot release notes. I keep a sandbox repo where I port one internal module to each new LTS early — that's how we found our javaxjakarta work before the Boot 3 upgrade was urgent. On the front end I track the Angular release blog and the RFCs, and I've prototyped signals and zoneless change detection. Internally I run a 30-minute tech share each sprint; the last two were virtual threads and Angular signals. And I pilot anything new on a low-risk service behind a feature flag before proposing it team-wide."

5. JVM, Memory & Garbage Collection

This is where a 10-year candidate separates from a 4-year one. Tie every answer to something you actually diagnosed.

Describe the JVM memory areas.
In simple words

Think of the JVM's memory as a building:

  • Heap — the big shared warehouse where all objects live. Garbage collected.
  • Stack — each thread's personal notepad: method calls, local variables. Automatically cleaned when a method returns.
  • Metaspace — the filing cabinet holding class definitions (what a Customer class is). Lives in native memory, not the heap.
  • Code cache — where the JIT stores machine code it has compiled.

Detail worth knowing

  • The heap is split into Young (Eden + two Survivor spaces) and Old. New objects start in Eden.
  • Each thread's stack is about 1MB by default. Deep recursion → StackOverflowError.
  • Metaspace replaced PermGen in Java 8. It grows until -XX:MaxMetaspaceSize, so a classloader leak now exhausts native memory instead of PermGen.
The container gotcha

Total memory used by a Java process ≈ heap + metaspace + code cache + (1MB × threads) + direct/NIO buffers + GC structures. That's why a container with a 512MB limit and -Xmx512m gets OOM-killed by the kernel — the heap alone is allowed to fill the entire limit. Use -XX:MaxRAMPercentage=75 instead of a fixed -Xmx.

Say this

"Heap is shared and collected, stack is per-thread and automatic, metaspace is native and holds class metadata. In containers I size with MaxRAMPercentage because RSS is much more than the heap."

How does garbage collection actually work?
In simple words

The GC doesn't count references — it asks a simpler question: "starting from the live parts of the program, can I still reach this object?" Anything unreachable is garbage, no matter how many other pieces of garbage point at it (which is why circular references are collected fine in Java).

The starting points are called GC roots: local variables on thread stacks, static fields, active threads, JNI references.

Why generations exist

The weak generational hypothesis: most objects die very young (a DTO created and discarded inside one request). So the JVM optimises for that:

  1. New objects go into Eden — allocation is just moving a pointer, extremely fast.
  2. Eden fills up → Minor GC. Surviving objects are copied to a Survivor space and their age increases. Everything else is discarded wholesale — cheap, because most objects died.
  3. An object that survives enough rounds is promoted to the Old generation.
  4. Old fills up → Major/Full GC: mark, sweep, compact. Much more expensive, longer pause.
CollectorBest forCharacter
SerialTiny heaps, 1 CPUSimple; the container default if <2 CPUs
ParallelBatch jobsHighest throughput, longest pauses
G1Default since Java 9 — most server appsDivides heap into regions; you set a pause target
ZGC / ShenandoahLatency-critical, huge heapsNearly all work concurrent; sub-millisecond pauses
Say this

"Reachability from GC roots, generational because most objects die young. I run G1 with a pause target and only reach for ZGC when p99 latency is dominated by GC pauses on a large heap."

You get an OutOfMemoryError in production. Walk me through it.
In simple words

OutOfMemoryError doesn't always mean "not enough RAM". The message tells you which memory area ran out, and each one has a different cause. Read the message first — most people skip this and start guessing.

Step 1 — read the message

MessageWhat it usually means
Java heap spaceReal leak, or the heap is genuinely too small
GC overhead limit exceededSpending >98% of time in GC recovering <2% — nearly always a leak
MetaspaceClassloader leak — classic with repeated hot redeploys
unable to create new native threadThread leak or an OS ulimit
Direct buffer memoryNIO/Netty buffers not released

Step 2 — always have evidence configured in advance

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=20M
-XX:+ExitOnOutOfMemoryError        # let the orchestrator restart a poisoned JVM

Step 3 — leak or just undersized?

Plot old-generation occupancy immediately after each Full GC. A sawtooth that returns to the same baseline = healthy, just under pressure. A staircase that keeps climbing = a leak.

Step 4 — find the culprit

Open the heap dump in Eclipse MAT → Leak Suspects report → dominator tree → pick the biggest retained set → right-click "Path to GC Roots" — that tells you exactly which object is refusing to let go.

Leaks I have actually found
  • A static HashMap used as a "cache" with no size limit or TTL.
  • A ThreadLocal never removed, in a pooled Tomcat thread.
  • Listeners registered but never deregistered.
  • An unbounded queue inside an ExecutorService.
  • A JPA transaction reading millions of rows — the persistence context holds every entity.
Say this

"Read the message, confirm leak vs pressure from post-Full-GC occupancy, then MAT's dominator tree and path-to-GC-root. The fix is usually a bound — a size limit, a TTL, or a removal I forgot."

Explain strong, soft, weak and phantom references — and the ThreadLocal leak.
In simple words

Four strengths of "I'm holding on to this object", from clingy to barely holding on:

  • Strong — normal reference. Never collected while reachable.
  • Soft — "keep it if you can, drop it if memory gets tight". Memory-sensitive caches.
  • Weak — "drop it as soon as nobody else strongly wants it". Used by WeakHashMap.
  • Phantom — "tell me after it's gone" so I can clean up a native resource.
The famous ThreadLocal leak — explained properly

Each thread has a ThreadLocalMap. Its keys are weak references to the ThreadLocal object, but its values are strong. In a thread pool the thread lives forever, so if you never call remove(), the value is strongly held for the life of the application. If that value transitively references your webapp's classes, the whole classloader leaks on redeploy — Metaspace OOM.

private static final ThreadLocal<Context> CTX = new ThreadLocal<>();

try {
    CTX.set(context);
    doWork();
} finally {
    CTX.remove();          // MANDATORY in any pooled thread
}
Say this

"Weak keys but strong values is the detail people miss — that's why the fix is always an explicit remove in a finally block, usually in a servlet filter."

How does class loading work?
In simple words

Before a class can be used, it must be loaded (read the bytes), linked (verify, allocate statics, resolve references) and initialized (run static blocks).

The key rule is parent delegation: when asked to load a class, a classloader first asks its parent. Only if the parent can't find it does the child try. This is why nobody can slip a fake java.lang.String onto the classpath — the bootstrap loader always answers first.

Practical consequences

  • Class identity = fully-qualified name + classloader. The same class loaded by two loaders is two different types → a baffling ClassCastException saying Foo cannot be cast to Foo.
  • ClassNotFoundException = the class was never found (usually a missing jar).
    NoClassDefFoundError = it was found once but is unusable now — most often its static initializer threw earlier, which is a very common cause of confusing startup failures.
  • Frameworks deliberately break delegation: Tomcat uses child-first per webapp for isolation, and Spring Boot's LaunchedURLClassLoader reads nested jars inside the fat jar.
Say this

"Parent delegation for safety, and class identity includes the loader. When I see 'Foo cannot be cast to Foo', I know it's a duplicate jar loaded by two classloaders."

What does the JIT compiler do?
In simple words

Java starts by interpreting bytecode — slow but instant to start. Meanwhile it watches which methods run often ("hot" methods) and compiles those into native machine code. That's the JIT: Just-In-Time compilation.

Because it watches real behaviour, it can optimise better than an ahead-of-time compiler: it knows which branch is usually taken and which method is always called.

What it does

  • Tiered compilation: C1 compiles quickly with light optimisation, then C2 recompiles the hottest methods aggressively.
  • Inlining (removing call overhead), loop unrolling, escape analysis (an object that never leaves a method may be allocated on the stack or eliminated entirely), lock elision.
  • Deoptimization: C2 speculates ("this call site is always an ArrayList"). If a different type appears, it throws away the compiled code and falls back to the interpreter.
Say this

"This is why the first minutes after a deploy are slower, and why you never benchmark with a hand-written timing loop — you're measuring the interpreter. I use JMH, which handles warm-up and dead-code elimination properly."

Explain the Java Memory Model, volatile and happens-before.
In simple words

Each CPU core has its own cache. If thread A writes a variable, thread B might keep reading a stale copy from its own cache forever — and the compiler is even allowed to reorder your statements for speed.

The Java Memory Model defines the rules for when a write by one thread is guaranteed to be visible to another. volatile is the simplest way to demand that guarantee for one variable.

class Worker {
    private volatile boolean running = true;   // without volatile this loop may NEVER exit

    public void stop(){ running = false; }
    public void loop(){ while (running) { doWork(); } }
}

happens-before — the rules that create guarantees

  • Statements within a single thread, in program order.
  • Releasing a lock happens-before any later acquisition of the same lock.
  • A volatile write happens-before every later read of that variable.
  • Thread.start() happens-before everything the new thread does.
  • Everything a thread does happens-before another thread's successful join().
  • final fields are safely published at the end of the constructor.
volatile does NOT give atomicity

count++ is really read, add, write — three steps. Two threads can read the same value and both write back the same result, losing an increment. volatile fixes visibility, not this. Use AtomicInteger, LongAdder, or a lock.

Say this

"volatile gives visibility and ordering, not atomicity. For a counter I use LongAdder under contention; for anything spanning multiple fields I need a lock, because the invariant is across variables."

Which tools do you use to diagnose a slow or unhealthy JVM?
In simple words

Have a short, ordered list. The order matters more than the tool names — it shows you don't guess.

My order

  1. Dashboards first (Micrometer → Prometheus → Grafana): is it CPU, GC, thread-pool saturation, DB pool, or a downstream? Alert on p99, never on the average.
  2. Thread dumpjcmd <pid> Thread.print, three dumps 10 seconds apart. Many threads stuck in the same frame = your bottleneck.
  3. GC log — pause frequency and duration, old-gen trend.
  4. JFR + JDK Mission Control — near-zero-overhead profiling you can safely enable in production: jcmd <pid> JFR.start settings=profile duration=120s filename=x.jfr.
  5. async-profiler — CPU and allocation flame graphs without safepoint bias.
  6. Eclipse MAT — for heap dumps.
  7. Distributed tracing (OpenTelemetry) when more than one service is involved.

Reading a thread dump

  • BLOCKED + "waiting to lock <0x...>" → find who owns that monitor.
  • "Found one Java-level deadlock" → the JVM has told you outright.
  • WAITING on getTask() = an idle pool thread, harmless.
  • Thread count growing between dumps = a thread leak.
Say this

"Dashboards → thread dump → GC log → JFR. Then fix the single top contributor, add a regression test and an alert so it can't silently come back."

What is a safepoint and why can it cause a latency spike?
In simple words

Some JVM operations (garbage collection, taking a thread dump, deoptimization) need every application thread to pause at a safe point first. Threads only check "should I pause?" at certain places in the code.

If one thread is in a long tight loop without such a check, everyone else waits for it — you get a pause with no GC to blame.

Symptom: a 400ms stall, but the GC log shows nothing unusual. Enable -Xlog:safepoint and look at "time to safepoint".

Say this

"If pauses don't correlate with GC, I check safepoint logs — long counted loops or huge array copies delay time-to-safepoint and stall the whole JVM."

6. Concurrency & Multithreading

Expect 20–30 minutes here. Always propose the highest-level abstraction that solves the problem.

Thread lifecycle, and how do you create threads today?
In simple words

A thread moves through: NEW (created, not started) → RUNNABLE (eligible to run) → BLOCKED (waiting for a lock) / WAITING (waiting for a signal) / TIMED_WAITING (sleep, wait with timeout) → TERMINATED.

One detail that trips people: Java's RUNNABLE covers both "actually running on a CPU" and "blocked in a network read" — the JVM can't tell the difference.

How to create work, worst to best

  1. extends Thread — wastes your one inheritance slot. Avoid.
  2. implements Runnable — better, but you're still managing threads by hand.
  3. Callable + ExecutorService — pooled, returns a value, handles exceptions.
  4. CompletableFuture — composable async pipelines.
  5. Virtual threads / structured concurrency (Java 21) — cheap thread-per-task.
Say this

"In application code I never call new Thread(). I submit tasks to a named, bounded, managed executor so it's observable in a thread dump and shuts down cleanly."

synchronized vs ReentrantLock vs ReadWriteLock.
In simple words

synchronized is the built-in lock: simple, and the JVM releases it automatically even if you throw. ReentrantLock is a lock object you control manually — more power (timeouts, interruption, fairness), more responsibility (you must unlock).

ReadWriteLock lets many readers OR one writer — useful when reads massively outnumber writes.

synchronizedReentrantLock
ReleaseAutomaticManual — must be in finally
Try with timeouttryLock(2, SECONDS)
InterruptiblelockInterruptibly()
Fairness optionnew ReentrantLock(true)
Multiple wait sets1 implicitMany Conditions
// ReentrantLock - ALWAYS this exact shape
lock.lock();
try {
    // critical section
} finally {
    lock.unlock();      // if this isn't in finally, an exception deadlocks your app
}

// tryLock to avoid waiting forever
if (lock.tryLock(2, TimeUnit.SECONDS)) {
    try { ... } finally { lock.unlock(); }
} else {
    metrics.increment("lock.timeout");   // degrade gracefully instead of hanging
}
Say this

"Default to synchronized — the JIT can optimise it and you can't forget to release it. I reach for ReentrantLock when I need a timeout, interruption or multiple conditions. And with virtual threads I now prefer ReentrantLock because synchronized can pin the carrier thread."

volatile vs Atomic vs synchronized — which one when?
In simple words

Three levels of protection:

  • volatile — "everyone sees the latest value of this one variable". No atomicity.
  • Atomic classes — "read-modify-write this one variable atomically, without a lock" (uses a CPU compare-and-set instruction).
  • synchronized / Lock — "keep these several variables consistent with each other".
// volatile: a stop flag - visibility only
private volatile boolean running = true;

// atomic: a counter - atomic read-modify-write, no lock
private final AtomicInteger hits = new AtomicInteger();
hits.incrementAndGet();
hits.updateAndGet(v -> Math.min(v + 1, MAX));

// LongAdder: better than AtomicLong under HIGH contention (striped internally)
private final LongAdder requests = new LongAdder();
requests.increment();

// lock: an invariant across TWO fields
synchronized void transfer(int amt){ this.from -= amt; this.to += amt; }

How CAS works — the mental model

int prev, next;
do {
    prev = value.get();
    next = compute(prev);
} while (!value.compareAndSet(prev, next));   // retry if someone else changed it first

Mentioning the ABA problem earns credit: a value changes A→B→A, so CAS thinks nothing happened. AtomicStampedReference adds a version stamp to detect it.

Say this

"volatile for a flag, Atomic/LongAdder for a single counter, a lock when the invariant spans multiple fields. Most concurrency bugs I've fixed were someone using volatile where they needed atomicity."

How do you size and configure a thread pool?
In simple words

A thread pool is a fixed set of workers pulling tasks off a queue. The three things that go wrong are: too few threads (slow), too many threads (context-switching and memory), and an unbounded queue (memory grows silently until OOM, and the caller never learns the system is overloaded).

ThreadPoolExecutor pool = new ThreadPoolExecutor(
    8, 16,                                  // core, max threads
    60L, TimeUnit.SECONDS,                  // idle timeout for threads above core
    new ArrayBlockingQueue<>(1000),         // BOUNDED - this is the important part
    new CustomizableThreadFactory("report-"),   // NAMED threads - readable dumps
    new ThreadPoolExecutor.CallerRunsPolicy()); // when full, the submitter runs it
                                                // -> automatic back-pressure

Sizing

  • CPU-bound work: roughly cores + 1.
  • I/O-bound: cores × (1 + waitTime / serviceTime).
  • In practice: size it to the downstream limit. If the DB pool allows 20 connections, 200 threads just means 180 threads queueing.
Why I avoid the Executors factory methods

Executors.newFixedThreadPool() uses an unbounded LinkedBlockingQueue — under load it silently buffers millions of tasks until the JVM dies, and callers never feel the pressure. newCachedThreadPool() creates unbounded threads. Always build ThreadPoolExecutor explicitly.

Shutdown

pool.shutdown();                                     // stop accepting new tasks
if (!pool.awaitTermination(30, TimeUnit.SECONDS))    // let running tasks finish
    pool.shutdownNow();                              // then interrupt them
Say this

"Bounded queue, named threads, an explicit rejection policy, and sized to the downstream constraint. CallerRunsPolicy is my default because it throttles the producer instead of dropping work."

Future vs CompletableFuture — build an async pipeline.
In simple words

Future.get() is a dead end: it blocks your thread until the answer arrives, and you can't chain anything onto it.

CompletableFuture is a promise you can build a pipeline on: "when this finishes, do that; combine it with this other one; if it fails, fall back to that; give up after 2 seconds."

CompletableFuture<Profile> profile =
    CompletableFuture.supplyAsync(() -> userClient.get(id), ioPool)      // task 1
        .thenCombine(                                                    // run in parallel
            CompletableFuture.supplyAsync(() -> prefClient.get(id), ioPool),
            Profile::new)                                                // merge results
        .orTimeout(2, TimeUnit.SECONDS)                                  // give up after 2s
        .exceptionally(ex -> Profile.fallback(id));                      // graceful degrade

// wait for several
CompletableFuture.allOf(f1, f2, f3).join();

The methods worth memorising

MethodMeaning
thenApplytransform the result (like map)
thenComposechain another future (like flatMap) — avoids CF<CF<T>>
thenCombinemerge two independent futures
allOf / anyOfwait for all / first
exceptionally / handlerecover from failure
Two traps
  • Without an explicit executor, it uses the common ForkJoinPool — blocking there affects the whole JVM.
  • Non-Async variants may run on whichever thread completed the previous stage. Pass an executor for predictability.
Say this

"I always pass my own executor and always set a timeout with a fallback. An async pipeline without a timeout just moves the hang somewhere harder to see."

What is a deadlock? How do you detect and prevent it?
In simple words

Two threads each holding something the other needs, and neither will let go. Thread A holds lock 1 and wants lock 2; thread B holds lock 2 and wants lock 1. Both wait forever.

Everyday version: two people in a corridor, each stepping aside in the same direction forever.

Prevention — break the circular wait

The most reliable fix is a global lock ordering: always acquire locks in the same order, everywhere.

// DEADLOCK-PRONE: transfer(a,b) and transfer(b,a) at the same time
synchronized (from) { synchronized (to) { ... } }

// SAFE: always lock the lower account id first
void transfer(Account a, Account b, BigDecimal amt){
    Account first  = a.getId() < b.getId() ? a : b;
    Account second = a.getId() < b.getId() ? b : a;
    synchronized (first) {
        synchronized (second) { a.debit(amt); b.credit(amt); }
    }
}

Other prevention: use tryLock with a timeout and back off; hold locks for as short a time as possible; never call unknown/foreign code while holding a lock (it might take another lock).

Detection

jcmd <pid> Thread.print        # prints "Found one Java-level deadlock" with both stacks

Related terms: livelock (threads keep reacting to each other but make no progress) and starvation (one thread never gets its turn).

Say this

"Consistent lock ordering is the fix that scales. When it can't be guaranteed, tryLock with a timeout plus a metric on lock failures — because a timeout you don't measure is just a slower deadlock."

Implement producer–consumer. Why while, not if, around wait()?
In simple words

One or more threads produce work; others consume it. The consumer must wait when there's nothing to do, and the producer must wait when the buffer is full. BlockingQueue does all of this for you — that's the answer you should give first.

// THE PRACTICAL ANSWER
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100);

// producer
queue.put(task);      // blocks if full  -> natural back-pressure

// consumer
Task t = queue.take(); // blocks if empty -> no busy-waiting
// THE "SHOW ME WAIT/NOTIFY" ANSWER
synchronized (lock) {
    while (buffer.isEmpty()) {     // WHILE, not if
        lock.wait();               // releases the lock and sleeps
    }
    Task t = buffer.poll();
    lock.notifyAll();              // wake producers waiting for space
}
Why while and not if?

Two reasons. (1) Spurious wakeups — the JVM is permitted to wake a thread with no notify at all. (2) With notifyAll, several waiters wake up, but by the time yours runs, another may already have taken the item. while re-checks the condition; if would proceed on a false assumption.

Also: wait() releases the monitor; Thread.sleep() does not — sleeping while holding a lock blocks everyone.

Say this

"In real code I use a BlockingQueue — it gives correct back-pressure with no hand-written wait/notify. I only write wait/notify in an interview or in low-level library code."

CountDownLatch vs CyclicBarrier vs Semaphore.
In simple words
  • CountDownLatch — a one-time gate. "Wait until these 5 things are done." Cannot be reset. Like a race start that fires once.
  • CyclicBarrier — threads wait for each other, then all continue, and it can be reused. Like a group hike where everyone regroups at each checkpoint.
  • Semaphore — a fixed number of permits. Like 3 parking spaces: the 4th car waits. This is how you rate-limit or build a bulkhead.
// Latch: main thread waits for 5 init tasks
CountDownLatch ready = new CountDownLatch(5);
// worker: ready.countDown();
ready.await();

// Semaphore: never more than 10 concurrent calls to a fragile downstream
Semaphore permits = new Semaphore(10);
permits.acquire();
try { legacyClient.call(); } finally { permits.release(); }   // release in finally!
Say this

"I use Semaphore as a bulkhead — capping concurrency to a fragile downstream so one slow dependency can't consume every thread in the service."

Write a thread-safe singleton and explain double-checked locking.
In simple words

You want exactly one instance, created lazily, without paying for a lock on every access. There are three good ways and one famously subtle one.

// 1. BEST - enum. Thread-safe, and immune to reflection and serialization attacks.
public enum Config { INSTANCE; public String url(){ return "..."; } }

// 2. Holder idiom - lazy, no synchronization after initialization.
public class Config {
    private Config(){}
    private static class Holder { static final Config INSTANCE = new Config(); }
    public static Config get(){ return Holder.INSTANCE; }   // JVM guarantees class init is thread-safe
}

// 3. Double-checked locking - 'volatile' is MANDATORY
public class Config {
    private static volatile Config instance;
    public static Config get(){
        Config local = instance;              // read the volatile once (small optimisation)
        if (local == null) {
            synchronized (Config.class) {
                local = instance;
                if (local == null) instance = local = new Config();
            }
        }
        return local;
    }
}
Why volatile is required — the classic question

instance = new Config() is not one step. It's: (1) allocate memory, (2) run the constructor, (3) assign the reference. The JVM is allowed to reorder 2 and 3. Without volatile, another thread can see a non-null but half-constructed object and use it. volatile forbids that reordering.

Say this

"In a Spring application the container gives me singletons, so I only hand-roll this in library code — and then I use the enum or holder idiom rather than double-checked locking."

What is ThreadLocal and when is it dangerous?
In simple words

ThreadLocal gives each thread its own private copy of a variable. It's how a request's user, trace id, or transaction is carried around without passing it as a parameter through 20 method calls.

// Typical use: a non-thread-safe formatter, one per thread
private static final ThreadLocal<SimpleDateFormat> FMT =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

try {
    return FMT.get().format(date);
} finally {
    FMT.remove();     // essential in a pooled thread
}

You already use it indirectly: Spring's SecurityContextHolder, SLF4J's MDC for correlation ids, and the JPA EntityManager binding.

The three dangers

  • Memory leak in pooled threads if you never remove() (see the references question).
  • It does not propagate to threads you spawn — an @Async method loses the security context and the trace id unless you use a TaskDecorator or Spring's delegating executors.
  • With millions of virtual threads, per-thread copies become expensive — Java 21's ScopedValue is the successor.
Say this

"Great for request-scoped context, but always paired with a remove() in a filter's finally block, and always with a TaskDecorator if the work moves to another thread."

How do you handle thread interruption correctly?
In simple words

Java has no safe way to kill a thread. Interruption is a polite request: it sets a flag, and well-behaved code notices and stops. Blocking methods like sleep, wait and take throw InterruptedException — and importantly, they clear the flag when they do.

try {
    queue.take();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // RESTORE the flag you just consumed
    return;                               // then stop, or rethrow
}

// In a long loop, check periodically
while (!Thread.currentThread().isInterrupted()) { processChunk(); }
Review blocker

Catching InterruptedException and doing nothing (or just logging). It breaks shutdownNow(), cancellation and graceful shutdown — your pods then get SIGKILLed during every deploy.

Say this

"Either rethrow it or restore the flag. Swallowing it silently is one of the specific things I look for in code review."

7. Exceptions & Design Principles

Checked vs unchecked exceptions — what's your stance?
In simple words

Checked exceptions must be declared or caught — the compiler forces you. Unchecked (anything extending RuntimeException) can propagate silently.

The design intent was: checked = "the caller can recover from this", unchecked = "this is a bug". In practice checked exceptions produce a lot of noisy wrap-and-rethrow code.

My position for a Spring service

Use unchecked for almost everything. That's the direction the ecosystem went too: Spring deliberately wraps SQLException (checked) into the unchecked DataAccessException hierarchy, and @Transactional rolls back on unchecked exceptions by default. Use checked only when the caller has a genuinely different recovery path.

Rules I enforce in review

  • Never catch (Exception e) {} — an empty catch block is a blocker.
  • Never throws Exception on a public API.
  • Always preserve the cause: throw new OrderFailedException("id=" + id, e).
  • Log or rethrow, not both — double logging makes production noise.
  • Throw domain exceptions from the service layer (InsufficientFundsException), not technical ones.
  • Don't use exceptions for control flow — building a stack trace is expensive.
Say this

"Unchecked by default, translated to a domain exception at the service boundary, and mapped to an HTTP status in one central @RestControllerAdvice so the error contract is consistent."

What happens if finally contains a return?
In simple words

A return or throw in finally overrides whatever the try block was going to do — including silently discarding an exception. It's one of the nastiest bug shapes in Java because the exception simply vanishes.

// This method NEVER throws, and always returns 2. The exception disappears.
int broken(){
    try { throw new RuntimeException("boom"); }
    finally { return 2; }
}

This is exactly why try-with-resources exists: if both the body and close() throw, it keeps the original exception and attaches the other as a suppressed exception (e.getSuppressed()) instead of losing it.

Say this

"A return inside finally is a code-review blocker for me — it silently swallows exceptions. try-with-resources solves the same cleanup problem without the hazard."

Explain SOLID with a violation you actually fixed.
In simple words, one line each
  • Single Responsibility — a class should have one reason to change.
  • Open/Closed — you should be able to add behaviour without editing existing code.
  • Liskov Substitution — a subclass must be usable anywhere the parent is, without surprises.
  • Interface Segregation — many small interfaces beat one fat one.
  • Dependency Inversion — depend on abstractions, not concrete classes.

Concrete examples

  • SRP violation: a ReportService that queried the DB, formatted HTML and sent email. A template change risked breaking the SQL. Split into three classes.
  • OCP violation: a growing switch (paymentType). Replaced with a Map<PaymentType, PaymentHandler> assembled from injected beans — adding a payment type became adding a class.
  • LSP violation: a ReadOnlyList subclass that threw on add(). Anything expecting a List broke.
  • DIP: this is literally what Spring DI gives you — and it's why unit tests don't need a database.
// Open/Closed via Spring DI - the OCP fix in real code
public interface PaymentHandler { PaymentType type(); void handle(Payment p); }

@Service
public class PaymentRouter {
    private final Map<PaymentType, PaymentHandler> handlers;

    public PaymentRouter(List<PaymentHandler> all){          // Spring injects every impl
        this.handlers = all.stream().collect(toMap(PaymentHandler::type, h -> h));
    }
    public void route(Payment p){
        handlers.getOrDefault(p.getType(), unsupported).handle(p);
    }
}
Say this

"The one I invoke most in review is Single Responsibility, because it's the practical test for 'should this be two classes?'. Open/Closed is the one I use when a switch statement starts growing."

Which design patterns appear inside Spring and Angular?
In simple words

Don't recite the GoF book. Name the pattern and immediately point at where you've seen it — that proves you recognise patterns in real code rather than memorised them.

PatternWhere you've already used it
SingletonDefault Spring bean scope; Angular providedIn:'root'
FactoryBeanFactory, Calendar.getInstance()
ProxySpring AOP, @Transactional, @Cacheable, JPA lazy loading
Template MethodJdbcTemplate, RestTemplate
BuilderLombok @Builder, HttpRequest.newBuilder()
StrategyInjecting List<Validator>; Angular interceptors
ObserverSpring ApplicationEvent; RxJS Observable
DecoratorBufferedInputStream wrapping a stream
Chain of ResponsibilityServlet filters; Spring Security filter chain
AdapterMapStruct mappers; HandlerAdapter
Say this

"The one I write by hand most is Strategy, because Spring makes it free — inject a List or Map of an interface and the if/else chain disappears."

Why composition over inheritance?
In simple words

Inheritance says "a Car is a Vehicle". Composition says "a Car has an Engine".

Inheritance ties you permanently to the parent's internals: change the parent, break the children (the "fragile base class" problem). Composition just holds a reference you can swap at runtime and mock in tests.

Java's own Stack extends Vector is the standard cautionary tale: because it inherited everything, stack.insertElementAt(0, x) is legal and breaks LIFO semantics entirely.

Say this

"Inheritance for genuine 'is-a' with a stable contract, composition for everything else. Composition also forces me to define the narrow interface I actually need, which makes testing easier."

What is idempotency and where does it matter?
In simple words

An operation is idempotent if doing it twice has the same effect as doing it once. "Set the status to SHIPPED" is idempotent. "Add ₹100 to the balance" is not.

This matters because in a distributed system everything gets retried — the user double-clicks, the gateway retries a timeout, Kafka redelivers a message. Without idempotency you get duplicate orders and double charges.

// Client sends a unique key; the server stores it and returns the SAME result on a repeat
@PostMapping("/payments")
ResponseEntity<PaymentDto> pay(@RequestHeader("Idempotency-Key") String key,
                              @RequestBody PayRequest req){

    Optional<IdempotentResult> existing = store.find(key);
    if (existing.isPresent()) return ok(existing.get().response());   // replay, don't re-charge

    PaymentDto result = service.charge(req);
    store.save(key, result);       // unique constraint on 'key' makes this race-safe
    return ok(result);
}

For Kafka consumers: make the handler naturally idempotent (upsert by business key) or record processed event ids in the same transaction as the state change.

Say this

"HTTP-wise GET, PUT and DELETE are idempotent and POST isn't — so any POST that can be retried gets an Idempotency-Key backed by a unique constraint. That constraint is what makes it safe under concurrency, not the check."

What does clean code mean to you, practically?
In simple words

Code that the next person — including future you at 2am during an incident — can read, change and test without fear.

My concrete checklist

  • Names state intent; no data, tmp, process().
  • A method does one thing at one level of abstraction.
  • Guard clauses instead of nested if pyramids.
  • No magic numbers or strings.
  • Return empty collections, not null.
  • No business logic in controllers.
  • Tests assert behaviour, not implementation.
  • Logs carry a correlation id and no PII or secrets.
  • The pull request is small enough to review properly (<400 lines).
Say this

"The test I apply is: could a new joiner change this safely in six months without asking me? If not, the problem is usually unclear naming or a method doing three things."

8. Spring Core & Spring Boot

What is IoC and DI, and which injection style do you use?
In simple words

Normally your code creates what it needs: new OrderRepository(). With Inversion of Control, you stop doing that — the Spring container creates the objects and hands them to you. Dependency Injection is the delivery mechanism.

Analogy: instead of going out and buying your own ingredients, someone delivers exactly what your recipe asks for. That means you can be given real ingredients in production and fake ones in a test — which is the whole point.

@Service
public class OrderService {
    private final OrderRepository repo;      // final = guaranteed set, never reassigned
    private final PricingClient pricing;

    // Constructor injection - no @Autowired needed for a single constructor
    public OrderService(OrderRepository repo, PricingClient pricing) {
        this.repo = repo;
        this.pricing = pricing;
    }
}

// In a unit test - no Spring, no database, instant
var service = new OrderService(mockRepo, mockPricing);

Why constructor injection, always

  • Dependencies can be final → immutable, thread-safe.
  • The object is never in a half-built state.
  • Missing beans fail at startup, not on the first request at 3am.
  • A constructor with 8 parameters is visibly ugly — it tells you the class does too much. Field injection hides that.
  • You can construct it in a plain unit test without a Spring context.
Say this

"Constructor injection always, field injection never — I'd flag @Autowired on a field in review because it hides dependencies and prevents the field being final."

Explain the Spring bean lifecycle.
In simple words

Spring doesn't just call new. It runs a defined pipeline: read the definitions, build the object, inject dependencies, let post-processors wrap it, call your init method, and eventually call your destroy method on shutdown.

The step worth remembering is where proxies are created — because that explains the most common Spring bug (self-invocation).

The pipeline

  1. Bean definitions loaded (component scan, @Bean methods). BeanFactoryPostProcessors can still edit the definitions — this is where ${property} placeholders get resolved.
  2. Instantiate the object.
  3. Inject dependencies.
  4. *Aware callbacks (ApplicationContextAware, etc.).
  5. BeanPostProcessor.postProcessBeforeInitialization
  6. @PostConstructafterPropertiesSet() → custom initMethod
  7. BeanPostProcessor.postProcessAfterInitializationAOP proxies are created HERE, wrapping your bean
  8. Bean is in use.
  9. Shutdown: @PreDestroydestroy() → custom destroyMethod (singletons only — Spring does not destroy prototypes).
Say this

"The key insight is step 7: what gets injected into other beans is often a proxy wrapping my class, not my class. That single fact explains why @Transactional and @Async don't work on internal method calls."

What are bean scopes, and what's the tricky one?
In simple words

Singleton (the default) = one shared instance for the whole application. Prototype = a brand-new object every time it's requested. Plus web scopes: request (one per HTTP request) and session (one per user session).

The trap they ask about

Inject a prototype bean into a singleton and you get one instance forever — because the singleton is only created once, so injection only happens once. The prototype scope is silently ignored.

// Fix 1: ask the container each time
private final ObjectProvider<Draft> drafts;
Draft d = drafts.getObject();          // new instance per call

// Fix 2: a scoped proxy - Spring injects a proxy that resolves per request
@Bean @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public RequestContext requestContext(){ return new RequestContext(); }
The other thing to say

Because a singleton bean serves all concurrent requests on all threads, it must be stateless (or explicitly thread-safe). A mutable instance field on a @Service is a data-corruption bug waiting to happen — and it's something I check for in review.

Say this

"Singleton by default, and my rule is that singleton beans hold no mutable state. Request-scoped data goes in the method parameters or in a request-scoped bean accessed through a scoped proxy."

@Component vs @Bean vs @Service vs @Repository.
In simple words

@Component goes on your own class and Spring finds it by scanning. @Bean goes on a method inside a config class and is used for classes you can't annotate — third-party ones — or when creating the object needs logic.

@Service, @Repository and @Controller are all @Component underneath; they mainly express intent.

@Configuration
public class ClientConfig {
    @Bean                                              // third-party class - can't annotate it
    RestClient pricingClient(RestClient.Builder b, @Value("${pricing.url}") String url){
        return b.baseUrl(url).build();
    }
}

The differences that are real

  • @Repository adds exception translation — vendor-specific persistence exceptions become Spring's DataAccessException hierarchy, so your service layer isn't coupled to Hibernate or JDBC error codes.
  • @RestController = @Controller + @ResponseBody, and it's what the MVC handler mapping looks for.
  • The layer annotations also let you write AOP pointcuts per layer.
A detail worth dropping

A @Configuration class is CGLIB-proxied, so calling otherBean() from inside another @Bean method returns the same singleton. With @Configuration(proxyBeanMethods = false) (or on a plain @Component), the same call would create a new object each time — a subtle source of duplicate-bean bugs.

Say this

"@Component for my classes, @Bean for third-party ones. @Repository genuinely earns its place because of exception translation."

How does Spring Boot auto-configuration work?
In simple words

Spring Boot looks at what's on your classpath and configures sensible defaults. Add the JPA starter → you get a DataSource, an EntityManager and a transaction manager without writing any config. Add H2 → it configures an in-memory database.

Crucially, every auto-configuration is conditional and backs off if you define your own bean. That's the "convention, with an easy override" model.

Step by step

  1. @SpringBootApplication = @SpringBootConfiguration + @ComponentScan + @EnableAutoConfiguration.
  2. @EnableAutoConfiguration imports a selector that reads a list of candidate config classes from every jar's META-INF/spring/…AutoConfiguration.imports file.
  3. Each candidate is filtered by conditions: @ConditionalOnClass (is this library present?), @ConditionalOnMissingBean (did the user already define one?), @ConditionalOnProperty, @ConditionalOnWebApplication.
  4. Auto-configurations are processed after your beans, so your bean always wins.
# See exactly what matched and, more usefully, what did NOT match and why
java -jar app.jar --debug          # prints the CONDITIONS EVALUATION REPORT
# or, at runtime:  GET /actuator/conditions

# turn one off
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
Say this

"When something is configured that I didn't ask for, I run with --debug and read the conditions report — it tells me which auto-configuration matched and on which condition. That's usually a 2-minute fix instead of an hour of guessing."

How do you manage configuration and profiles across environments?
In simple words

Same jar, different settings per environment. Spring reads properties from many places and layers them, with the most specific source winning.

// Type-safe config - validated at STARTUP, not on first use
@ConfigurationProperties(prefix = "pricing")
@Validated
public record PricingProps(
    @NotBlank String url,
    @Positive int timeoutMs,
    boolean cacheEnabled) {}

// application.yml
pricing:
  url: https://pricing.internal
  timeout-ms: 2000        # relaxed binding: timeout-ms -> timeoutMs

I prefer this over scattered @Value("${...}") because it's type-safe, validated, grouped, documented and easy to inject into a test.

Property precedence (highest wins)

  1. Command-line arguments
  2. SPRING_APPLICATION_JSON
  3. OS environment variables (how Kubernetes injects config)
  4. External application-{profile}.yml next to the jar
  5. Packaged application-{profile}.yml
  6. Packaged application.yml
Say this

"Profiles for environment differences, @ConfigurationProperties for type safety, and secrets from Vault or Kubernetes secrets injected as environment variables — never committed, and never in application.yml."

What is AOP, and what are its limitations?
In simple words

Some concerns — logging, security, transactions, metrics, retries — apply to many methods across many classes. Copying that code everywhere is noise. AOP lets you write it once and declare where it applies.

Spring implements this by wrapping your bean in a proxy: callers actually talk to the proxy, which runs the extra behaviour and then calls your real method.

@Aspect @Component
public class AuditAspect {

    @Around("@annotation(audited)")          // applies to any method annotated @Audited
    public Object audit(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
        long start = System.nanoTime();
        try {
            Object result = pjp.proceed();   // call the real method
            log.info("op={} status=OK ms={}", audited.value(), ms(start));
            return result;
        } catch (Exception e) {
            log.warn("op={} status=FAIL err={}", audited.value(), e.toString());
            throw e;
        }
    }
}

Vocabulary: aspect (the class), join point (a method execution), pointcut (the expression selecting which ones), advice (@Before, @After, @Around, @AfterThrowing).

The limitation you MUST know

Because it's a proxy, advice only runs when the call goes through the proxy. It does NOT apply to:

  • Self-invocation — one method in the class calling another via this.
  • private or final methods (CGLIB can't override them).
  • Calls made before the bean is fully proxied.
@Service
public class OrderService {
    public void placeAll(List<Order> orders){
        orders.forEach(this::place);      // 'this' bypasses the proxy!
    }                                     // -> @Transactional on place() does NOTHING

    @Transactional
    public void place(Order o){ ... }
}
// Fixes: move place() to another bean, inject self with @Lazy,
// use AopContext.currentProxy(), or use AspectJ weaving.
Say this

"Spring AOP is proxy-based, so self-invocation silently skips the advice. That's the number one cause of 'my @Transactional isn't working' — and it's the first thing I check."

JDK dynamic proxy vs CGLIB — which does Spring use?
In simple words

Two ways to build the wrapper. A JDK dynamic proxy creates a new class implementing the same interfaces — so the bean must have an interface. CGLIB creates a subclass of your actual class — so the class and its methods must not be final.

Spring Boot defaults to CGLIB (proxyTargetClass=true) so proxying works whether or not you wrote an interface. Consequences: you can inject by concrete type, but final classes/methods can't be advised, and you shouldn't rely on field state read through the proxy.

Say this

"Boot uses CGLIB by default. It's worth knowing because a final service method silently loses its transaction — the annotation is simply ignored."

How do you handle circular dependencies?
In simple words

Bean A needs B, and B needs A. Neither can be built first. Since Spring Boot 2.6 this fails at startup by default rather than being quietly worked around.

Fix in this order

  1. Redesign — extract the shared logic into a third bean, or publish an application event so A doesn't need to call B directly. This is nearly always the right answer; a cycle is a layering smell.
  2. @Lazy on one side — Spring injects a proxy and resolves the real bean on first use.
  3. Setter injection.
  4. spring.main.allow-circular-references=true — an escape hatch to unblock a release, not a fix.
Say this

"I treat a cycle as a design signal rather than a configuration problem. Nine times out of ten there's a third responsibility hiding inside one of the two beans."

Multiple beans of the same type — @Qualifier, @Primary, and injecting all of them.
public interface NotificationSender { String channel(); void send(Msg m); }

@Component class EmailSender implements NotificationSender { ... }
@Component @Primary class SmsSender implements NotificationSender { ... }

@Service
class AlertService {
    // explicit choice wins over @Primary
    AlertService(@Qualifier("emailSender") NotificationSender sender) { }
}

// Inject ALL implementations - this is the Strategy pattern, for free
@Service
class Fanout {
    private final Map<String, NotificationSender> byChannel;
    Fanout(List<NotificationSender> all){
        this.byChannel = all.stream().collect(toMap(NotificationSender::channel, s -> s));
    }
}

Resolution order: @Qualifier@Primary → a bean whose name matches the parameter name → otherwise NoUniqueBeanDefinitionException.

Say this

"Injecting a List or Map of an interface is my favourite Spring feature — adding a new handler means adding a class, with zero changes to the router."

What does Actuator give you and how do you secure it?
In simple words

Actuator adds ready-made HTTP endpoints that tell you what's happening inside a running application — health, metrics, configuration, thread dumps — without you writing any of it.

EndpointWhy it's useful
/healthKubernetes liveness/readiness probes
/metrics, /prometheusJVM, HTTP, HikariCP and custom metrics
/loggersChange log level at runtime — invaluable during an incident
/threaddump, /heapdumpDiagnostics without shell access
/conditions, /mappings, /envExplain what Boot configured and why
management.endpoints.web.exposure.include=health,info,prometheus,loggers
management.endpoint.health.probes.enabled=true       # liveness + readiness groups
management.server.port=9090                          # separate port, not publicly routed
Security

/env and /heapdump can expose secrets and customer data. Expose only what you need, run on a management port that isn't in the public ingress, and require an authenticated admin role for anything beyond health and info.

Say this

"/actuator/loggers has saved incidents for me — turning on DEBUG for one package on one pod for two minutes, without a redeploy."

How do you add custom metrics and a custom health check?
@Component
class PricingHealthIndicator implements HealthIndicator {
    public Health health(){
        try { client.ping(); return Health.up().withDetail("latencyMs", ms).build(); }
        catch (Exception e){ return Health.down(e).build(); }
    }
}

@Service
class OrderService {
    private final Counter placed;
    private final Timer   latency;

    OrderService(MeterRegistry registry){
        placed  = Counter.builder("orders.placed").tag("channel", "web").register(registry);
        latency = Timer.builder("orders.latency").publishPercentileHistogram().register(registry);
    }
    public void place(Order o){ latency.record(() -> { doPlace(o); placed.increment(); }); }
}
Cardinality warning

Never tag a metric with a user id, order id or email. Each unique tag value creates a new time series — a handful of such tags can consume gigabytes in Prometheus and take down your monitoring. Tag only with bounded values: status, channel, region.

Say this

"I add a business metric alongside the technical ones — orders placed per minute tells you about an outage faster than CPU does."

How do you do scheduled jobs and async work in Spring?
@EnableScheduling @EnableAsync
@Configuration
class AsyncConfig {
    @Bean("reportExecutor")
    Executor reportExecutor(){
        var e = new ThreadPoolTaskExecutor();
        e.setCorePoolSize(4); e.setMaxPoolSize(8); e.setQueueCapacity(500);
        e.setThreadNamePrefix("report-");
        e.setWaitForTasksToCompleteOnShutdown(true);
        e.setAwaitTerminationSeconds(30);
        e.initialize();
        return e;
    }
}

@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Kolkata")   // 2am daily
void nightlyReconcile(){ ... }

@Async("reportExecutor")
CompletableFuture<Report> build(long id){ ... }
Four gotchas
  • @Async is proxy-based → self-invocation doesn't work.
  • An @Async void method's exception disappears unless you register an AsyncUncaughtExceptionHandler. Return CompletableFuture instead.
  • @Scheduled uses a single-threaded scheduler by default — one slow job delays every other job.
  • With multiple pods, @Scheduled runs on every pod. Use ShedLock or Quartz clustering so it runs once.
Say this

"The multi-instance point matters — a nightly reconciliation job running on all 6 pods once caused duplicate settlement records for us. ShedLock with a database lock table fixed it in an hour."

9. Spring Data JPA & Hibernate

The richest source of real production bugs — and therefore of senior-level questions.

Explain the persistence context and the entity lifecycle.
In simple words

The persistence context (the EntityManager) is a workspace that tracks every entity you've loaded in the current transaction. Two things follow from that:

  • Identity: load the same row twice in one transaction and you get the same Java object.
  • Dirty checking: because it remembers the original values, it notices what you changed and writes an UPDATE at commit — you never call save().

The four states

StateMeaning
TransientJust new-ed. No id, not tracked.
ManagedInside the persistence context — changes are tracked automatically.
DetachedWas managed, but the transaction/context closed. Changes are ignored.
RemovedMarked for deletion at flush.
@Transactional
public void markShipped(long id){
    Order order = repo.findById(id).orElseThrow();   // now MANAGED
    order.setStatus(SHIPPED);                        // just change the field
}   // at commit, Hibernate compares with the snapshot and issues UPDATE. No save() needed.
merge() catches people out

merge(detachedEntity) copies the state into a managed copy and returns that. Your original object stays detached. So you must use the return value:

Order managed = em.merge(detached);    // use 'managed', not 'detached', afterwards
Say this

"Dirty checking is why an explicit save() inside a transaction is usually redundant — and why an accidental setter call inside a read-only method can write to the database. I mark read paths @Transactional(readOnly = true) partly to prevent that."

What is the N+1 problem, how do you detect it, and how do you fix it?
In simple words

You ask for 100 orders → that's 1 query. Then you loop over them and touch order.getLines() → Hibernate quietly fires one more query per order. That's 1 + 100 = 101 round trips to the database instead of 1 or 2.

It's invisible in development with 10 rows and catastrophic in production with 10,000. This is the single most common cause of "it got slow after go-live".

Four fixes — know all of them

// 1. JOIN FETCH - one query, best for a single collection
@Query("select distinct o from Order o join fetch o.lines where o.status = :s")
List<Order> findWithLines(@Param("s") Status s);

// 2. Entity graph - declarative, works with Spring Data derived queries
@EntityGraph(attributePaths = {"lines", "customer"})
List<Order> findByStatus(Status s);

// 3. Batch fetching - turns 100 queries into 2 (IN clauses of 50)
@BatchSize(size = 50)
private List<OrderLine> lines;
// or globally:
// spring.jpa.properties.hibernate.default_batch_fetch_size=50

// 4. DTO projection - fetch ONLY the columns the screen needs (usually fastest)
@Query("""
       select new com.acme.OrderSummary(o.id, o.total, c.name)
       from Order o join o.customer c where o.status = :s
       """)
List<OrderSummary> summaries(@Param("s") Status s);

How to detect it

show-sql isn't enough — you can't count 400 lines by eye. I use Hibernate statistics or a query-counting proxy (datasource-proxy / p6spy), and I put a query-count assertion in the integration test:

assertThat(queryCounter.getSelectCount()).isEqualTo(2);   // fails the build if N+1 returns
The pagination trap

join fetch on a collection combined with Pageable makes Hibernate load every row and paginate in memory — it logs HHH000104. Fix: fetch a page of ids first, then fetch the entities by those ids; or use @EntityGraph plus batch size.

Say this

"Entity graph or join fetch for the write model, DTO projections for read screens, and a query-count assertion in tests so it can't come back. I've cut an endpoint from 9 seconds to 240ms by doing exactly this."

LAZY vs EAGER — and why do you disable open-in-view?
In simple words

EAGER = "load the related data immediately, always". LAZY = "load it only if someone actually asks for it" — Hibernate gives you a placeholder proxy until then.

JPA's defaults are inconsistent: @ManyToOne and @OneToOne are EAGER, collections are LAZY. I override everything to LAZY and fetch explicitly per use case.

Why EAGER is harmful

  • It loads on every query of that entity, even when you only wanted the id.
  • It compounds: an eager chain Order → Customer → Address → Country can pull half the database for one lookup.
  • It makes performance unpredictable, because the cost is hidden in the mapping rather than the query.

The LazyInitializationException, and the wrong fix

If you access a lazy field after the transaction has closed, you get LazyInitializationException. The tempting fix is spring.jpa.open-in-view=true (on by default in Boot!), which keeps the session open for the entire HTTP request.

That's the wrong fix because it (a) hides N+1 queries — they now happen during JSON serialization, (b) holds a database connection for the whole request including view rendering, and (c) makes failures happen in the serializer where the stack trace is useless.

Say this

"I set spring.jpa.open-in-view=false explicitly on every new service. It forces the team to fetch deliberately, which surfaces N+1s at development time instead of under production load."

How does @Transactional work? Explain propagation and isolation.
In simple words

@Transactional puts a proxy around your method. The proxy opens a database transaction before your code runs, commits when it returns normally, and rolls back if it throws a runtime exception.

Propagation answers: "what if a transaction is already running?" Isolation answers: "how much can I see of what other transactions are doing?"

Propagation

ValueBehaviourWhen I use it
REQUIRED (default)Join the existing one, or start a new oneAlmost always
REQUIRES_NEWSuspend the current one, run in a brand-new transactionAn audit or failure log that must survive the caller's rollback
NESTEDSavepoint inside the current transactionPartial rollback within a batch (JDBC only)
SUPPORTS / NOT_SUPPORTED / MANDATORY / NEVERJoin if present / suspend / must exist / must not existRare

Isolation

LevelStopsNote
READ_UNCOMMITTEDnothingCan read data that later rolls back
READ_COMMITTEDdirty readsDefault in Postgres/Oracle/SQL Server
REPEATABLE_READ+ non-repeatable readsMySQL InnoDB default
SERIALIZABLE+ phantom readsSafest, lowest concurrency
The three ways it silently does nothing
  1. Self-invocation — an internal this.method() call bypasses the proxy. No transaction starts.
  2. Non-public method@Transactional on a private or final method is ignored.
  3. You caught the exception — but Spring already marked the transaction rollback-only, so the commit fails later with a confusing UnexpectedRollbackException.

Also: by default it rolls back on unchecked exceptions only. A checked exception commits unless you add rollbackFor = Exception.class.

Say this

"My rule is: transactions start at the service layer, they're short, and they never contain an HTTP call — holding a database connection while waiting on a third party is how you exhaust the connection pool."

Optimistic vs pessimistic locking.
In simple words

Optimistic = "probably nobody else is editing this; I'll check at save time." No locks are taken. Each row has a version number; the UPDATE only succeeds if the version is unchanged.

Pessimistic = "someone probably will edit this, so I'll lock the row now." Others wait.

Analogy: optimistic is editing a shared document and being told "someone else changed this, please review". Pessimistic is checking the document out so nobody else can open it.

// OPTIMISTIC - just add @Version. Hibernate does the rest.
@Entity
public class Account {
    @Id private Long id;
    @Version private long version;      // UPDATE ... SET version=6 WHERE id=? AND version=5
    private BigDecimal balance;
}
// If 0 rows are updated, someone else got there first:
// -> ObjectOptimisticLockingFailureException. Handle it: retry, or tell the user.

// PESSIMISTIC - real database lock
@Lock(LockModeType.PESSIMISTIC_WRITE)      // issues SELECT ... FOR UPDATE
@Query("select a from Account a where a.id = :id")
Account lockById(@Param("id") Long id);

Which to choose

  • Optimistic — the default. Great for user-edited screens where conflicts are rare. Scales because nothing is locked. Cost: the caller must handle the conflict.
  • Pessimistic — for genuinely hot rows (inventory count, ledger balance) where a retry loop would thrash. Cost: lock waits, deadlock risk, and you must set a lock timeout.
Say this

"Optimistic with @Version plus a bounded retry covers most cases; pessimistic only for a genuinely contended row. And in both cases I make sure the failure surfaces as a clean 409 to the UI, not a 500."

Hibernate caching: first level, second level, query cache.
In simple words
  • L1 cache = the persistence context. Always on, lives for one transaction, per-thread. Loading the same row twice hits the database once.
  • L2 cache = shared across transactions and threads, usually across the whole application (Ehcache, Redis, Hazelcast). Optional, per-entity.
  • Query cache = caches the ids a query returned. Sounds useful, usually isn't.

When each is a good idea

L2 is great for reference data that rarely changes: countries, currencies, product catalogue, config. It's dangerous for hot mutable data, and outright wrong if another application writes the same tables — Hibernate won't know to invalidate.

The query cache is invalidated by any write to any table it touches, so on a busy table it can make things slower while adding memory pressure.

Say this

"In practice I prefer an explicit application-level cache with @Cacheable plus Caffeine or Redis over Hibernate L2, because the key and the eviction rule are then visible in the code and I can reason about staleness during an incident."

Derived queries vs @Query vs Specifications — when do you use each?
// 1. DERIVED - Spring builds the query from the method NAME. Good for 1-2 criteria.
List<Order> findByCustomerIdAndStatusOrderByCreatedAtDesc(Long id, Status s);
Optional<Order> findFirstByCustomerIdOrderByCreatedAtDesc(Long id);
boolean existsByReference(String ref);
// Beyond ~3 conditions the name becomes unreadable -> switch to @Query.

// 2. @QUERY with a projection interface - fetch only 2 columns
public interface OrderView { Long getId(); BigDecimal getTotal(); }

@Query("select o.id as id, o.total as total from Order o where o.status = ?1")
List<OrderView> views(Status s);

// 3. NATIVE - for database-specific features
@Query(value = "select * from orders where payload @> :filter", nativeQuery = true)
List<Order> searchJson(@Param("filter") String filter);

// 4. MODIFYING - bulk update, bypasses the persistence context
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update Order o set o.status = :s where o.id in :ids")
int bulkUpdate(@Param("s") Status s, @Param("ids") List<Long> ids);

// 5. SPECIFICATION - for a dynamic search screen with optional filters
Specification<Order> spec = (root, query, cb) -> {
    List<Predicate> ps = new ArrayList<>();
    if (status != null) ps.add(cb.equal(root.get("status"), status));
    if (min != null)    ps.add(cb.greaterThanOrEqualTo(root.get("total"), min));
    return cb.and(ps.toArray(new Predicate[0]));
};
repo.findAll(spec, PageRequest.of(0, 20, Sort.by("createdAt").descending()));
@Modifying caution

A bulk JPQL update goes straight to the database: it skips dirty checking, skips @Version increments and skips entity callbacks, and it leaves stale objects in the persistence context — hence clearAutomatically = true.

Say this

"Derived for simple lookups, @Query with a projection for read screens, Specifications for dynamic filters, and native SQL for reporting. Choosing the right one is mostly about how many columns I actually need."

How do you map relationships correctly?
In simple words

In the database, a one-to-many relationship is stored as a foreign key on the child row. In Java you often want to navigate both ways. The side that owns the foreign key is the one Hibernate actually writes — the other side (mappedBy) is just a view.

If you only set the collection and forget the child's back-reference, the foreign key stays null and your data silently doesn't link up.

@Entity
public class Order {
    @OneToMany(mappedBy = "order",              // "the Order field on OrderLine owns the FK"
               cascade = CascadeType.ALL,
               orphanRemoval = true)
    private List<OrderLine> lines = new ArrayList<>();

    // ALWAYS provide helper methods that keep BOTH sides in sync
    public void addLine(OrderLine l){ lines.add(l); l.setOrder(this); }
    public void removeLine(OrderLine l){ lines.remove(l); l.setOrder(null); }
}

@Entity
public class OrderLine {
    @ManyToOne(fetch = FetchType.LAZY)          // override the EAGER default
    @JoinColumn(name = "order_id")              // the OWNING side - holds the FK column
    private Order order;
}

Cascade and orphanRemoval

  • cascade = ALL + orphanRemoval = true is right for a true parent–child where the child cannot exist alone (Order → OrderLine).
  • It's wrong for shared references — cascading a delete from Order to Customer would delete the customer.
  • Prefer replacing @ManyToMany with an explicit join entity as soon as the relationship needs its own attributes (e.g. quantity, added date).
Say this

"Owning side holds the FK, always keep both sides in sync with helper methods, and be very deliberate with cascade — I've seen a cascade on the wrong association delete reference data."

How do you write equals/hashCode for a JPA entity?
In simple words

Tricky, because an entity's identity changes during its life: it has no id before persist(), then it gets one. If hashCode depends on the id, an entity added to a HashSet before saving becomes unfindable after saving.

@Override public boolean equals(Object o){
    if (this == o) return true;
    if (!(o instanceof Order other)) return false;   // instanceof, NOT getClass() - proxies!
    return id != null && id.equals(other.getId());   // null id -> never equal to anything else
}

@Override public int hashCode(){
    return getClass().hashCode();     // CONSTANT - never changes as state changes
}

Why these two choices

  • instanceof instead of getClass() ==: Hibernate gives you a proxy subclass for lazy references, and getClass() would say they're different types.
  • A constant hashCode looks wrong but is correct and stable: it never changes when the id is assigned. All entities of that type land in one bucket, which is fine because you rarely put thousands in one Set.
  • Best of all: if the domain has a natural key (an order reference, a client-generated UUID), use that in both methods and the problem disappears.
Say this

"I prefer a client-generated UUID business key where the domain allows it — then equals/hashCode are stable from construction and the entity behaves like a normal object in collections."

How do you make bulk inserts fast in Hibernate?
# enable JDBC batching
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
# MySQL also needs this in the JDBC URL:  rewriteBatchedStatements=true
for (int i = 0; i < entities.size(); i++) {
    em.persist(entities.get(i));
    if (i % 50 == 0) {
        em.flush();     // send the batch to the database
        em.clear();     // detach them, so the persistence context doesn't grow to millions
    }
}
The detail that decides it

GenerationType.IDENTITY completely disables insert batching. Hibernate must execute each INSERT immediately to learn the generated key, so it can never group them. If you need bulk insert throughput, use a SEQUENCE with a pooled optimizer (allocationSize = 50) — that lets Hibernate assign 50 ids from one round trip and batch the inserts.

For truly large loads (millions of rows), drop to JdbcTemplate.batchUpdate or the database's native bulk loader (Postgres COPY) — JPA is the wrong tool at that scale.

Say this

"IDENTITY versus SEQUENCE is the thing most people miss. Switching that alone took a nightly import from 40 minutes to under 4."

How do you manage database schema changes safely?
In simple words

Schema changes should be versioned files in Git, reviewed like code, applied automatically and in the same order everywhere. That's what Flyway and Liquibase do.

# Flyway: versioned, immutable, reviewed
src/main/resources/db/migration/
    V1__create_orders.sql
    V2__add_status_index.sql
    V3__add_customer_email.sql

spring.jpa.hibernate.ddl-auto=validate      # validate in EVERY environment. Never 'update'.

ddl-auto=update is banned in production because it can't drop or rename anything, produces subtly different schemas per environment, and leaves no review trail.

Zero-downtime changes: expand → migrate → contract

  1. Expand: add the new nullable column. Old code ignores it, new code can use it.
  2. Migrate: backfill in batches; deploy code that writes both old and new.
  3. Switch reads to the new column.
  4. Contract: in a later release, drop the old column.

Also: create indexes CONCURRENTLY on Postgres, and know the lock behaviour of your DDL before running it on a 40-million-row table during business hours.

Say this

"Expand-and-contract, always backward-compatible, because during a rolling deploy the old and new versions of the application run at the same time against the same schema."

How do you size HikariCP, and what does pool exhaustion look like?
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=20            # = max, for stable latency
spring.datasource.hikari.connection-timeout=3000    # fail fast rather than hang
spring.datasource.hikari.max-lifetime=1740000       # just under the DB/proxy idle timeout
spring.datasource.hikari.leak-detection-threshold=20000
The counter-intuitive part

Bigger pools are usually slower. A common heuristic is connections ≈ (2 × cores) + disks on the database — beyond that, connections just queue inside the database instead of inside your app, while adding context-switching.

And remember to multiply by pod count: 20 pods × 20 connections = 400 connections, which exceeds most default Postgres max_connections of 100. Use PgBouncer if you need many instances.

The symptom

Connection is not available, request timed out after 30000ms. The cause is almost never "the pool is too small" — it's usually a long transaction holding a connection, typically an HTTP call inside @Transactional. Fix the transaction boundary before increasing the pool.

Say this

"When I see pool timeouts I look for what's holding connections, not for a bigger number. Raising the pool usually just moves the queue."

When would you NOT use JPA?
In simple words

JPA is excellent at loading an object graph, tracking changes and writing it back. It's a poor fit when you don't want objects at all.

  • Reporting and analytics — complex joins, window functions, aggregates. You want SQL, not entities.
  • Bulk data movement — millions of rows; use JdbcTemplate batch or a native loader.
  • Very simple services — Spring Data JDBC or JdbcClient is lighter, with no lazy loading or dirty checking to reason about.
  • When you need exact control of the SQL — jOOQ gives type-safe SQL without the ORM layer.
Say this

"We used JPA for the transactional write model and plain JdbcClient with hand-written SQL for the reporting endpoints. Being able to say 'this is the wrong tool here' is more useful than defending one for everything."

10. REST APIs & Spring Security

What makes an API RESTful? Design one properly.
In simple words

REST = treat everything as a resource with a URL, and use the standard HTTP verbs to act on it. The URL names a thing (a noun); the method says what you're doing to it.

GET    /api/v1/customers/42/orders?status=OPEN&page=0&size=20&sort=createdAt,desc
GET    /api/v1/orders/{id}
POST   /api/v1/orders                 -> 201 Created + Location header
PUT    /api/v1/orders/{id}            -> full replace  (idempotent)
PATCH  /api/v1/orders/{id}            -> partial update
DELETE /api/v1/orders/{id}            -> 204 No Content  (idempotent)

Status codes that mean something

CodeUse for
200 / 201 / 204OK / Created (+ Location) / Deleted, no body
400Malformed or invalid request
401 vs 403Not authenticated vs authenticated but not allowed
404Resource doesn't exist
409Conflict — duplicate, or optimistic lock failure
422Well-formed but semantically invalid
429Rate limited
500 vs 503Our bug vs a dependency is down

The rest of a good design

  • Versioning: /v1 in the path is pragmatic and easy to debug and cache.
  • Pagination: page/size for small sets, cursor (?after=id) for large or live-updating ones.
  • Errors: one consistent shape — RFC 7807 ProblemDetail, plus a trace id.
  • Statelessness: no server-side session state, so any instance can serve any request.
Say this

"I design the contract first as OpenAPI and review it with the BA and the Angular developer before writing code. It costs an hour and saves a week of rework — and the frontend can generate its client from it."

Walk through the Spring MVC request lifecycle.
In simple words

A request passes through a chain of gates before your controller sees it, and back out through the same chain. Knowing this order tells you exactly where to put security, logging or error handling.

  1. Servlet container → Filters (encoding, CORS, Spring Security, your trace-id filter).
  2. DispatcherServlet — the front controller.
  3. HandlerMapping finds the right controller method.
  4. HandlerInterceptor.preHandle.
  5. HandlerAdapter invokes the method: argument resolvers bind @PathVariable/@RequestParam, an HttpMessageConverter (Jackson) turns the JSON body into your DTO, and @Valid runs.
  6. The returned object is serialised back to JSON by a message converter.
  7. Any exception goes to HandlerExceptionResolver → your @RestControllerAdvice.
  8. postHandle / afterCompletion, response flushed.

Filter vs Interceptor (a common follow-up): a filter is Servlet-level — it sees every request, can wrap the request/response, but knows nothing about Spring MVC. An interceptor is Spring-level and knows which controller method will handle the request.

Say this

"Cross-cutting concerns that need the raw request go in a filter — like the correlation-id filter that populates MDC. Anything needing the handler method goes in an interceptor."

How do you do validation and global exception handling?
// 1. Declare the rules on the DTO
public record CreateOrder(
    @NotBlank String customerId,
    @NotEmpty @Valid List<LineDto> lines,      // @Valid cascades into the nested objects
    @Positive BigDecimal total) {}

// 2. Trigger them with @Valid
@PostMapping
ResponseEntity<OrderDto> create(@Valid @RequestBody CreateOrder req){ ... }

// 3. Convert failures into ONE consistent error shape - in one place
@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail onValidation(MethodArgumentNotValidException e){
        var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        pd.setProperty("errors", e.getBindingResult().getFieldErrors().stream()
            .collect(toMap(FieldError::getField, FieldError::getDefaultMessage, (a,b) -> a)));
        pd.setProperty("traceId", MDC.get("traceId"));   // so support can find the log line
        return pd;
    }

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail onNotFound(OrderNotFoundException e){
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    ProblemDetail onUnexpected(Exception e){
        log.error("unhandled", e);                        // log the stack trace SERVER-side
        return ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error");  // never leak it to the client
    }
}

Also useful: @Validated on the class for validating @RequestParams, validation groups for different create vs update rules, and a custom ConstraintValidator for domain rules.

Say this

"One advice class, one error shape, always with a trace id. Support can then quote the trace id from the user's screenshot and I find the exact request in seconds."

RestTemplate vs WebClient vs RestClient — and the one setting people forget.
In simple words

RestTemplate is the old blocking client (maintenance mode). WebClient is the reactive non-blocking one. RestClient (Spring 6.1+) is the modern blocking client with a fluent API — the right default for a normal MVC application, especially with virtual threads.

// Declarative typed client - cleanest option
@HttpExchange("/pricing")
public interface PricingClient {
    @GetExchange("/{sku}")
    Price get(@PathVariable String sku);
}

// Always configure timeouts
@Bean
RestClient pricingRestClient(RestClient.Builder builder){
    var factory = new SimpleClientHttpRequestFactory();
    factory.setConnectTimeout(1000);      // time to establish the TCP connection
    factory.setReadTimeout(2000);         // time to wait for the response
    return builder.baseUrl(url).requestFactory(factory).build();
}
The single most common production incident with HTTP clients

No timeout configured. The default is often infinite. One slow downstream then holds every request thread until your whole service stops responding — and it looks like your service is broken. Every client gets a connect timeout, a read timeout, and a circuit breaker.

Say this

"Timeouts and a circuit breaker on every outbound call, no exceptions. I've been on the incident where a third party got slow — not down, just slow — and took our service with it."

How does Spring Security work?
In simple words

Spring Security is a chain of filters in front of your application. Each filter has one job: read the token, check CSRF, apply CORS, decide if the user may proceed. If any of them rejects the request, your controller is never called.

Two separate concepts: authentication = who are you? authorization = are you allowed to do this?

@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
  return http
    .csrf(csrf -> csrf.disable())                       // stateless bearer-token API
    .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
    .cors(Customizer.withDefaults())
    .authorizeHttpRequests(a -> a
        .requestMatchers("/actuator/health", "/api/v1/public/**").permitAll()
        .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated())                  // deny by default
    .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
    .build();
}

// Method-level rules, including data ownership
@PreAuthorize("hasRole('MANAGER') or #order.ownerId == authentication.name")
public void approve(Order order){ ... }

Order matters: rules are evaluated top to bottom, first match wins, and anyRequest().authenticated() last means "deny by default" — the correct posture.

Say this

"Deny by default and authorise at the method level too, including object ownership. Route-level rules alone let an authenticated user fetch someone else's record just by changing the id — that's the most common real-world access-control bug."

JWT vs session, and how do you secure a Java + Angular app?
In simple words

Session: the server remembers who you are; the browser just holds a session id. Easy to revoke instantly, but the server has to store state (or share it between instances).

JWT: a signed token that contains the user's identity and roles. The server can verify it without any lookup — great for scaling — but you cannot cancel it before it expires without keeping a denylist.

Analogy: a session id is a cloakroom ticket (the venue has the record). A JWT is a signed festival wristband (self-contained, but you can't un-issue it).

My standard setup

  1. OAuth2/OIDC with an identity provider (Keycloak, Azure AD, Okta) — don't build auth yourself.
  2. Angular uses the Authorization Code flow with PKCE — the correct flow for a browser app, because it never holds a client secret.
  3. Short-lived access token (5–15 min) + a refresh token. Short life limits the damage if a token leaks.
  4. Store the access token in memory in Angular, not localStorage (any XSS can read localStorage). If it must persist, use an HttpOnly; Secure; SameSite=Strict cookie plus CSRF protection.
  5. An Angular HttpInterceptor attaches the token and handles 401 → refresh → retry once.
  6. Spring validates the signature, iss, aud and exp against the provider's JWKS endpoint.
Remember

A JWT payload is base64 encoded, not encrypted — anyone can read it. Never put PII, secrets or anything sensitive in the claims.

Say this

"Short-lived access tokens plus a refresh token, tokens in memory rather than localStorage, and the refresh logic in a single interceptor so no component ever thinks about auth."

What is CORS and how do you configure it?
In simple words

Browsers block a page on app.example.com from reading a response from api.example.com — that's the same-origin policy, and it protects users. CORS is the server's way of saying "this particular origin is allowed".

For anything beyond a simple GET, the browser first sends an OPTIONS preflight request asking permission, then sends the real one.

@Bean
CorsConfigurationSource corsConfigurationSource(){
    var config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.example.com"));  // NEVER "*" with credentials
    config.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE"));
    config.setAllowedHeaders(List.of("Authorization","Content-Type","X-Trace-Id"));
    config.setAllowCredentials(true);
    config.setMaxAge(3600L);                     // cache the preflight for an hour
    var source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}
Key insight

CORS is not a server-side security control. It protects browsers. A curl request or a backend client ignores it completely — so never rely on CORS for authorisation.

Say this

"In development I avoid CORS entirely with the Angular dev proxy, so /api is same-origin. In production the API is behind the same gateway domain, which also removes it."

How do you address the OWASP Top 10 in this stack?
RiskWhat I do
InjectionParameterised queries / JPA binding only. Never concatenate SQL. Allow-list any dynamic ORDER BY.
Broken access controlDeny by default; check object ownership server-side, not just the route. Hiding an Angular button is not security.
XSSAngular escapes by default; the risk is innerHTML and bypassSecurityTrustHtml. Add a Content-Security-Policy header.
CSRFNeeded for cookie auth — Spring's CSRF token cookie is read automatically by Angular's HttpClient. Not needed for pure bearer tokens.
Sensitive dataTLS everywhere, BCrypt/Argon2 for passwords, secrets in a vault, no PII in logs.
Vulnerable dependenciesOWASP dependency-check / Snyk / Dependabot in CI, with an SLA to patch criticals.
MisconfigurationNo stack traces to clients, security headers (HSTS, X-Content-Type-Options), actuator locked down.
SSRFAllow-list outbound URLs if any user input reaches an HTTP client.
Rate limiting / brute forceBucket4j or gateway limits; lockout and delay on auth endpoints.
Say this

"The one I check hardest in review is object-level access control — the authenticated but not authorised for this record case. It's the bug that passes every functional test."

How do you implement logging, correlation ids and tracing?
In simple words

When something fails across 6 services, you need to be able to follow one user's request through all of them. You do that by generating an id at the edge and passing it everywhere — into logs, into HTTP headers, into Kafka records.

// Filter: put the id into MDC so EVERY log line in this request carries it automatically
String traceId = Optional.ofNullable(request.getHeader("X-Trace-Id"))
                         .orElse(UUID.randomUUID().toString());
MDC.put("traceId", traceId);
try {
    chain.doFilter(request, response);
} finally {
    MDC.clear();          // MUST clear - pooled threads would leak it to the next request
}

Log discipline

  • Log in JSON (logstash-logback-encoder) so fields are searchable in ELK/Splunk.
  • ERROR = a human must act. WARN = degraded but handled. INFO = business events. DEBUG = off in production, switchable at runtime via /actuator/loggers.
  • Never log passwords, tokens, card numbers or full request bodies.
  • Use Micrometer Tracing / OpenTelemetry so the trace id propagates automatically and you get a span waterfall showing which hop was slow.
Say this

"The trace id also goes into the error response, so a user's screenshot is enough to find the exact request. That single change cut our support investigation time dramatically."

11. Microservices & Distributed Systems

Monolith vs microservices — when would you NOT split?
In simple words

Microservices trade local complexity for distributed complexity. Inside a monolith, a method call always works. Between services, that same call can be slow, fail halfway, or arrive twice — and now you need retries, timeouts, tracing, versioning and independent deployments.

You take that trade when the benefits (independent deployment, team autonomy, separate scaling) are worth more than the cost.

When NOT to split

  • The domain boundaries aren't stable yet — you'll draw them wrong and every change will span services.
  • One team owns everything. Microservices mostly solve a coordination problem you don't have.
  • You don't yet have the platform: CI/CD, centralised logging, tracing, on-call.
  • The services would need to be transactionally consistent with each other — that's a sign they're one service.
Say this

"I'd start with a well-modularised monolith and extract when there's a forcing function — different scaling profile, different release cadence, a separate team, or a compliance boundary. Splitting on the org chart before the domain is stable produces a distributed monolith: all of the cost, none of the benefit."

Which microservice patterns have you used?
PatternThe problem it solvesTypical tech
API Gateway / BFFOne entry point: auth, routing, rate limiting, shaping payloads for the UISpring Cloud Gateway
Service discoveryInstances come and go; who do I call?Eureka, Consul, Kubernetes DNS
Centralised configSame jar, different settings, changeable without rebuildConfig Server, ConfigMap
Circuit breaker / bulkhead / retryStop one failure cascading through everythingResilience4j
SagaA business transaction spanning servicesEvents or an orchestrator
Transactional outboxSave to DB and publish an event atomicallyOutbox table + Debezium/poller
CQRSRead and write shapes differ wildlySeparate read model
Strangler figMigrate off a monolith incrementallyGateway routing rules
Say this

"The two I'd never skip are circuit breakers on every outbound call and the outbox for anything that changes state and publishes an event."

Explain the circuit breaker pattern.
In simple words

Exactly like an electrical fuse. If a downstream service starts failing, there's no point sending it 1,000 more requests and making 1,000 more threads wait for a timeout. The circuit breaker trips and fails instantly for a while, giving the downstream time to recover and keeping your threads free.

The three states

  1. CLOSED — normal. Calls pass through; failures are counted.
  2. OPEN — the failure rate crossed the threshold. All calls fail immediately (no waiting), and the fallback runs.
  3. HALF_OPEN — after a wait period, a few probe calls are allowed. Success → back to CLOSED. Failure → back to OPEN.
@CircuitBreaker(name = "pricing", fallbackMethod = "cachedPrice")
@Retry(name = "pricing")        // retry sits INSIDE - and only for idempotent calls
@Bulkhead(name = "pricing")     // cap concurrent calls
@TimeLimiter(name = "pricing")
public Price get(String sku){ return client.get(sku); }

// The fallback must be MEANINGFUL - stale cache, a default, a queued request
private Price cachedPrice(String sku, Throwable t){
    return cache.getOrDefault(sku, Price.UNAVAILABLE);
}
resilience4j.circuitbreaker.instances.pricing:
  slidingWindowSize: 50
  failureRateThreshold: 50          # trip at 50% failures
  waitDurationInOpenState: 30s
  permittedNumberOfCallsInHalfOpenState: 5
Retries need jitter

If 500 instances all retry after exactly 1 second, you create a synchronised stampede that keeps the downstream down. Use exponential backoff with random jitter.

Say this

"A fallback that just rethrows adds nothing — the value is in degrading gracefully. On a pricing outage we served slightly stale cached prices with a banner rather than failing checkout entirely."

How do you handle transactions across services? (Saga + outbox)
In simple words

You can't have one database transaction spanning three services. So instead of "all or nothing at once", you do a sequence of local transactions, and if step 3 fails you run compensating actions to undo steps 1 and 2.

Note "compensate", not "rollback": you don't un-charge a card, you issue a refund. That's a real business action, and the customer may see both.

Two saga styles

  • Choreography — each service listens for events and reacts. Simple, no central component; but with more than about 4 steps nobody can tell you what the overall flow is.
  • Orchestration — one orchestrator explicitly drives each step and handles compensation. Slightly more code, but the flow is visible, testable and observable. My default for anything involving money.

The outbox pattern — why you need it

The dual-write problem

You cannot atomically (a) commit to your database and (b) publish to Kafka. If the commit succeeds and the publish fails, the rest of the system never hears about the order. If you publish first and the commit fails, you've announced something that didn't happen.

@Transactional
public void placeOrder(Order o){
    orderRepo.save(o);
    outboxRepo.save(new OutboxEvent("OrderPlaced", toJson(o)));   // SAME transaction
}
// A separate poller (or Debezium CDC reading the WAL) reads the outbox table,
// publishes to Kafka, and marks the row as sent.
// Delivery is at-least-once -> every consumer MUST be idempotent.
Say this

"Outbox plus idempotent consumers is the combination that actually works in production. At-least-once delivery is a guarantee you design around, not one you try to avoid."

Explain CAP in practical terms.
In simple words

When the network between your nodes breaks (a partition — and it will), you must choose:

  • Consistency — refuse to answer rather than give possibly-stale data.
  • Availability — answer anyway, accepting that it might be stale.

You don't get to pick all three, because partitions aren't optional — they're a fact of networks.

What real systems do: strong consistency inside each service's own database (a normal ACID transaction), and eventual consistency between services via events. So the order service is always internally consistent, but the reporting service might be two seconds behind.

Worth adding PACELC: even when there's no partition (E), you're still trading latency (L) against consistency (C) — which is exactly the read-replica lag conversation.

Say this

"The practical version of this question is usually 'can the user see their order immediately after placing it?'. If yes, that read must go to the primary, not a replica — and that's a design decision, not a database setting."

Kafka: how does it work and how do you avoid losing messages?
In simple words

Kafka is a durable, replayable log. Producers append messages to a topic; the topic is split into partitions so it can scale; consumers in a consumer group each get some partitions.

Two consequences follow from that design and they're what interviewers probe:

  • Ordering is guaranteed only within a partition — so pick your key carefully.
  • Maximum parallelism = number of partitions. 3 partitions means at most 3 useful consumers in a group.
# PRODUCER - durability
acks=all                       # wait for all in-sync replicas, not just the leader
enable.idempotence=true        # a retry won't create a duplicate
retries=2147483647
min.insync.replicas=2          # (broker/topic side) refuse writes if replicas are down

# CONSUMER - don't lose messages
enable.auto.commit=false       # commit the offset AFTER successful processing
isolation.level=read_committed

Delivery semantics — say this clearly

  • At-most-once: commit the offset before processing. You can lose messages. Rarely acceptable.
  • At-least-once: commit after processing. The normal choice — but a crash between processing and committing means redelivery, so consumers must be idempotent.
  • Exactly-once: possible with Kafka transactions, but only for Kafka→Kafka flows. If you write to a database, you're back to at-least-once plus idempotency.

Also mention: a dead-letter topic with a retry topic and backoff for poison messages, and monitoring consumer lag as your primary health signal.

Say this

"Partition key by customerId so all events for one customer stay ordered, manual offset commits, and idempotent handlers. Consumer lag is the metric I alert on — it tells you about a problem before users do."

Synchronous REST or asynchronous messaging — how do you decide?
In simple words

Ask one question: does the caller need the answer right now to continue?

Yes → synchronous (a price quote, an auth check). No → asynchronous (send a confirmation email, update a reporting view, recalculate loyalty points).

The maths that makes the point

Every synchronous hop multiplies failure probability and adds latency. A chain of 5 sync calls, each 99.9% available, gives you 99.5% — that's 3.6 hours of downtime per month you didn't choose. Converting the tail of that chain into events removes it.

Async also gives you: buffering against traffic spikes, one producer to many consumers, and the ability for a consumer to be down and catch up later.

Say this

"I keep the user-facing path synchronous and short, and push everything that isn't needed for the response onto events. That's usually the single biggest availability improvement available."

How do you debug an issue spanning several services?
In simple words

You need three things, and they're called the three pillars of observability:

  • Logs — what happened, in detail. Structured, with a shared trace id.
  • Metrics — how much and how fast, aggregated. Rate, Errors, Duration per endpoint.
  • Traces — the journey of one request across services, showing which hop consumed the time.

My sequence

  1. Get the trace id from the failing response or the user's screenshot.
  2. Open the trace — the span waterfall usually shows the guilty service immediately.
  3. In that service, check error logs and dependency metrics: DB pool usage, cache hit rate, circuit-breaker state, consumer lag.
  4. If needed, bump that package to DEBUG at runtime via /actuator/loggers, or take a thread dump.
  5. Fix, add a regression test, and add the alert that would have caught it sooner.
Say this

"Without a propagated trace id, cross-service debugging is guesswork. It's the first thing I add to a new service, before any feature work."

How do you deploy safely? (blue-green, canary, feature flags)
In simple words
  • Rolling — replace pods a few at a time. The Kubernetes default.
  • Blue-green — run the new version alongside the old, then flip the router. Instant rollback.
  • Canary — send 5% of traffic to the new version, watch the error rate, then promote.
  • Feature flags — separate deploying code from releasing it. The code ships dark and you turn it on per tenant.

Non-negotiables for zero downtime

  • Backward-compatible database migrations — during a rolling deploy, old and new code run simultaneously against the same schema.
  • Backward-compatible API and event schemas.
  • A readiness probe that fails before the pod is ready, so traffic doesn't arrive early.
  • Graceful shutdown: server.shutdown=graceful plus a preStop sleep so the load balancer deregisters the pod before the JVM stops.
  • An automatic rollback trigger on error rate.
Say this

"Feature flags are what let us deploy on a Friday. The deployment becomes boring, and the risky moment — enabling the feature — is instantly reversible without a rebuild."

How do you approach caching?
@Cacheable(cacheNames = "price", key = "#sku", unless = "#result == null")
public Price get(String sku){ ... }

@CacheEvict(cacheNames = "price", key = "#p.sku")
public void update(Price p){ ... }
The layers

Browser/CDN → API gateway → in-process cache (Caffeine — fastest, but each pod has its own copy so they can disagree) → distributed cache (Redis — shared and consistent, costs a network hop) → the database's own buffer cache.

What interviewers listen for

  • Always a TTL and a max size. An unbounded cache is a memory leak with good PR.
  • Invalidation strategy: TTL for tolerable staleness, event-driven eviction when you can't tolerate it.
  • Cache stampede: a popular key expires and 500 threads all rebuild it at once. Fix with a per-key lock, refreshAfterWrite, or jittered TTLs.
  • Negative caching: cache "not found" too, or a missing-key attack hammers your database.
  • Never cache user-specific data under a shared key — that's a data-leak incident, not a bug.
Say this

"I start by asking how stale the data is allowed to be — that single answer picks the layer, the TTL and the invalidation strategy. Caching without agreeing that is how you get support tickets about 'wrong' data."

What does a good Docker image and Kubernetes setup look like for a Java service?
# Multi-stage: build with the JDK, run on the smaller JRE
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw -q -DskipTests package \
 && java -Djarmode=layertools -jar target/app.jar extract   # split into layers

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/dependencies/ ./          # rarely changes -> cached layer
COPY --from=build /app/spring-boot-loader/ ./
COPY --from=build /app/application/ ./           # changes every build -> last layer
USER 1000                                        # never run as root
ENTRYPOINT ["java","-XX:MaxRAMPercentage=75",
            "org.springframework.boot.loader.launch.JarLauncher"]

Kubernetes essentials to name

  • Requests and limits — but be careful with hard CPU limits: CFS throttling can cause latency spikes in a JVM that's just doing GC.
  • Three probes: startup (slow boot), liveness (restart me if I'm wedged), readiness (don't send traffic yet) — mapped to Actuator health groups.
  • terminationGracePeriodSeconds matched to your graceful shutdown timeout.
  • HPA on a meaningful metric, ConfigMap/Secret for config, PodDisruptionBudget so a node drain doesn't take all replicas.
Say this

"Layered jars matter more than people expect — dependencies rarely change, so a code-only deploy pushes a few megabytes instead of a few hundred."

12. Testing — JUnit 5 & Mockito

The JD names JUnit explicitly. Lead with a strategy, not annotation trivia.

What's your testing strategy?
In simple words — the test pyramid

Lots of unit tests (fast, no Spring, no database — milliseconds). Some integration tests (real database via Testcontainers, real HTTP layer — seconds). Very few end-to-end tests (whole system through the browser — minutes, and flaky).

Inverting this pyramid is the classic mistake: a suite of 200 Selenium tests that takes 90 minutes, fails randomly, and nobody trusts.

What I actually put in place

  • Unit tests for business rules and edge cases — the bulk of the suite.
  • Slice tests (@WebMvcTest, @DataJpaTest) for the boundaries.
  • Integration tests with Testcontainers for repositories, Flyway migrations and Kafka.
  • Contract tests (Spring Cloud Contract / Pact) between services, so a breaking change fails my build rather than the consumer's production.
  • A handful of Cypress/Playwright journeys — login, place order, pay.
  • Coverage gates on new/changed code, not a global percentage — and occasional mutation testing (PIT) when coverage numbers look gamed.
Say this

"My rule in review is that the test must assert behaviour, not implementation. Tests coupled to implementation break on every refactor, and then the team starts deleting them."

JUnit 5 essentials.
@BeforeAll static void once(){}          // JUnit 4: @BeforeClass
@BeforeEach void setUp(){}               // JUnit 4: @Before
@Test @DisplayName("rejects a negative withdrawal")
void rejectsNegative(){ }

@ParameterizedTest                       // one test, many inputs
@ValueSource(strings = {"", " ", "\t"})
void blankIsInvalid(String input){ ... }

@ParameterizedTest
@CsvSource({"1,2,3", "5,5,10", "-1,1,0"})
void adds(int a, int b, int expected){ assertEquals(expected, calc.add(a,b)); }

@Nested class WhenAccountIsFrozen { ... }   // grouped, readable contexts
@Tag("slow") @Timeout(2) @RepeatedTest(5) @Disabled("flaky - JIRA-123")

// assertions
assertThrows(IllegalArgumentException.class, () -> svc.withdraw(-1));
assertAll(() -> assertEquals(2, r.size()),
          () -> assertTrue(r.contains("x")));     // reports BOTH failures, not just the first

// AssertJ reads far better and gives better failure messages
assertThat(orders).hasSize(2)
                  .extracting(Order::getStatus)
                  .containsExactly(OPEN, OPEN);

vs JUnit 4: modular (Platform/Jupiter/Vintage), @ExtendWith replaces runners and rules (and you can compose many extensions, where you could only have one runner), and it supports parameter injection.

Say this

"@ParameterizedTest with @CsvSource is what I reach for on business rules — one test method covering the whole boundary table, so nobody 'forgets' the negative case."

Write a good unit test with Mockito. @Mock vs @Spy vs @InjectMocks vs @MockBean?
In simple words
  • @Mock — a fake object. Nothing real happens; unstubbed methods return null/0/empty.
  • @Spy — a real object you can partially override. Unstubbed methods run the real code.
  • @InjectMocks — builds the class under test and pushes the mocks into it.
  • @MockBean — replaces a bean inside the Spring context (integration tests only).
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock OrderRepository repo;
    @Mock PricingClient pricing;
    @InjectMocks OrderService service;          // constructed with the two mocks
    @Captor ArgumentCaptor<Order> orderCaptor;

    @Test
    void appliesDiscountForLoyalCustomers(){
        // GIVEN
        given(pricing.quote("SKU1")).willReturn(new BigDecimal("100"));
        given(repo.save(any())).willAnswer(inv -> inv.getArgument(0));

        // WHEN
        Order result = service.place(new PlaceOrder("cust-1", "SKU1", true));

        // THEN - assert the OUTCOME
        assertThat(result.getTotal()).isEqualByComparingTo("90.00");

        // and capture what was actually saved
        then(repo).should().save(orderCaptor.capture());
        assertThat(orderCaptor.getValue().getStatus()).isEqualTo(PLACED);
    }
}
Two Mockito details worth knowing
  • On a @Spy, use doReturn(x).when(spy).method() — the usual when(spy.method()) would actually call the real method while stubbing.
  • @MockBean creates a new Spring context for that combination, so overusing it slows the whole suite dramatically.
Say this

"If I need mockStatic, I treat it as a design smell — usually the fix is injecting a collaborator, like injecting a Clock instead of calling Instant.now() directly."

Explain Spring Boot test slices.
In simple words

Loading the entire Spring context for every test is slow. A slice loads only the layer you're testing — the web layer, or the JPA layer — and mocks the rest.

AnnotationLoadsUse for
@SpringBootTestEverythingFull wiring — keep these few
@WebMvcTest(X.class)MVC layer only, services mockedRouting, validation, JSON, security rules
@DataJpaTestJPA + a database, rolls back each testQueries, mappings, migrations
@RestClientTest, @JsonTestOne client / serializationClient behaviour, DTO contracts
@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Autowired MockMvc mvc;
    @MockBean OrderService service;         // the real service is NOT loaded

    @Test
    void returns400WhenCustomerIdMissing() throws Exception {
        mvc.perform(post("/api/v1/orders")
               .contentType(APPLICATION_JSON)
               .content("{}"))
           .andExpect(status().isBadRequest())
           .andExpect(jsonPath("$.errors.customerId").exists());
    }
}
Say this

"Spring caches the context per unique configuration, so every different combination of @MockBean and properties creates another context. Standardising our test configuration took the suite from 14 minutes to 4 — that's a real, measurable win to mention."

What are Testcontainers and why not H2?
In simple words

Testcontainers starts a real Postgres (or Kafka, or Redis) in Docker for the duration of your test, then throws it away. Your integration tests run against the actual database engine you use in production.

@SpringBootTest
@Testcontainers
class OrderRepositoryIT {

    @Container
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry){
        registry.add("spring.datasource.url", db::getJdbcUrl);
        registry.add("spring.datasource.username", db::getUsername);
        registry.add("spring.datasource.password", db::getPassword);
    }
}

Why H2 "in Postgres mode" isn't good enough

  • Different SQL dialect edge cases — queries that pass on H2 and fail in production.
  • No JSONB, no window function parity, different locking and sequence behaviour.
  • Flyway migrations that succeed on H2 and fail on the real database — the worst kind, because you find out during deployment.

Cost: Docker in CI and slower startup. Mitigate with a shared static container per suite and reusable containers locally.

Say this

"The moment that convinced me was a migration that worked on H2 and failed in staging. Since moving to Testcontainers, migration failures are caught on the developer's machine."

How do you test asynchronous and time-dependent code?
In simple words

Never use Thread.sleep() in a test. It makes the suite slow when the machine is fast and flaky when the machine is loaded. Instead: control time by injecting it, and wait for a condition rather than a duration.

// TIME: inject a Clock instead of calling Instant.now()
public OrderService(Clock clock){ this.clock = clock; }
Instant now = Instant.now(clock);
// in the test:
var service = new OrderService(Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), UTC));

// ASYNC: Awaitility - poll for a condition with a timeout
await().atMost(5, SECONDS)
       .untilAsserted(() -> assertThat(repo.count()).isEqualTo(1));

// CONCURRENCY: release N threads simultaneously, then assert the invariant
var latch = new CountDownLatch(1);
IntStream.range(0, 50).forEach(i -> pool.submit(() -> { latch.await(); counter.increment(); }));
latch.countDown();

// DOWNSTREAM FAILURES: WireMock to simulate slow/500 responses,
// which is also how you actually test your circuit breaker
Say this

"Injecting a Clock is the single change that makes date logic testable — no more tests that fail on the 31st or during a leap year."

What makes a bad test? What do you reject in review?
  • Only verifying mocks ("the method was called") without asserting the actual outcome.
  • Coupled to implementation — any refactor turns it red even though behaviour is unchanged.
  • Shared mutable state or dependence on test execution order.
  • Thread.sleep, or dependence on the current date.
  • No negative cases — no null, empty, boundary, duplicate or concurrent scenarios.
  • Hitting a real external system.
  • Names like test1. I want rejectsWithdrawalWhenBalanceIsInsufficient.
On flaky tests

A flaky test is a broken test. I never add a blanket retry — retries hide genuine race conditions. It gets quarantined with a ticket and a named owner.

Say this

"My non-negotiable is that every bug fix ships with a test that fails without the fix. That's how the suite becomes a record of everything that has ever gone wrong."

Do you practise TDD?
An honest senior answer

"Red–green–refactor, and I use it strictly in two places: algorithmic or business-rule code, where the test clarifies what I'm building; and bug fixes, where I always reproduce the defect with a failing test first — that proves the fix works and prevents the regression.

For exploratory UI work or integration wiring I'll often spike first and then write the tests before merging. What I insist on in review isn't the order the code was written in — it's that the behaviour is covered."

13. SQL & Databases

Explain the join types.
In simple words

Two tables, and you're deciding what to do with rows that have no match on the other side.

  • INNER — only rows that match on both sides.
  • LEFT — every row from the left table; missing right-side columns become NULL.
  • RIGHT — the mirror image (rarely used; just swap the tables).
  • FULL OUTER — everything from both sides.
  • CROSS — every combination (deliberate, e.g. building a date × product grid).
  • SELF — a table joined to itself: employee → their manager.
-- Customers who have NEVER ordered: LEFT JOIN + IS NULL is the standard idiom
SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

-- Employees earning more than their manager (self join)
SELECT e.name, e.salary, m.name AS manager
FROM employees e
JOIN employees m ON e.manager_id = m.id
WHERE e.salary > m.salary;
The classic mistake

Putting a condition on the right table in WHERE instead of ON silently converts your LEFT JOIN into an INNER JOIN — because NULL = 'X' is never true, so the unmatched rows get filtered out again.

-- BROKEN: acts as an INNER JOIN
LEFT JOIN orders o ON o.customer_id = c.id WHERE o.status = 'OPEN'
-- CORRECT
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'OPEN'
WHERE vs HAVING, and the logical order of execution.
In simple words

WHERE filters individual rows before grouping. HAVING filters whole groups after grouping. So you can't use an aggregate in WHERE (it doesn't exist yet) and you shouldn't use HAVING for a plain row filter (it's slower — you grouped rows you were going to throw away).

Logical order — memorise this

FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT

This explains two things that confuse people: you can't use a SELECT alias in WHERE (SELECT hasn't run yet), but you can in ORDER BY (it has).

SELECT dept_id, COUNT(*) AS headcount
FROM employees
WHERE active = true             -- filters ROWS first (cheap)
GROUP BY dept_id
HAVING COUNT(*) > 5             -- filters GROUPS after
ORDER BY headcount DESC;        -- alias works here
How do indexes work, and when are they NOT used?
In simple words

An index is the book's index: instead of reading all 500 pages, you look up the term and jump to page 312. Technically it's a B+ tree — sorted, so lookups are O(log n) and ranges are cheap.

The cost: every INSERT, UPDATE and DELETE must also update every index. So indexes speed up reads and slow down writes.

When your index is silently ignored

CauseExampleFix
Function on the columnWHERE UPPER(email) = ?Functional index, or store normalised
Leading wildcardLIKE '%abc'Full-text index or trigram index
Type mismatchvarchar column compared to a numberFix the type / the binding
Low selectivity60% of rows matchNothing — a full scan really is cheaper
Wrong column orderIndex on (a,b,c), query filters on b onlyLeftmost-prefix rule — reorder or add an index
Stale statisticsOptimiser thinks the table has 100 rowsANALYZE
Leftmost prefix — explain it like this

An index on (country, city, street) is like a phone book sorted by country, then city, then street. You can find "everyone in India", or "everyone in India, Chennai". You cannot efficiently find "everyone on Main Street" across all countries — the book isn't sorted that way.

Covering index

If the index contains every column the query needs, the database never touches the table at all — an index-only scan. That's often the single biggest win available on a hot read query.

Say this

"I index for real query patterns and then check pg_stat_user_indexes to drop the ones nothing uses — unused indexes are pure write cost."

A query got slow in production. Walk me through fixing it.
In simple words

Don't guess and don't add an index reflexively. Get the execution plan, find the single most expensive step, fix that, and measure again.

My sequence

  1. Confirm which query. pg_stat_statements or the slow-query log, ranked by total time, not per-call time — a 20ms query run 10,000 times/minute matters more than a 2-second report run hourly.
  2. Get the real plan: EXPLAIN (ANALYZE, BUFFERS). Compare estimated vs actual rows — a big gap means bad statistics and the optimiser is choosing badly.
  3. Read for these signals: Seq Scan on a large table; a Nested Loop with a huge inner side; an external Sort (spilling to disk); very high buffer reads.
  4. Fix in this order:
    • The query — drop SELECT *, remove an unnecessary DISTINCT, turn a correlated subquery into a join, remove functions from indexed columns.
    • The index — composite in the right order, covering, or partial (WHERE status='OPEN').
    • Statistics — ANALYZE.
    • The access pattern — keyset pagination instead of OFFSET 100000; a materialised view for reporting.
    • Only then — partitioning, read replicas, caching.
  5. Verify with the same EXPLAIN ANALYZE, then add an alert on p99 so a regression is noticed.
Check the application first in a Java shop

Often "slow SQL" is really: an N+1 from lazy loading, a missing Pageable, a Specification that lost its filter, or connection-pool starvation caused by a long transaction. The database is innocent surprisingly often.

Say this

"EXPLAIN ANALYZE first, always. The estimated-versus-actual row count tells you in ten seconds whether it's a plan problem or a genuine data-volume problem."

Explain ACID and the isolation anomalies.
In simple words
  • Atomicity — all of it happens or none of it does.
  • Consistency — the database's rules (constraints) always hold.
  • Isolation — concurrent transactions don't corrupt each other's view.
  • Durability — once committed, it survives a power cut.
AnomalyWhat you'd observePrevented from
Dirty readYou read data another transaction later rolls backREAD COMMITTED
Non-repeatable readYou read the same row twice and get different valuesREPEATABLE READ
Phantom readYou run the same range query twice and new rows appearedSERIALIZABLE
Lost updateTwo people edit; the second overwrites the first silentlyLocking / @Version
Say this

"Most systems run READ COMMITTED and solve lost updates with optimistic locking rather than raising the isolation level globally — raising isolation everywhere trades a rare bug for constant lock contention."

Normalization vs denormalization — how far do you go?
In simple words

Normalization = store each fact in exactly one place. It prevents contradictions (an address changed in one table but not another). Denormalization = deliberately duplicate data to make reads faster.

1NF: no repeating groups, atomic values. 2NF: no field depending on only part of a composite key. 3NF: no field depending on another non-key field.

My practical position

Normalize the write model to 3NF, so there's a single source of truth and no update anomalies. Denormalize deliberately and separately for read paths that are measurably too slow — a reporting table, a materialised view, a search index or a CQRS read model — always with a defined refresh mechanism.

Say this

"Denormalization is a trade of write complexity for read speed, and it's only valid if I can point at the measurement that justified it and the mechanism that keeps it fresh."

How do you paginate large result sets efficiently?
In simple words

OFFSET 100000 makes the database read and discard 100,000 rows before returning 20. Page 1 is fast, page 5000 is agony. It's also unstable: if a new row is inserted, rows shift and users see duplicates across pages.

Keyset (cursor) pagination says "give me the 20 rows after this one" — constant time regardless of depth, and stable under inserts.

-- OFFSET: simple, degrades badly
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000;

-- KEYSET: constant time. The tuple comparison handles ties correctly.
SELECT * FROM orders
WHERE (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- index on (created_at DESC, id DESC)

Also: avoid running COUNT(*) on every page. Spring Data's Slice (instead of Page) skips the count query entirely — you just tell the UI whether there's a next page.

Say this

"Offset for admin screens where people never go past page 3; keyset for infinite scroll, APIs and anything over a few thousand rows."

SQL vs NoSQL — how do you choose?
In simple words

Choose relational when you need joins, multi-row transactions, a strong schema and unpredictable ad-hoc queries — which describes most enterprise and financial systems.

Choose NoSQL when the access pattern is known and narrow and you need scale or flexible shape.

TypeGood forExample
DocumentAggregates always read as a wholeMongoDB
Key-valueCache, sessions, countersRedis
Wide-columnMassive write throughput, known partition keyCassandra
GraphRelationship traversal (fraud rings, recommendations)Neo4j
SearchFull-text, faceting, fuzzy matchingElasticsearch
Say this

"In practice it's polyglot: Postgres as the system of record, Redis for cache, Elasticsearch for search — with the search index populated from events rather than dual writes."

What causes database deadlocks and how do you avoid them?
In simple words

Same idea as a Java deadlock, but with rows: transaction A locks row 1 and wants row 2; transaction B locks row 2 and wants row 1. The database detects the cycle and kills one of them — your application sees a deadlock exception.

Prevention

  • Consistent access order — always update rows in the same order (e.g. by ascending id) across all code paths. This is the fix that actually scales.
  • Keep transactions short — never do an HTTP call or wait for user input inside one.
  • Use SELECT ... FOR UPDATE SKIP LOCKED for queue-like tables so workers grab different rows.
  • Retry — a deadlock victim usually succeeds on retry, so a bounded retry on that specific error code is legitimate.
Say this

"We had deadlocks on a batch update because two jobs iterated the same rows in different orders. Sorting the input by primary key before updating removed them entirely — no locking changes needed."

Stored procedures, views, triggers — what's your position?
  • Views — fine. Good for encapsulating a join or restricting columns.
  • Materialized views — excellent for expensive reporting aggregates, with a scheduled refresh.
  • Stored procedures — fast (no round trips), but business logic in the database is hard to version, test, review and debug. I keep logic in Java unless there's a genuine bulk-data reason.
  • Triggers — I avoid them for business logic. They cause invisible side effects: someone reads the Java code, sees no reason for a change, and loses hours. Auditing is the one defensible use, and even then CDC/outbox is usually better.
Say this

"My test is: would a developer reading the Java code understand everything that happens on save? A trigger breaks that, and during an incident that costs real time."

How do you process millions of rows in a batch job?
In simple words

Never load it all into memory, and never do it in one giant transaction. Process in chunks, commit each chunk, and record where you got to so the job can resume instead of restarting.

  • Read with a cursor/keyset — Stream or ScrollableResults, never findAll().
  • Process in batches of 500–1000; flush() and clear() the persistence context each chunk.
  • Commit per chunk, so a failure at 90% doesn't roll back hours of work.
  • Record a restart point and make each chunk idempotent, so a rerun is safe.
  • Throttle — a batch job that saturates the database takes the online system down with it. Run off-peak and rate-limit.
  • Emit progress metrics so someone can see it's alive.

Spring Batch gives you chunk processing, skip/retry policies and a job repository for restartability out of the box — worth naming.

Say this

"Resumability is the requirement people forget. A 6-hour job that can't restart from where it failed is a 6-hour job you run twice."

14. Angular Fundamentals

Answer in terms of modern Angular (standalone, signals, new control flow) while showing you know the NgModule world most enterprise codebases still run on.

What is Angular's architecture, and how is it different from React?
In simple words

Angular is a complete framework: routing, forms, HTTP, dependency injection, testing and a CLI all ship in the box, and TypeScript is mandatory. React is a library for rendering — you choose and assemble everything else yourself.

For a 30-person enterprise team that's usually an advantage: the framework makes the decisions, so all the code looks the same and onboarding is faster.

The building blocks

  • Component — a piece of UI: a TypeScript class + an HTML template + styles.
  • Template — HTML with bindings, loops and conditions.
  • Directive — adds behaviour to an element (*ngIf, or your own).
  • Pipe — formats a value for display (| date, | currency).
  • Service — logic and state, shared via dependency injection.
  • Router — maps URLs to components, with lazy loading and guards.
Say this

"Angular's opinionated nature is the reason I'd pick it for a large enterprise app — consistency across teams matters more than flexibility once you're past a few developers."

NgModules vs standalone components — how do you structure an app today?
In simple words

The old way: every component had to be declared in an NgModule, and the NgModule listed everything it needed. It was a layer of bookkeeping with no real payoff.

Standalone components (default from Angular 19) removed that layer: each component declares its own imports directly. Dependencies are local, obvious, and tree-shaking works better.

@Component({
  selector: 'app-order-list',
  standalone: true,
  imports: [CommonModule, RouterLink, OrderCardComponent],   // exactly what THIS component uses
  templateUrl: './order-list.component.html'
})
export class OrderListComponent {}

// Bootstrapping - no AppModule
bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withPreloading(PreloadAllModules)),
    provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
    provideAnimations()
  ]
});

Folder structure I use

src/app/
  core/            singletons: auth, interceptors, error handler  (provided ONCE)
  shared/          dumb reusable components, pipes, directives    (no feature imports)
  features/
    orders/        routes + smart containers + presentational components + services
    customers/
  models/          interfaces and types
Say this

"Migration is incremental — standalone components can be imported into existing NgModules, so we converted leaf components first and worked upward, with no big-bang rewrite."

Explain the component lifecycle hooks.
In simple words

Angular tells your component when things happen to it: inputs arrived, the view is rendered, it's about to be destroyed. You hook into the moment you need.

HookWhen it firesWhat it's for
ngOnChangesBefore init, and on every @Input changeReact to an input changing; gives you previous + current
ngOnInitOnce, after the first inputs are setInitial data load, form setup
ngDoCheckEvery change-detection runCustom dirty checking — expensive, rarely needed
ngAfterViewInitAfter this component's view and children render@ViewChild access, DOM measurement, chart libraries
ngOnDestroyJust before removalUnsubscribe, clear timers, remove listeners
Two things they'll probe
  • Why not the constructor for data loading? The constructor is for dependency injection only. At construction time @Input values aren't set yet, and doing work there makes the class hard to test.
  • ExpressionChangedAfterItHasBeenCheckedError comes from changing a bound value in ngAfterViewInit — after Angular has already checked it. In dev mode Angular runs the check twice to catch exactly this.
Say this

"Constructor for DI, ngOnInit for initialisation, ngOnDestroy for cleanup — and since Angular 16 I use takeUntilDestroyed() so cleanup doesn't need ngOnDestroy at all."

What are the types of data binding?
In simple words

Four ways data moves between the class and the template. Read the punctuation: {{ }} and [ ] flow into the view, ( ) flows out, [( )] does both.

<!-- 1. Interpolation: class -> view -->
<h2>{{ order.reference }}</h2>

<!-- 2. Property binding: class -> view (real DOM property, not a string) -->
<button [disabled]="form.invalid">Save</button>
<img [src]="user.avatarUrl">

<!-- 3. Event binding: view -> class -->
<button (click)="save($event)">Save</button>

<!-- 4. Two-way -->
<input [(ngModel)]="query">
<!-- which is exactly shorthand for: -->
<input [ngModel]="query" (ngModelChange)="query = $event">

The "banana in a box" works on any component that exposes @Input() x plus @Output() xChange — it's a naming convention, not magic. Saying that shows you understand the mechanism.

How do components communicate?
In simple words

Down the tree with inputs, up the tree with events, and sideways (between unrelated components) through a shared service.

// Parent -> child
@Input() order!: Order;
@Input({ required: true }) id!: string;      // Angular 16+: enforced at build
readonly order = input.required<Order>();    // signal-based input (Angular 17+)

// Child -> parent
@Output() saved = new EventEmitter<Order>();
this.saved.emit(order);
readonly saved = output<Order>();            // signal-based output

// Parent reaching into a child
@ViewChild(ChildComponent) child!: ChildComponent;

// Unrelated components -> shared service
@Injectable({ providedIn: 'root' })
export class CartStore {
  private readonly _items = signal<Item[]>([]);
  readonly items = this._items.asReadonly();          // expose read-only
  add(i: Item){ this._items.update(list => [...list, i]); }
}
The design rule that matters

Smart (container) components talk to services and hold state. Presentational (dumb) components only take inputs and emit outputs — no services, no HTTP. Dumb components are trivially testable, reusable, and work perfectly with OnPush.

Say this

"Smart/dumb separation is the single structural decision that keeps a large Angular app maintainable — and it makes OnPush safe to apply everywhere."

Explain Angular's dependency injection.
In simple words

Same idea as Spring: you declare what you need, Angular supplies it. Where you provide a service decides how many instances exist and who shares them.

@Injectable({ providedIn: 'root' })      // ONE instance app-wide, and tree-shakable
export class OrderService {
  private http = inject(HttpClient);      // inject() function - no constructor needed
}

// Provided at COMPONENT level = a NEW instance per component instance,
// destroyed with the component. Perfect for per-wizard or per-dialog state.
@Component({ providers: [DraftStore] })

// Non-class dependencies need a token
export const API_URL = new InjectionToken<string>('API_URL');

providers: [
  { provide: API_URL,   useValue: environment.apiUrl },
  { provide: Logger,    useClass: ProdLogger },        // swap implementation
  { provide: Cache,     useFactory: () => new Cache(50) }
]

Angular uses a hierarchical injector: it looks in the component's own injector, then its ancestors, then the root. Modifiers: @Optional(), @Self(), @SkipSelf().

Say this

"Component-level providers are underused — scoping a store to a component subtree means the state is automatically cleaned up when the user navigates away, with no manual reset logic."

Structural directives and the new control flow syntax.
In simple words

Structural directives add or remove elements from the DOM (*ngIf, *ngFor) — the * is shorthand for wrapping the element in an <ng-template>. Attribute directives change how an existing element looks or behaves (ngClass, ngStyle).

Angular 17 introduced built-in control flow — no imports needed, better type narrowing, and measurably faster.

@if (order(); as o) {
  <app-summary [order]="o" />
} @else {
  <app-empty-state />
}

@for (line of lines(); track line.id) {      <!-- track is REQUIRED -->
  <app-line [line]="line" />
} @empty {
  <p>No lines yet</p>
}

@switch (status()) {
  @case ('OPEN')   { <app-open /> }
  @case ('CLOSED') { <app-closed /> }
  @default         { <app-unknown /> }
}

@defer (on viewport) {          <!-- lazy-load a heavy component when scrolled into view -->
  <app-chart />
} @placeholder {
  <app-skeleton />
}
Why track matters

Without it, when the array reference changes Angular destroys and rebuilds every DOM node — losing focus, scroll position and animations, and burning CPU. With track item.id it reuses the rows that didn't change. On a 500-row grid this is often the single biggest performance win.

// A custom structural directive: show content only if the user has a role
@Directive({ selector: '[appHasRole]', standalone: true })
export class HasRoleDirective {
  private tpl  = inject(TemplateRef<unknown>);
  private vcr  = inject(ViewContainerRef);
  private auth = inject(AuthService);

  @Input() set appHasRole(role: string) {
    this.vcr.clear();
    if (this.auth.hasRole(role)) this.vcr.createEmbeddedView(this.tpl);
  }
}
// usage:  <button *appHasRole="'ADMIN'">Delete</button>
Pure vs impure pipes — and why is a pipe better than a method in the template?
In simple words

A pure pipe (the default) only recalculates when its input reference changes — Angular caches the result. An impure pipe recalculates on every single change-detection cycle.

That's why calling a method in a template is a performance trap: it behaves like an impure pipe, running hundreds or thousands of times per second.

<!-- BAD: runs on EVERY change detection cycle, for every row -->
<td>{{ calculateTotal(order) }}</td>

<!-- GOOD: pure pipe, cached per input -->
<td>{{ order | orderTotal }}</td>

<!-- ALSO GOOD: computed signal, recalculated only when a dependency changes -->
<td>{{ total() }}</td>
@Pipe({ name: 'maskAccount', standalone: true })     // pure by default
export class MaskAccountPipe implements PipeTransform {
  transform(value: string, visible = 4): string {
    if (!value) return '';
    return '•'.repeat(Math.max(0, value.length - visible)) + value.slice(-visible);
  }
}

AsyncPipe is impure by necessity — it must react whenever the observable emits. It also unsubscribes automatically, which is why it's the preferred way to consume observables in templates.

How do you call HTTP APIs properly, and what belongs in an interceptor?
In simple words

Components should never know about URLs or headers. A typed service owns the API calls; an interceptor handles anything that applies to every request — auth token, trace id, error handling, loading spinner.

@Injectable({ providedIn: 'root' })
export class OrderApi {
  private http = inject(HttpClient);
  private base = inject(API_URL);

  list(page: number, size: number): Observable<Page<Order>> {
    const params = new HttpParams().set('page', page).set('size', size);
    return this.http.get<Page<Order>>(`${this.base}/orders`, { params })
      .pipe(
        retry({ count: 2, delay: (_, i) => timer(300 * 2 ** i) }),   // GETs only
        catchError(this.toDomainError)
      );
  }
}

// Functional interceptor (Angular 15+)
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthStore).token();
  const authed = token
      ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
      : req;
  return next(authed).pipe(
    catchError(err => err.status === 401 ? refreshAndRetry(authed, next)
                                        : throwError(() => err))
  );
};
Careful with retry

Only retry idempotent calls. Retrying a POST /orders can create two orders. And never retry a 4xx — the request will fail identically every time.

Say this

"Auth, correlation ids, global error mapping and the loading indicator all live in interceptors, so no component ever contains an if (401)."

Routing: lazy loading, guards and resolvers.
In simple words

Lazy loading means a feature's JavaScript isn't downloaded until the user actually navigates there. On a large app this is the single biggest improvement to first-load time.

Guards decide whether navigation is allowed. Resolvers fetch data before the route activates so the page doesn't flash empty.

export const routes: Routes = [
  { path: '', component: HomeComponent },
  {
    path: 'orders',
    canMatch: [authGuard],                        // don't even DOWNLOAD the chunk if not allowed
    loadChildren: () => import('./features/orders/orders.routes')
                          .then(m => m.ORDER_ROUTES)
  },
  {
    path: 'orders/:id',
    loadComponent: () => import('./order-detail.component')
                           .then(m => m.OrderDetailComponent),
    resolve: { order: orderResolver },
    canDeactivate: [unsavedChangesGuard]          // "you have unsaved changes"
  },
  { path: '**', component: NotFoundComponent }    // wildcard MUST be last
];

export const authGuard: CanMatchFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.isLoggedIn() ? true : router.createUrlTree(['/login']);
};

Which guard?

  • canActivate — may the user enter this route?
  • canDeactivate — may the user leave? (unsaved changes prompt)
  • canMatch — preferred over the old canLoad; prevents even downloading the lazy chunk.
Resolvers have a downside

The user sees the old page until the data arrives. For a slow call I prefer navigating immediately and showing a skeleton — it feels faster even though it isn't.

Say this

"Guards are UX, not security. Every rule they enforce is enforced again server-side — a guard is trivially bypassed by editing the JavaScript."

Template-driven vs reactive forms.
In simple words

Template-driven: the form lives in the HTML, Angular builds the model implicitly. Fine for a login box.

Reactive: you build the form model explicitly in TypeScript. More code up front, but the model is testable without the DOM, synchronously readable, and dynamic — you can add and remove controls at runtime.

For anything non-trivial: reactive.

form = this.fb.nonNullable.group({
    customerId: ['', Validators.required],
    email:      ['', [Validators.required, Validators.email], [this.emailTaken()]], // async
    lines:      this.fb.array([this.newLine()])
  },
  { validators: totalMatchesLines }         // cross-field validator on the GROUP
);

get lines(){ return this.form.get('lines') as FormArray; }
addLine(){ this.lines.push(this.newLine()); }
removeLine(i: number){ this.lines.removeAt(i); }

// custom validator - a function returning null (valid) or an error object
export function futureDate(c: AbstractControl): ValidationErrors | null {
  return new Date(c.value) > new Date() ? null : { futureDate: true };
}

// async validator - debounce so you don't hit the API on every keystroke
emailTaken(): AsyncValidatorFn {
  return c => timer(400).pipe(
    switchMap(() => this.api.emailExists(c.value)),
    map(exists => exists ? { emailTaken: true } : null)
  );
}

onSubmit(){
  if (this.form.invalid) { this.form.markAllAsTouched(); return; }  // show all errors
  this.api.save(this.form.getRawValue()).subscribe(...);
}

Angular 14+ typed forms mean form.value.email is a string, not any — worth mentioning that you migrated to them.

Say this

"FormArray plus a cross-field validator is what makes reactive forms worth it — a dynamic order-lines form with a 'lines must sum to the total' rule is basically impossible template-driven."

What are Angular signals?
In simple words

A signal is a value that knows who is reading it. When the value changes, it tells exactly those readers — nobody else.

Analogy: instead of ringing a bell that makes the whole building check whether anything changed (what Zone.js does), a signal sends a message to precisely the three people who care.

export class CartComponent {
  // writable state
  readonly items  = signal<CartItem[]>([]);
  readonly coupon = signal<string | null>(null);

  // DERIVED state - recomputed only when items() changes, and cached
  readonly total = computed(() =>
      this.items().reduce((sum, i) => sum + i.price * i.qty, 0));

  readonly hasItems = computed(() => this.items().length > 0);

  constructor(){
    // effect = side effects only (logging, localStorage). NOT for deriving state.
    effect(() => console.log('total is now', this.total()));
  }

  add(item: CartItem){ this.items.update(list => [...list, item]); }
  clear(){ this.items.set([]); }
}
<!-- in the template, call it like a function -->
<p>Total: {{ total() | currency }}</p>

Signals vs RxJS — the split I recommend

  • Signals for synchronous component state: form filters, selected row, derived totals, UI flags.
  • RxJS for asynchronous streams over time: HTTP, websockets, debounced search, complex event orchestration.
  • Bridge them: toSignal(observable$) and toObservable(signal).
Say this

"Signals aren't a replacement for RxJS — they replace BehaviorSubject for local state. They're also the foundation of zoneless Angular, which removes Zone.js entirely and makes change detection precise rather than 'check everything'."

What is AOT and what should a production build look like?
In simple words

AOT (Ahead-Of-Time) compiles your HTML templates into JavaScript at build time. The alternative (JIT) shipped the Angular compiler to the browser and compiled templates on load — bigger download, slower start, and template errors only appeared at runtime.

AOT has been the only production mode since Angular 9. Practical benefit: a typo in a template fails the build, not the user's session.

ng build --configuration production
# - AOT compilation
# - tree-shaking (unused code removed)
# - minification + hashed filenames for cache busting
# - budgets enforced

// angular.json - fail the build if the bundle grows
"budgets": [
  { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" }
]

To find what's bloating the bundle: ng build --stats-json then source-map-explorer.

Say this

"Bundle budgets in CI are the thing that actually keeps size under control — without them, size grows silently one dependency at a time until someone complains about load speed."

How do you handle loading and error states consistently?
In simple words

Most Angular apps grow a mess of isLoading, hasError and errorMessage booleans that can contradict each other. Model it as one state instead, so impossible combinations can't exist.

type ViewState<T> =
  | { status: 'loading' }
  | { status: 'loaded'; data: T }
  | { status: 'empty' }
  | { status: 'error'; message: string };

readonly vm$ = this.api.list().pipe(
  map(data => data.length
        ? { status: 'loaded', data } as const
        : { status: 'empty' } as const),
  startWith({ status: 'loading' } as const),
  catchError(e => of({ status: 'error', message: friendly(e) } as const))
);
@switch (vm().status) {
  @case ('loading') { <app-spinner /> }
  @case ('empty')   { <app-empty-state /> }
  @case ('error')   { <app-error [message]="..." (retry)="reload()" /> }
  @case ('loaded')  { <app-table [rows]="..." /> }
}

Plus a global ErrorHandler for uncaught runtime errors, reporting to Sentry with the trace id.

Say this

"A discriminated union makes the impossible states unrepresentable — you can't be loading and errored at the same time, which is a real bug class in booleans-based code."

15. RxJS & Change Detection

The two hardest Angular areas — and where most performance bugs live.

Observable vs Promise.
In simple words

A Promise is a single delivery: one value, and it's already on its way the moment you create it. You can't cancel it.

An Observable is a subscription to a stream: zero, one or many values over time. Nothing happens until you subscribe(), and you can cancel by unsubscribing — which actually aborts the HTTP request.

PromiseObservable
ValuesExactly oneZero to many
StartsImmediately (eager)On subscribe (lazy)
Cancel✅ unsubscribe
Operatorsthen / catch100+ composable
The consequence that catches people out

http.get() is cold: every subscription starts a new request. So two | async pipes on the same observable = two HTTP calls. Fix with shareReplay(1).

Say this

"Laziness plus cancellation is the reason Angular chose Observables — when a user navigates away mid-request, the request is actually aborted rather than resolving into a destroyed component."

switchMap vs mergeMap vs concatMap vs exhaustMap.
In simple words — with the everyday version of each
  • switchMap — "forget the last one, do this instead". Changing your search term: you only care about the newest.
  • mergeMap — "do them all at once". Uploading 5 files in parallel.
  • concatMap — "do them one after another, in order". Saving edits that must apply in sequence.
  • exhaustMap — "ignore new ones while I'm busy". A Save button: ignore the impatient second click.
// The canonical type-ahead search
this.results$ = this.searchControl.valueChanges.pipe(
  debounceTime(300),                     // wait for a pause in typing
  distinctUntilChanged(),                // ignore if the text didn't actually change
  filter((q): q is string => !!q && q.length >= 2),
  switchMap(q => this.api.search(q).pipe(
      catchError(() => of([]))           // catch INSIDE so the outer stream survives
  )),
  shareReplay({ bufferSize: 1, refCount: true })
);

// Prevent double-submit without disabling the button
this.saveClicks$.pipe(
  exhaustMap(() => this.api.save(this.form.value))
).subscribe();
Why switchMap matters — a real bug

The user types "ja" then "java". If the "ja" request is slower, its results arrive after "java" and overwrite the correct list. Users see the wrong results flicker in. switchMap cancels the outdated request, which fixes it.

Say this

"switchMap for anything where only the latest matters, exhaustMap for submit buttons. Getting these two wrong causes race conditions users see as flickering or duplicate submissions."

How do you avoid memory leaks from subscriptions?
In simple words

If you subscribe to a stream that never ends, and the component is destroyed, the subscription keeps running — still holding a reference to the dead component and still reacting to events. Navigate back and forth 20 times and you have 20 live subscriptions.

// 1. BEST - let the template manage it. AsyncPipe unsubscribes automatically.
<div *ngIf="orders$ | async as orders">...</div>

// 2. takeUntilDestroyed (Angular 16+) - no boilerplate at all
constructor(){
  this.svc.updates$.pipe(takeUntilDestroyed()).subscribe(...);
}
// outside an injection context (e.g. in ngOnInit):
private destroyRef = inject(DestroyRef);
ngOnInit(){
  this.svc.updates$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(...);
}

// 3. The classic pattern (still common in older codebases)
private destroy$ = new Subject<void>();
ngOnDestroy(){ this.destroy$.next(); this.destroy$.complete(); }
this.x$.pipe(takeUntil(this.destroy$)).subscribe(...);   // takeUntil must be LAST in the pipe

Which ones actually leak?

Only streams that never complete: valueChanges, router.events, interval, websockets, and any Subject in a service. A single HttpClient call completes by itself, so it isn't a leak — though it can still call back into a destroyed component.

Say this

"AsyncPipe first, takeUntilDestroyed second, manual subscribe only when I genuinely need a side effect. If I see .subscribe() without one of those in review, I ask about it."

Subject vs BehaviorSubject vs ReplaySubject.
In simple words
  • Subject — a live radio broadcast. Tune in late and you missed what was said.
  • BehaviorSubject — a radio that replays the last thing said as soon as you tune in. It needs an initial value. The default choice for state.
  • ReplaySubject(n) — replays the last n messages to anyone who joins.
  • AsyncSubject — says nothing until the broadcast ends, then gives only the final value. Rare.
@Injectable({ providedIn: 'root' })
export class UserStore {
  private readonly _user = new BehaviorSubject<User | null>(null);
  readonly user$ = this._user.asObservable();     // expose READ-ONLY

  setUser(u: User | null){ this._user.next(u); }
  get current(){ return this._user.value; }        // synchronous read
}

Always expose asObservable() so consumers can't call .next() — the same encapsulation argument as making a field private with a getter.

Say this

"BehaviorSubject was my go-to for shared state; for new code I'd use a signal instead, because it gives the same 'always has a current value' semantics with less ceremony and better change detection."

Cold vs hot observables, and what does shareReplay do?
In simple words

Cold = each subscriber gets its own private execution. Like everyone streaming a film separately — three viewers, three streams.

Hot = one execution shared by all subscribers. Like a live TV broadcast — one transmission, many viewers.

shareReplay converts cold to hot and remembers the last value for late subscribers.

// Without shareReplay: this HTTP call fires once PER subscriber
readonly config$ = this.http.get<Config>('/api/config');

// With: one call, result shared and replayed to anyone who subscribes later
readonly config$ = this.http.get<Config>('/api/config')
    .pipe(shareReplay({ bufferSize: 1, refCount: true }));
The subtle leak

shareReplay(1) — the plain number form — keeps the source subscription alive forever, even after every consumer has unsubscribed. On an interval or a websocket that's a genuine leak. Always use the object form with refCount: true, which tears down the source when the last subscriber leaves.

Say this

"refCount: true is the detail. I've seen a websocket kept open by a shareReplay(1) long after the component using it was destroyed."

combineLatest vs forkJoin vs withLatestFrom.
In simple words
  • forkJoin — "tell me when all of these are finished, and give me the final value of each". The RxJS equivalent of Promise.all. Only use with things that complete (like HTTP calls).
  • combineLatest — "tell me every time any of these changes, giving me the latest of all". Perfect for "filters + page + sort → reload".
  • withLatestFrom — "when this one fires, also give me the current value of the others". Perfect for "on submit, take the current form values".
// forkJoin: load everything a page needs, in parallel
forkJoin({
  order:     this.api.getOrder(id),
  customer:  this.api.getCustomer(custId),
  countries: this.api.getCountries()
}).subscribe(({ order, customer, countries }) => { ... });

// combineLatest: any filter change reloads the table
readonly rows$ = combineLatest({
  filters: this.filters$,
  page:    this.page$
}).pipe(
  debounceTime(50),                                     // batch rapid changes
  switchMap(({ filters, page }) => this.api.search(filters, page))
);
Two traps
  • forkJoin gives you nothing at all if any source errors — guard each with its own catchError.
  • combineLatest emits nothing until every source has emitted at least once. If one never fires, your page stays blank forever. Give each source a startWith(...).
How do you handle errors in RxJS without killing the stream?
In simple words

In RxJS an error is terminal — it ends the stream permanently. So if a search request fails and you catch it at the outer level, your valueChanges pipeline is dead: typing does nothing for the rest of the session.

The fix is to catch the error inside the inner observable, so only that one request fails and the outer stream keeps flowing.

// WRONG - one failure kills the search box permanently
this.query$.pipe(
  switchMap(q => this.api.search(q)),
  catchError(() => of([]))         // outer catch - stream now COMPLETE
);

// RIGHT - catch inside
this.query$.pipe(
  switchMap(q => this.api.search(q).pipe(
      catchError(err => { this.toast.error(err); return of([]); })
  ))
);

// Retry only server errors, with exponential backoff + jitter
this.api.get().pipe(
  retry({
    count: 3,
    delay: (err, i) => err.status >= 500
        ? timer(500 * 2 ** i + Math.random() * 200)
        : throwError(() => err)        // don't retry 4xx - it'll fail identically
  })
);

Also worth knowing: EMPTY (complete quietly), throwError(() => e), and finalize() for cleanup like hiding a spinner whatever the outcome.

Say this

"Inner catch versus outer catch is the distinction. It's caused a real bug for me — a failed autocomplete request silently disabled the whole search box until the user refreshed."

How does Angular change detection work?
In simple words

Angular needs to know when your data changed so it can update the screen. It uses Zone.js, which patches every asynchronous browser API — clicks, timers, HTTP. When any of them completes, Zone.js tells Angular: "something might have changed, go check".

Angular then walks the component tree from the root downwards, re-evaluating every template binding and comparing it with the previous value. If a value differs, it updates that piece of DOM.

Note what that means: one click anywhere can trigger a check of every component in the app. That's why performance work in Angular is mostly about reducing this.

The dev-mode double check

In development Angular runs the check twice and throws ExpressionChangedAfterItHasBeenCheckedError if a bound value differs between the two passes. That means you changed state during the checking phase — usually in ngAfterViewInit. Fixes: move the change to ngOnInit, defer it with queueMicrotask/setTimeout, or call cdr.detectChanges() deliberately.

Say this

"Zone.js is coarse by design — it can't know what changed, only that something async happened. Signals and zoneless change detection replace that guesswork with precise notification, which is why they're the direction Angular is moving."

What is OnPush change detection and when does it re-render?
In simple words

Default strategy: "check this component every time anything happens anywhere". OnPush: "skip this component and its children unless I have a specific reason".

The catch — and the thing interviews test — is that OnPush watches the reference, not the contents. Change a property on the same object and OnPush sees no change at all.

An OnPush component re-renders only when:

  1. An @Input() receives a new reference.
  2. An event fires from inside its own template (a click, an input).
  3. An | async pipe in its template emits.
  4. You explicitly call cdr.markForCheck().
  5. A signal read in its template changes.
@Component({ changeDetection: ChangeDetectionStrategy.OnPush, ... })

// This does NOT update an OnPush child - same object reference
this.user.name = 'New Name';

// This DOES - new object, new reference
this.user = { ...this.user, name: 'New Name' };

// Same for arrays
this.items.push(x);                  // ❌ no update
this.items = [...this.items, x];     // ✅ updates

Which is why OnPush goes hand-in-hand with immutable updates.

markForCheck() marks this component and its ancestors dirty for the next cycle. detectChanges() runs the check on this component now. detach()/reattach() gives full manual control for extreme cases like a high-frequency price ticker.

Say this

"I default every presentational component to OnPush. OnPush plus track plus the async pipe removes most Angular performance problems before they exist — and it forces immutable updates, which makes state easier to reason about anyway."

How do you optimise a slow Angular page?
In simple words

Angular slowness is nearly always one of three things: too many change-detection checks, too many DOM nodes, or too much JavaScript downloaded. Measure first to find out which.

Measure

Angular DevTools profiler (shows exactly which components re-render and how long they take), Chrome Performance panel, Lighthouse, and source-map-explorer for bundle size.

Fix — change detection

  • ChangeDetectionStrategy.OnPush everywhere.
  • No method calls or getters in templates — use a pure pipe or a computed().
  • track on every list.
  • Run high-frequency work outside Angular: ngZone.runOutsideAngular(() => ...) for scroll, mousemove, animation loops and third-party charts.

Fix — DOM volume

  • Virtual scrolling (cdk-virtual-scroll-viewport) — render 20 rows instead of 5,000.
  • Pagination; @defer for below-the-fold widgets.

Fix — network and bundle

  • Route-level lazy loading; standalone components; bundle budgets in CI.
  • Import individual functions, not whole libraries (import debounce from 'lodash-es/debounce').
  • debounceTime on inputs; shareReplay to avoid duplicate calls; ask the API for fewer fields.
  • SSR/hydration for first-paint-sensitive pages; brotli; long-cache hashed assets.
Say this — a story that lands

"Our orders grid took about 4 seconds to filter. DevTools showed the entire tree re-rendering on every keystroke. We added OnPush and track, moved the filter to debounceTime(300) + switchMap, and virtualised the table. Filtering dropped to around 200ms, and lazy-loading the reporting module cut the initial bundle by about 30%."

What state management do you use — and do you always need NgRx?
In simple words

State management is just "where does shared data live, and who is allowed to change it". You should escalate only as far as the app actually needs.

The ladder

  1. Component state — a signal or a plain field. Most state belongs here.
  2. A service with signals or a BehaviorSubject — shared between a few components. This covers the majority of enterprise apps.
  3. A lightweight store — NgRx SignalStore, Elf.
  4. Full NgRx — actions, reducers, effects, selectors.

When full NgRx earns its cost

Many components share and mutate the same state; you need time-travel debugging or an audit trail of every state change; complex async orchestration; or a large team that benefits from enforced conventions. Not for a CRUD app with six screens — the boilerplate will exceed the benefit.

// The NgRx flow, in one comment block:
// Component --dispatch(Action)--> Reducer (pure function) --> new State
//                            \--> Effect (side effect: HTTP) --> new Action
// Component <--Selector (memoised)-- Store

export const loadOrders = createAction('[Orders] Load');
export const loadOrdersSuccess =
    createAction('[Orders] Load Success', props<{ orders: Order[] }>());

loadOrders$ = createEffect(() => this.actions$.pipe(
  ofType(loadOrders),
  switchMap(() => this.api.list().pipe(
    map(orders => loadOrdersSuccess({ orders })),
    catchError(err => of(loadOrdersFailure({ err })))
  ))
));
Say this

"We evaluated NgRx and chose a signal-based service store instead, because we had eight screens and little shared state. I documented the criteria that would trigger adopting NgRx, so it's a decision we can revisit with evidence rather than preference."

How do you test Angular components and services?
// SERVICE test - fake the HTTP layer, assert the request AND the response handling
TestBed.configureTestingModule({
  providers: [provideHttpClient(), provideHttpClientTesting()]
});
const httpMock = TestBed.inject(HttpTestingController);

service.list().subscribe(res => expect(res.length).toBe(2));

const req = httpMock.expectOne('/api/orders');
expect(req.request.method).toBe('GET');
req.flush([{ id: 1 }, { id: 2 }]);          // supply the fake response
httpMock.verify();                           // fail if any unexpected call was made
// COMPONENT test
await TestBed.configureTestingModule({
  imports: [OrderListComponent],                        // standalone: import it
  providers: [{ provide: OrderApi, useValue: apiSpy }]
}).compileComponents();

const fixture = TestBed.createComponent(OrderListComponent);
fixture.detectChanges();                                // runs ngOnInit and renders

expect(fixture.nativeElement.querySelectorAll('tr').length).toBe(3);

// ASYNC - control time instead of waiting
it('debounces the search', fakeAsync(() => {
  typeInto('ja');
  tick(300);                                            // advance the virtual clock
  expect(apiSpy.search).toHaveBeenCalledTimes(1);
}));
Say this

"I test through the rendered DOM — query by text or role rather than internal CSS classes — so a styling refactor doesn't break the test. And fakeAsync/tick rather than real waiting, which is what keeps the suite fast and stable."

How do you keep an Angular app secure?
  • XSS — Angular sanitises by default and escapes interpolation. The danger points are [innerHTML] with user content and bypassSecurityTrustHtml. Add a strict Content-Security-Policy header.
  • Token storage — in memory, or an HttpOnly cookie. localStorage is readable by any injected script.
  • CSRF — Angular's HttpClient automatically reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN; pair that with Spring Security's CSRF config when you use cookie auth.
  • Guards are UX, not security — every rule is enforced again server-side.
  • No secrets in environment.ts — it ships to the browser. Anything in the bundle is public.
  • Dependenciesnpm audit, Renovate/Dependabot, and avoid unmaintained UI libraries.
Say this

"The one I raise most in review is a developer disabling sanitisation to render server-provided HTML. If we must render rich text, it gets sanitised server-side against an allow-list, not bypassed client-side."

16. TypeScript, Build & DevOps

Which TypeScript features do you rely on?
In simple words

TypeScript's value is catching mistakes at build time that JavaScript would only reveal in production. The features worth knowing are the ones that let the compiler prove things about your code.

// DISCRIMINATED UNION + exhaustive check - the most valuable pattern
type Result<T> =
  | { kind: 'ok';  value: T }
  | { kind: 'err'; error: string };

function render<T>(r: Result<T>): string {
  switch (r.kind) {
    case 'ok':  return String(r.value);     // TS knows 'value' exists here
    case 'err': return r.error;             // and 'error' exists here
    default:
      const _exhaustive: never = r;         // adding a new kind = COMPILE ERROR here
      return _exhaustive;
  }
}

// UTILITY TYPES - derive types instead of duplicating them
Partial<T>  Required<T>  Readonly<T>  Pick<T,K>  Omit<T,K>
Record<K,V>  ReturnType<F>  Awaited<P>  NonNullable<T>

type OrderForm = Omit<Order, 'id' | 'createdAt'>;    // stays in sync with Order

// GENERIC with a constraint
function byId<T extends { id: string }>(list: T[], id: string): T | undefined {
  return list.find(x => x.id === id);
}

// TYPE GUARD - narrows 'unknown' safely
function isOrder(x: unknown): x is Order {
  return !!x && typeof (x as Order).id === 'string';
}

// 'as const' union instead of enum - simpler output, better tree-shaking
const STATUSES = ['OPEN', 'CLOSED', 'CANCELLED'] as const;
type Status = typeof STATUSES[number];      // 'OPEN' | 'CLOSED' | 'CANCELLED'

Compiler settings I insist on

strict: true, which turns on strictNullChecks and noImplicitAny, plus noUncheckedIndexedAccess. Prefer unknown over any — it forces you to narrow before use.

Say this

"On a legacy codebase I turned strict mode on incrementally, folder by folder, rather than in one commit. It found real null bugs, and the incremental approach meant the team never faced a 400-error build."

interface vs type in TypeScript?
In simple words

interface describes the shape of an object and can be extended and merged. type can describe anything — unions, intersections, tuples, mapped and conditional types.

My rule: interface for object shapes you might extend, type for everything else, especially unions. Both are erased at runtime — neither validates incoming JSON, so if you need runtime validation use zod, or generate the client from OpenAPI.

Say this

"Types don't exist at runtime, which surprises people — an API can return anything and TypeScript won't notice. That's why I generate API clients from the OpenAPI spec: it keeps frontend types honest against what the backend actually returns."

Maven: what does a good build look like?
In simple words

Maven runs a fixed lifecycle: validate → compile → test → package → verify → install → deploy. Running a later phase runs all the earlier ones.

  • package builds the jar into target/; install also puts it in your local repository so other local projects can use it.
  • Dependency conflicts: Maven picks the version nearest in the dependency tree, which is why an old jar can appear unexpectedly. mvn dependency:tree shows you; a <dependencyManagement> section or a BOM pins it.
  • Scopes: compile, provided (present at runtime from the container), runtime, test.
  • I use surefire for unit tests and failsafe for *IT integration tests so slow tests don't run on every compile, plus jacoco with a coverage gate and OWASP dependency-check.
Say this

"mvn dependency:tree -Dincludes=... is the command I reach for whenever there's a NoSuchMethodError — it's almost always two versions of the same library on the classpath."

Describe your CI/CD pipeline.

On a pull request

Compile → unit tests → static analysis (SonarQube quality gate on new code) → dependency and secret scanning → integration tests with Testcontainers → build the image → deploy to an ephemeral environment → smoke and contract tests.

On merge to main

Version, publish the image, deploy to staging, run E2E, then a gated canary release to production with automatic rollback on error rate.

Principles

  • The pipeline is the only path to production — no manual deploys.
  • Build the artifact once and promote the same one through environments; inject configuration per environment.
  • Fail fast, and keep it under ~10 minutes — beyond that, people start working around it.
Say this

"Quality gate on new code rather than overall coverage. A legacy module at 20% shouldn't block a well-tested change, but nobody gets to add untested code either."

Git: branching strategy, and how do you recover from mistakes?
In simple words

Trunk-based development with short-lived branches: everyone merges to main at least daily, and unfinished work hides behind feature flags. Long-lived branches cause painful merges and delay integration problems until they're expensive.

CommandWhen I use it
git rebase -iClean up my own commits before review
git cherry-pickApply a hotfix to a release branch
git revertUndo a commit that's already shared — safe, creates a new commit
git bisectBinary-search history to find which commit introduced a bug
git reflogRecover commits after a bad reset — the "I thought I lost my work" button
Rule

Rebase your own local branch freely; never rebase or force-push a branch other people are working on. To undo something already pushed, use revert, not reset --hard.

How do you deploy Angular alongside a Java backend?
In simple words

Two options. Separate: Angular is built to static files and served by a CDN or Nginx; the API lives behind the same gateway under /api. Bundled: the Angular build output is copied into the Spring Boot jar's static/ folder so one artifact ships everything.

I default to separate: independent deploys, CDN caching, and no JVM restart to ship a CSS fix.

Either way, get these right

  • Caching: hashed filenames with a long cache header; index.html with no-cache so a new deploy is picked up immediately.
  • SPA fallback: try_files $uri /index.html, otherwise a deep link like /orders/42 returns 404 on refresh.
  • Runtime config: fetch /assets/config.json at bootstrap instead of baking the API URL into the bundle — so the same artifact runs in dev, staging and production.
Say this

"Runtime config rather than build-time environment files is the change that makes 'build once, promote everywhere' actually work for the frontend."

17. System Design & Technical Design

The JD's first bullet is "analyse business requirements and prepare technical design" — so expect a design discussion.

What framework do you use for any system design question?
In simple words

Don't start drawing. Interviewers are scoring your process, not your diagram. Use the same eight steps every time so you never freeze.

  1. Clarify (2–3 minutes). Who uses it? What's the core use case? How many users/requests? Read-heavy or write-heavy? Latency budget? Consistency needs? Compliance?
  2. Define the contract. The 4–6 APIs or events involved.
  3. Estimate. 1M requests/day ≈ 12 per second average — plan for 5–10× peak. Storage per record × records per day × retention.
  4. High-level design. Client → gateway → services → data stores → async pipeline.
  5. Data model. Tables, keys, indexes, partitioning.
  6. Deep dive on the one or two hard parts they seem interested in.
  7. Scale and failure. Caching, replication, queues, idempotency, retries, circuit breakers. What happens when each component dies?
  8. Trade-offs and next steps. State clearly what you deliberately did not build and why.
Say this throughout

Narrate trade-offs continuously: "I'd accept eventual consistency here because the reporting view can be two seconds behind, but the balance check must read the primary." That sentence pattern is what scores.

Design an order management system.
Why this one

It's the most likely scenario for a Java + Angular enterprise role, and it exercises everything: REST design, transactions, events, consistency and UI performance.

1. Contract

POST  /api/v1/orders            (Idempotency-Key header)  -> 202 Accepted
GET   /api/v1/orders?status&page&size
GET   /api/v1/orders/{id}
PATCH /api/v1/orders/{id}/cancel

Events: OrderPlaced, PaymentAuthorised, StockReserved, OrderConfirmed, OrderFailed

2. Services

Angular SPA → API Gateway (auth, rate limiting) → Order service (system of record) → events → Payment, Inventory, Notification.

3. The flow

  1. Order service writes the order as PENDING and an outbox row, in one local transaction.
  2. The outbox publisher emits OrderPlaced.
  3. Payment and Inventory react; a saga orchestrator advances the order to CONFIRMED, or issues compensations (release stock, refund) and sets FAILED.
  4. The client gets 202 immediately and either polls GET /orders/{id} or receives an SSE/websocket update — so a slow payment provider never blocks the UI.

4. Data

Postgres: orders + order_lines. Indexes on (customer_id, created_at DESC) and (status, created_at). @Version on the order for cancel/update races. An idempotency_keys table with a unique constraint.

5. Scale & resilience

Read replica for list/reporting queries; Redis for product and pricing reference data; Kafka partitioned by customerId to preserve per-customer ordering; DLQ plus retry topic; circuit breaker on Payment; rate limiting at the gateway.

6. Frontend

Lazy-loaded orders feature; OnPush and virtual scroll on the grid; keyset pagination; filters stored in the URL so refresh and back work; optimistic UI on cancel with rollback if the server rejects it.

Say this

"The two decisions I'd defend hardest are returning 202 rather than blocking on payment, and the outbox — because both are about not letting a third party's latency become our availability problem."

Design a file upload and processing feature.
The key insight

Don't send the file through your Java service. Have Angular upload directly to object storage using a short-lived pre-signed URL that your backend issues. The file never touches your JVM heap, so memory and request timeouts stay bounded, and you get resumable chunked uploads for free.

  1. Angular asks the backend for a pre-signed upload URL.
  2. Angular uploads directly to S3/blob storage, showing progress.
  3. Angular tells the backend "done" → the backend creates a job row (QUEUED) and publishes a message.
  4. A separate worker deployment (scaled independently) streams the file, validates it, and processes it in chunks with Spring Batch — restartable, with skip and retry policies.
  5. Angular polls GET /jobs/{id} or subscribes to SSE for progress.
  6. Failures produce a downloadable error report keyed by row number — which is what business users actually need.

Guardrails: size and MIME allow-list, virus scan, tenant-scoped storage paths, TTL on temporary objects, and a cap on concurrent jobs per tenant so one customer can't starve the queue.

Say this

"Separating the worker from the API is what makes this safe — a 2GB upload can't affect the latency of the API serving everyone else."

How would you migrate a legacy monolith to microservices?
In simple words — the strangler fig

Put a gateway in front of the monolith so requests can be routed per-URL. Then extract one capability at a time, redirect its routes to the new service, and repeat. The monolith shrinks gradually instead of being rewritten in one terrifying release.

  1. Find the seams by bounded context and by pain — what changes most often, what needs to scale differently.
  2. Extract the lowest-coupling, highest-value capability first, to prove the pipeline, observability and deployment story before attempting anything hard.
  3. Decouple the data. The new service owns its own schema. Use CDC or an outbox to keep the monolith's view in sync during the transition — this is the hardest part, not the code.
  4. Run both in parallel with traffic shadowing and compare outputs before cutting over.
  5. Delete the old code. An extraction isn't finished until the monolith path is removed — otherwise you're maintaining both.
Say this

"I'd agree the success metrics up front — deployment frequency, lead time, change-failure rate. If splitting isn't improving those, that's evidence to stop, not to split harder."

How do you write a technical design document? (JD bullet)

My template

  1. Context and problem — what's broken or needed, in business terms.
  2. Goals / non-goals — non-goals prevent scope arguments later.
  3. Requirements — functional, plus NFRs: performance, availability, security, compliance, with numbers.
  4. Proposed design — component and sequence diagrams, API/event contracts, data model.
  5. Alternatives considered and why rejected. This is the section reviewers actually read.
  6. Risks and mitigations.
  7. Rollout plan — feature flags, backfill, rollback.
  8. Testing strategy and observability — which dashboards and alerts we add.
  9. Open questions with named owners.

Process

Circulate at 60% complete for comments — a polished document invites cosmetic feedback rather than real challenge. Walk the BA and QA through the flows; they catch requirement gaps developers miss. Record decisions as short ADRs so the "why" survives after people leave.

Say this

"The alternatives section is the one that saves time later — otherwise the same 'why didn't we just use X' question resurfaces every six months."

How do you turn vague non-functional requirements into a design?
In simple words

"Fast" and "always available" aren't requirements — they're feelings. Your job is to turn them into numbers with the BA, because the numbers determine the architecture.

They sayYou agreeWhat it decides
"It must be fast"p95 < 400ms at 200 requests/secCaching, index strategy, sync vs async
"It can't go down"99.9% = ~43 min/monthMulti-AZ? Active-active? Or just fast restarts?
"It should scale"3× current volume within 12 monthsPartitioning, statelessness, autoscaling
"It must be secure"PII encrypted at rest, audit trail, 7-year retentionKey management, audit tables, archival

Then verify the claims with load tests (Gatling, k6, JMeter) against a production-like environment as part of the release process. A design claim you haven't load tested is a guess.

Say this

"99.9% versus 99.99% is a completely different architecture and cost. Getting that number agreed early is one of the highest-value conversations in the whole project."

Design the front-end architecture for a large Angular application.

Layering

core/          auth, interceptors, error handling, config   (provided once)
shared/        presentational components, pipes, directives (imports NO feature)
features/
  orders/
    data-access/   typed API client + store
    ui/            dumb components
    orders.routes.ts
models/

Rules that keep it maintainable with 30 developers

  • Generated API clients from OpenAPI, so frontend types can't drift from the backend.
  • A design system library (Angular Material or internal) so UI is consistent and accessible by default.
  • Boundary rules — ESLint import restrictions, or Nx tags — so one feature can't import another feature's internals.
  • OnPush by default; strict TypeScript; bundle budgets in CI.
  • Shared components documented in Storybook, so people find them instead of rebuilding them.

For very large organisations: an Nx monorepo with affected-only builds. Micro-frontends via module federation only when separate teams genuinely need separate release cadences — the runtime and version-alignment costs are real.

Say this

"The duplicate-component problem is what actually kills large frontends. Storybook plus a documented shared library is the cheapest fix — people reuse what they can find."

18. Behavioral, Agile & Code Review

At 10+ years this round often decides the offer. Use STAR — Situation, Task, Action, Result — and always land on a number.

"Tell me about yourself." — your 90-second opener.
The structure: now → then → why here

Don't narrate your CV chronologically. Start with what you do now, add one concrete achievement with a number, and finish with why this role specifically.

A template to adapt

"I'm a full stack engineer with 10+ years building enterprise applications, currently on a Java 17 / Spring Boot backend with an Angular front end for [domain].

My work splits roughly in half: design and backend — working with BAs to turn requirements into a technical design, then building REST services and the JPA persistence layer — and Angular, where I own the [X] module including its state management and performance.

The thing I'm most pleased with recently is [one achievement with a number — e.g. cutting p95 latency on our orders API from 1.8s to 300ms by fixing N+1 queries and adding a Redis cache]. I also own our code review standards and mentor two junior developers.

I'm interested in this role because it's the same combination of design ownership and hands-on Java and Angular, and [something specific about their product or scale]."

Rehearse this out loud

It sets the frame for the whole interview. Every technical term you drop in it — "N+1", "state management" — is an invitation for the follow-up questions you want to be asked.

"Describe a challenging technical problem you solved."
Use STAR, and spend most of the time on Action

Situation (1 sentence) → Task (1 sentence) → Action (most of the answer) → Result (with numbers).

Worked example

S: "Our order search endpoint started timing out at month-end. Support tickets spiked and ops were restarting pods twice a day."

T: "I owned the investigation and fix, with two days before the next month-end."

A: "I correlated the APM trace with the database slow-query log and found two problems. First, a lazy association was causing about 400 queries per page — a classic N+1. Second, we were using OFFSET pagination, which degrades as data grows. I replaced the fetch with an entity graph plus a DTO projection so we fetched only the columns the screen needed, moved to keyset pagination, and added a composite index on (status, created_at). I also added a query-count assertion in the integration test so it can't silently regress, and a Grafana panel with a p95 alert per endpoint."

R: "p95 went from 9 seconds to 240ms, database CPU dropped about 35%, and we deleted the restart runbook. I wrote it up as a tech share, and the team found the same pattern in two other modules."

Have your own version ready with real numbers. "It became much faster" is the single most common way senior candidates lose points here.
How do you conduct a code review and give constructive feedback? (JD bullet)
In simple words

Two halves: what you look for, and how you say it. Most candidates only answer the first, and the second is what tells the interviewer whether you're pleasant to work with.

What I look for, in priority order

  1. Does it solve the actual requirement? (Read the ticket first.)
  2. Correctness and edge cases — null, empty, boundary, concurrency.
  3. Tests that would catch a regression.
  4. Security — authorisation including object ownership, injection, secrets, PII in logs.
  5. Performance — N+1, unbounded queries or collections, missing index, blocking call in a hot path.
  6. Readability and naming; consistency with existing patterns.
  7. Observability — will we be able to debug this in production?

How I give the feedback

  • I label every comment [blocking], [suggestion] or [nit], so the author knows what actually stops the merge. This one habit removes most review friction.
  • Ask, don't decree: "what happens if the list is empty here?" rather than "this is wrong".
  • Praise good solutions publicly — reviews shouldn't be purely negative.
  • If a thread goes beyond two rounds, move it to a 10-minute call. Long text arguments burn goodwill and rarely converge.
  • Adjust to the person: for a junior I explain the why and link a reference; for a peer I flag the risk and let them decide.

What I ask of authors

Small PRs (under ~400 lines), a description with context and testing notes, self-review before requesting review, and no mixing a refactor with a behaviour change in one diff.

Say this

"The blocking/suggestion/nit labelling was the change that most improved our review culture — before that, authors treated every comment as mandatory and reviews took days."

"Tell me about a disagreement with a teammate."
What they're testing

Not whether you were right — whether you can turn an opinion argument into an evidence question, and whether you can lose gracefully.

Worked example

"A colleague wanted to introduce NgRx across the whole application. I thought it was overhead for our eight screens. Rather than argue it in pull request comments, we agreed on the criteria first — how much genuinely shared state we had, expected growth in 12 months, and team familiarity — and then time-boxed a two-day spike implementing one feature both ways.

The service-with-signals version was around 40% less code and everyone on the team could read it, so we went with that and documented the conditions under which we would adopt NgRx later. The point is we made it a testable question rather than a preference contest. I also made sure he presented the spike results, since he'd done half the work."

How do you handle a production incident?
The one rule: mitigate first, diagnose second.

Your first job is to stop the bleeding, not to understand it. Understanding comes after the users are OK.

  1. Communicate immediately — impact, and when the next update will come. Silence during an incident is what damages trust.
  2. Stabilise — roll back, disable the feature flag, scale out, or fail over.
  3. Preserve evidence before restarting — heap dump, thread dump, logs, trace ids. A restart destroys the only copy of the cause, and then it happens again next week.
  4. Root cause with the data you captured.
  5. Fix properly, with a test that reproduces the bug.
  6. Blameless post-mortem with concrete actions and named owners. The action is almost always a missing alert, a missing timeout, or a missing test.
Have one ready

"A missing read timeout on a third-party call exhausted the Tomcat thread pool. We mitigated with a rollback in 12 minutes. The real fix was timeouts plus a Resilience4j circuit breaker on every external client, and a checklist item so a new HTTP client can't be merged without them."

How do you work with BAs, QA and support? (JD bullet)

With the BA

I get involved before the story is finalised and ask about the edge cases people forget: partial data, duplicates, timezones, permissions, what happens on failure. I turn acceptance criteria into Given/When/Then so QA and I are testing the same thing. And I show a UI prototype or the Swagger contract early — people react far better to something concrete than to a document.

With QA

I share the technical design and specifically point out the risky areas so they can target exploratory testing there. I make sure they have test data and access to feature flags. When they raise a defect I reproduce it with a failing automated test first. On non-trivial stories I run a "three amigos" kickoff — BA, developer, QA — which is the cheapest defect prevention available.

With support

I make the system diagnosable: correlation ids in error messages that the user can quote, runbooks for known failure modes, and log levels adjustable at runtime. I also join the support rotation periodically — nothing improves your design instincts faster than answering for your own code on a Monday morning.

Say this

"The three amigos session is the single practice I'd bring to a new team. Fifteen minutes at the start of a story routinely saves a day of rework at the end of it."

Describe your role in Scrum ceremonies. (JD bullet)
  • Refinement — I push for clarity before estimation. I'll say "I can't size this until we know whether the export is synchronous". I flag technical dependencies and propose splitting fat stories into thin vertical slices that each deliver something usable.
  • Planning — realistic capacity, an explicit definition of done (code + tests + docs + observability + reviewed), and I make sure some NFR/tech-debt work is in every sprint rather than deferred to a mythical cleanup sprint.
  • Daily stand-up — focused on flow and blockers, not status theatre. If something's stuck more than a day, I raise it rather than repeating "still working on it".
  • Review/demo — I demo from the user's perspective, not the code's.
  • Retro — I bring data: cycle time, escaped defects, flaky test count. And I commit to one improvement that I personally own. Retros fail when actions have no owner.
Say this

"I'm pragmatic about process. If a ceremony isn't producing value, I'd rather change it in the retro than perform it — but I'd change it deliberately, with the team, not by quietly opting out."

How do you mentor and share knowledge? (JD bullet)
Give mechanisms, not intentions

"I like helping people" is unmeasurable. Name the specific things you set up.

  • Pairing on their first tickets in an unfamiliar area — an hour of pairing beats a day of review comments.
  • Reviews that teach: explain the why and link a reference, rather than just marking it wrong.
  • A rotating 30-minute tech share each sprint. Mine have been virtual threads, JPA fetch strategies and Angular signals.
  • ADRs and a living onboarding document, which each new joiner updates as their first pull request — it's a useful task and it tests the doc.
  • Deliberately handing over a design I could do faster myself, then supporting it. The goal is a second person who can own it, not a hero.
Say this

"The measurable outcome: the module I owned alone last year now has three people comfortable changing it, and my review turnaround stopped being a bottleneck for the team."

"Tell me about a time you failed."
Rules for this answer

Pick a real and moderately serious failure — a trivial one reads as evasion. Own it without excuses, and spend most of the answer on what changed afterwards.

Worked example

"I shipped a schema migration that added a NOT NULL column with a default to a 40-million-row table, during business hours. It locked the table for several minutes and caused timeouts across the application.

I rolled back immediately, then re-ran it off-peak in the expand/contract style — a nullable column first, backfilled in batches, then the constraint added separately. Afterwards I added a migration checklist to our PR template requiring an estimated lock time and row count for any DDL, and we added a staging dataset closer to production volume.

The lesson I actually internalised was that 'it worked in staging' means nothing when staging has ten thousand rows. We haven't had a migration incident since."

How do you prioritise technical debt against features?
In simple words

Frame debt in business terms, not moral ones. "The code is ugly" loses to a feature every time. "This costs us two days per sprint and caused last month's incident" doesn't.

  • Keep a visible debt register with an estimated cost: hours lost per sprint, incident risk, onboarding friction.
  • Propose a standing allocation — typically 15–20% of each sprint — rather than negotiating from scratch every time.
  • Give debt work a measurable outcome: "cut build time from 14 minutes to 5", "remove the flaky tests causing 3 pipeline reruns a week".
  • Accept that not all debt needs repaying. Debt in a stable module nobody touches is fine. Prioritise debt in the code you're changing most.
Say this

"I attach debt to the feature that's affected by it wherever possible — 'this story takes 3 days, or 5 days if we also fix the thing that will otherwise bite us again next sprint'. That's a decision a product owner can actually make."

How do you handle unclear requirements or scope changes mid-sprint?
Say this

"I don't start building on an assumption. I write the assumption down, state its impact, and get a decision — usually within a day, because I ask early rather than when I'm blocked.

If scope changes mid-sprint, I make the trade-off explicit and let the product owner choose what comes out to make room. I keep a small buffer for the unexpected. And if something is genuinely urgent, I'd rather cut scope within the story — ship the happy path behind a feature flag — than silently extend the sprint and surprise everyone at review."

"Why are you leaving?" and "What questions do you have for us?"
Why leaving

Forward-looking, never critical of your current employer — criticism reads as "this is how they'll talk about us". "I've had a good run and delivered [X]; I'm looking for [larger scale / more design ownership / a domain I find more interesting], which this role has."

Questions to ask them — this is scored, so have 4–5 ready

  • "What does the delivery pipeline look like — how long from merge to production, and who can deploy?"
  • "How is technical design decided: architects, RFCs, or the team?"
  • "What's the current state of automated testing, and what's the biggest source of production incidents?"
  • "Which Java and Angular versions are you on, and is there an upgrade plan?"
  • "How do developers, BAs and QA collaborate on a typical story?"
  • "What would success look like for this person in the first six months?"
Why these work

They're the questions someone who has actually shipped software asks. They also tell you a lot: an evasive answer about production incidents or upgrade plans is real information about the job.

19. Final Prep Checklist

The 20 answers to have polished before you walk in.
  1. Your 90-second intro, with one measurable achievement.
  2. HashMap internals, and ConcurrentHashMap's per-bucket locking.
  3. A groupingBy stream problem, coded live without hesitating.
  4. Thread pool sizing, and why you avoid Executors.newFixedThreadPool.
  5. volatile vs atomic vs synchronized, with happens-before.
  6. How you debugged an OOM or a memory leak, naming the tools.
  7. Spring bean lifecycle, and where AOP proxies are applied.
  8. The three ways @Transactional silently does nothing.
  9. N+1: how you detect it and four ways to fix it.
  10. Optimistic vs pessimistic locking, with the @Version code.
  11. Your global exception handling and error contract.
  12. The OAuth2/JWT flow for an Angular SPA, and where the token lives.
  13. Circuit breaker states and a meaningful fallback.
  14. Saga plus transactional outbox, and why the outbox is needed.
  15. Your test pyramid, and why Testcontainers over H2.
  16. An EXPLAIN ANALYZE story about a slow query.
  17. switchMap vs mergeMap vs concatMap vs exhaustMap.
  18. OnPush rules, and how you sped up a slow page.
  19. Signals vs RxJS and when you'd use each.
  20. A code review disagreement you handled well.
How to answer when you genuinely don't know.
Never bluff.

Senior interviewers detect it instantly, and it costs more than the missed question — because now everything else you said is suspect.

The shape that works: state the boundary → reason from what you do know → say how you'd find out

Example

"I haven't used Kafka Streams in production, so I'd be guessing on the exact API. What I do know is that it's a library rather than a separate cluster, that it keeps local state in RocksDB with a changelog topic for recovery, and that the partitioning rules are the same as a normal consumer group. If I needed it next week, I'd prototype a windowed aggregation against a Testcontainers Kafka and compare it with doing the same thing in a plain consumer plus Redis before committing to it."

That answer often scores better than a memorised correct one, because it demonstrates exactly what a 10-year hire is for: calibrated judgement and a method for handling the unknown.

Red flags to avoid in the room.
  • Answering "what is X?" with a definition and then stopping. Always add the trade-off and a use case.
  • Blaming previous teams or managers.
  • Claiming everything — "I've used all of those extensively". Depth beats breadth claims, and they will probe.
  • No numbers in any achievement.
  • Not asking any questions at the end.
  • Talking for four minutes without checking you're answering what was asked. Pause: "does that cover it, or would you like me to go deeper on the failure handling?"
  • Whiteboarding in silence. Narrate continuously — they're scoring your reasoning, and they can't score what they can't hear.
  • Arguing with the interviewer. Disagree once, with evidence, then offer to move on.

20. Written Test — Data Structures in Java

The programs that actually come up in a written/coding round for an experienced Java developer. Each one: what's being asked → how to think about it → code → output → complexity. Learn the pattern, not the answer — most questions are variations of about eight patterns.

Pattern cheat-sheet: how to recognise which technique to use
In simple words

Almost every coding question is one of a small number of shapes. If you can name the shape in the first 30 seconds, you've done the hard part.

If the question says…Reach for
"find a pair / triplet that sums to X"HashMap (one pass) or two pointers on a sorted array
"longest / smallest substring or subarray with a condition"Sliding window
"sorted array, find something"Two pointers or binary search
"count / group / find duplicates / frequency"HashMap
"first non-repeating / preserve order"LinkedHashMap
"top K / K largest"PriorityQueue (heap) of size K
"matching brackets / undo / reverse order"Stack (ArrayDeque)
"level by level in a tree" / "shortest path"Queue → BFS
"all paths / depth / recursion in a tree"DFS recursion
"cycle in a linked list" / "middle of the list"Slow & fast pointers
"in place, O(1) extra space"Two pointers / index swapping / XOR
Say this while coding

"Let me state the brute force first so we agree on correctness, then I'll optimise." Interviewers reward this — it shows you can always produce a working answer, and it buys you thinking time.

1. Reverse a string (without StringBuilder.reverse)
What's being asked

Turn "hello" into "olleh". They want to see if you can do it in place with two pointers rather than reaching for the library method.

Thinking

Put one finger at the start and one at the end. Swap those two characters, then move both fingers inward. Stop when they meet.

public static String reverse(String input) {
    char[] c = input.toCharArray();
    int left = 0, right = c.length - 1;

    while (left < right) {
        char tmp = c[left];
        c[left]  = c[right];
        c[right] = tmp;
        left++;
        right--;
    }
    return new String(c);
}

// Reverse the WORDS instead of the characters - a common variation
public static String reverseWords(String s) {
    String[] words = s.trim().split("\\s+");
    Collections.reverse(Arrays.asList(words));
    return String.join(" ", words);
}
reverse("hello") -> "olleh" reverseWords("the sky is blue") -> "blue is sky the"

Time O(n) · Space O(n) for the char array (O(1) extra if you're given a char[])

2. Check if a string is a palindrome (ignoring case and punctuation)
What's being asked

"A man, a plan, a canal: Panama" reads the same forwards and backwards once you ignore spaces, punctuation and case.

Thinking

Two pointers again — but this time, skip any character that isn't a letter or digit, and compare in lower case. Don't build a cleaned copy of the string unless asked; skipping in place is O(1) space.

public static boolean isPalindrome(String s) {
    int left = 0, right = s.length() - 1;

    while (left < right) {
        // skip non-alphanumeric from the left
        while (left < right && !Character.isLetterOrDigit(s.charAt(left)))  left++;
        // skip non-alphanumeric from the right
        while (left < right && !Character.isLetterOrDigit(s.charAt(right))) right--;

        if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right)))
            return false;
        left++;
        right--;
    }
    return true;
}
isPalindrome("A man, a plan, a canal: Panama") -> true isPalindrome("race a car") -> false isPalindrome("") -> true

Time O(n) · Space O(1)

3. First non-repeating character in a string
What's being asked

In "swiss", 's' repeats, 'w' doesn't. Answer: 'w'. The catch is that you must return the first such character, so insertion order matters.

Thinking

Count every character in one pass. Then walk the string again in order and return the first with a count of 1. A plain HashMap loses the order, so either re-scan the original string (simplest) or use a LinkedHashMap.

public static Character firstNonRepeating(String s) {
    Map<Character, Integer> counts = new HashMap<>();
    for (char c : s.toCharArray())
        counts.merge(c, 1, Integer::sum);        // put 1, or add 1 if present

    for (char c : s.toCharArray())               // second pass keeps original order
        if (counts.get(c) == 1) return c;

    return null;
}

// Stream version, using LinkedHashMap to preserve order
public static Character firstNonRepeatingStream(String s) {
    return s.chars().mapToObj(i -> (char) i)
            .collect(Collectors.groupingBy(c -> c, LinkedHashMap::new, Collectors.counting()))
            .entrySet().stream()
            .filter(e -> e.getValue() == 1)
            .map(Map.Entry::getKey)
            .findFirst().orElse(null);
}
firstNonRepeating("swiss") -> 'w' firstNonRepeating("aabbcc") -> null firstNonRepeating("racecars") -> 'e'

Time O(n) · Space O(k) where k = distinct characters

4. Count character / word frequency
What's being asked

Given text, produce a map of how many times each character (or word) appears. This is the "can you use a HashMap idiomatically" question.

// Characters
public static Map<Character, Long> charFrequency(String s) {
    return s.chars().mapToObj(i -> (char) i)
            .collect(Collectors.groupingBy(c -> c, Collectors.counting()));
}

// Words, case-insensitive, sorted by count descending
public static LinkedHashMap<String, Long> topWords(String text) {
    return Arrays.stream(text.toLowerCase().split("\\W+"))
        .filter(w -> !w.isBlank())
        .collect(Collectors.groupingBy(w -> w, Collectors.counting()))
        .entrySet().stream()
        .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
                                  (a, b) -> a, LinkedHashMap::new));
}

// The classic non-stream way - merge() is the cleanest
Map<String, Integer> counts = new HashMap<>();
for (String w : words) counts.merge(w, 1, Integer::sum);
charFrequency("hello") -> {e=1, h=1, l=2, o=1} topWords("the cat the dog the bird") -> {the=3, cat=1, dog=1, bird=1}

Time O(n) (O(n log n) if you sort) · Space O(k)

5. Check if two strings are anagrams
What's being asked

"listen" and "silent" use exactly the same letters. Two approaches — say both, then pick.

Thinking

Approach A (sort): sort both strings and compare — 3 lines, O(n log n). Approach B (count): count characters of the first, subtract the second; all counts must end at zero — O(n). Mention A, implement B.

public static boolean isAnagram(String a, String b) {
    if (a.length() != b.length()) return false;        // fast reject

    int[] counts = new int[256];                       // assume ASCII; use a Map for Unicode
    for (int i = 0; i < a.length(); i++) {
        counts[a.charAt(i)]++;                         // add for the first string
        counts[b.charAt(i)]--;                         // subtract for the second
    }
    for (int c : counts) if (c != 0) return false;
    return true;
}

// One-liner using sorting - fine to mention, slower
boolean quick = Arrays.equals(
        a.chars().sorted().toArray(), b.chars().sorted().toArray());
isAnagram("listen", "silent") -> true isAnagram("hello", "world") -> false

Time O(n) · Space O(1) — the array is a fixed 256 regardless of input size

6. Longest substring without repeating characters (sliding window)
What's being asked

In "abcabcbb", the longest run with no repeated character is "abc" — length 3. This is the canonical sliding-window problem; learn this one properly and several others become easy.

Thinking — the sliding window

Imagine a window over the string with a left and right edge. Move the right edge forward one character at a time. If the new character is already inside the window, jump the left edge past its previous position. The window always contains only unique characters, so its size is a candidate answer.

public static int longestUnique(String s) {
    Map<Character, Integer> lastSeen = new HashMap<>();   // char -> last index
    int best = 0, left = 0;

    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);

        // if we've seen c inside the current window, shrink from the left
        if (lastSeen.containsKey(c) && lastSeen.get(c) >= left) {
            left = lastSeen.get(c) + 1;
        }
        lastSeen.put(c, right);
        best = Math.max(best, right - left + 1);
    }
    return best;
}

Dry run on "abcabcbb"

right=0 'a'  window "a"    best=1
right=1 'b'  window "ab"   best=2
right=2 'c'  window "abc"  best=3
right=3 'a'  'a' seen at 0 -> left=1, window "bca"  best=3
right=4 'b'  'b' seen at 1 -> left=2, window "cab"  best=3
...
longestUnique("abcabcbb") -> 3 ("abc") longestUnique("bbbbb") -> 1 ("b") longestUnique("pwwkew") -> 3 ("wke")

Time O(n) — each pointer moves forward only · Space O(k)

7. Two Sum — find the pair adding to a target
What's being asked

Given [2,7,11,15] and target 9, return the indices [0,1] because 2+7=9. The most-asked coding question in existence.

Thinking

Brute force is two nested loops — O(n²). The trick: as you walk the array, for each number you know exactly what its partner must be (target - current). So keep a map of everything you've already seen and check whether the partner is in it. One pass, O(n).

public static int[] twoSum(int[] nums, int target) {
    Map<Integer, Integer> seen = new HashMap<>();   // value -> index

    for (int i = 0; i < nums.length; i++) {
        int needed = target - nums[i];
        if (seen.containsKey(needed))
            return new int[]{ seen.get(needed), i };
        seen.put(nums[i], i);                       // put AFTER checking
    }
    return new int[0];                              // no pair found
}

Dry run: nums=[2,7,11,15], target=9

i=0  num=2  needed=7  seen={}          -> not found, seen={2:0}
i=1  num=7  needed=2  seen={2:0}       -> FOUND! return [0,1]
twoSum([2,7,11,15], 9) -> [0, 1] twoSum([3,2,4], 6) -> [1, 2]

Time O(n) · Space O(n) — the classic trade of memory for speed

8. Find duplicates / remove duplicates from an array
// Find all duplicates - the elegant Set trick:
// set.add() returns FALSE if the value was already present
public static Set<Integer> findDuplicates(int[] nums) {
    Set<Integer> seen = new HashSet<>();
    Set<Integer> dups = new LinkedHashSet<>();
    for (int n : nums)
        if (!seen.add(n)) dups.add(n);
    return dups;
}

// Same idea with streams
Set<Integer> seen = new HashSet<>();
Set<Integer> dups = Arrays.stream(nums).boxed()
        .filter(n -> !seen.add(n))
        .collect(Collectors.toSet());

// Remove duplicates from a SORTED array in place, return the new length
public static int removeDuplicatesSorted(int[] nums) {
    if (nums.length == 0) return 0;
    int write = 1;                                   // position to write the next unique value
    for (int read = 1; read < nums.length; read++) {
        if (nums[read] != nums[write - 1]) {
            nums[write++] = nums[read];
        }
    }
    return write;
}

// Remove duplicates preserving order (unsorted)
List<Integer> unique = new ArrayList<>(new LinkedHashSet<>(list));
findDuplicates([1,2,3,2,4,1]) -> [2, 1] removeDuplicatesSorted([1,1,2,2,3]) -> 3, array becomes [1,2,3,...]

Time O(n) · Space O(n) — or O(1) for the in-place sorted version

9. Second largest element / Nth highest
The trap

Sorting works but is O(n log n), and the interviewer usually wants O(n). Also: do duplicates count? Ask. "Second largest of [5,5,3]" is 3 if you mean distinct values, 5 if you don't.

// O(n) single pass, distinct values
public static int secondLargest(int[] nums) {
    long first = Long.MIN_VALUE, second = Long.MIN_VALUE;

    for (int n : nums) {
        if (n > first) {
            second = first;      // the old winner becomes runner-up
            first  = n;
        } else if (n > second && n != first) {
            second = n;
        }
    }
    if (second == Long.MIN_VALUE) throw new IllegalArgumentException("no second largest");
    return (int) second;
}

// Nth highest with a stream (readable; fine to offer as the "quick" version)
Optional<Integer> nth = Arrays.stream(nums).boxed()
        .distinct()
        .sorted(Comparator.reverseOrder())
        .skip(n - 1)
        .findFirst();

// Top K efficiently with a min-heap of size K - O(n log k)
PriorityQueue<Integer> heap = new PriorityQueue<>();   // min-heap
for (int num : nums) {
    heap.offer(num);
    if (heap.size() > k) heap.poll();                  // drop the smallest
}
// heap now holds the K largest; heap.peek() is the Kth largest
secondLargest([10, 5, 20, 8]) -> 10 secondLargest([5, 5, 3]) -> 3 (distinct)

Time O(n) single pass, or O(n log k) for top-K · Space O(1) / O(k)

10. Move all zeros to the end (in place)
What's being asked

[0,1,0,3,12] becomes [1,3,12,0,0], keeping the order of the non-zero values, without creating a new array.

Thinking

Use a write pointer. Walk through with a read pointer; every time you find a non-zero, write it at the write pointer and advance it. At the end, fill the rest with zeros. This "compaction" pattern appears constantly.

public static void moveZeros(int[] nums) {
    int write = 0;

    for (int read = 0; read < nums.length; read++) {
        if (nums[read] != 0) nums[write++] = nums[read];
    }
    while (write < nums.length) nums[write++] = 0;      // pad the tail
}

Dry run on [0,1,0,3,12]

read=0 (0)  skip
read=1 (1)  nums[0]=1  write=1
read=2 (0)  skip
read=3 (3)  nums[1]=3  write=2
read=4 (12) nums[2]=12 write=3
pad: nums[3]=0, nums[4]=0   -> [1,3,12,0,0]

Time O(n) · Space O(1)

11. Maximum subarray sum (Kadane's algorithm)
What's being asked

Find the contiguous run of numbers with the biggest total. In [-2,1,-3,4,-1,2,1,-5,4] the best run is [4,-1,2,1] = 6.

Thinking — the key insight

At each position ask one question: "is the running total I'm carrying actually helping me?" If the total so far is negative, it's dragging you down — drop it and start fresh from the current number. That's the whole algorithm.

public static int maxSubArraySum(int[] nums) {
    int currentBest = nums[0];     // best sum ENDING at the current index
    int overallBest = nums[0];

    for (int i = 1; i < nums.length; i++) {
        // either extend the previous run, or start a new one here
        currentBest = Math.max(nums[i], currentBest + nums[i]);
        overallBest = Math.max(overallBest, currentBest);
    }
    return overallBest;
}

Dry run on [-2,1,-3,4,-1,2,1,-5,4]

i=1  1:  max(1, -2+1=-1)  = 1     overall=1
i=2 -3:  max(-3, 1-3=-2)  = -2    overall=1
i=3  4:  max(4, -2+4=2)   = 4     overall=4   <- fresh start
i=4 -1:  max(-1, 4-1=3)   = 3     overall=4
i=5  2:  max(2, 3+2=5)    = 5     overall=5
i=6  1:  max(1, 5+1=6)    = 6     overall=6   <- answer

Time O(n) · Space O(1)

12. Rotate an array by K positions
What's being asked

Rotate [1,2,3,4,5] right by 2 → [4,5,1,2,3]. The elegant O(1)-space solution is the triple reverse trick.

Thinking

Reverse the whole array, then reverse the first k elements, then reverse the rest. It looks like magic but you can verify it in one dry run — and it's the answer they're looking for.

public static void rotate(int[] nums, int k) {
    int n = nums.length;
    k = k % n;                       // handle k > n
    if (k < 0) k += n;               // handle negative k

    reverse(nums, 0, n - 1);         // whole array
    reverse(nums, 0, k - 1);         // first k
    reverse(nums, k, n - 1);         // the rest
}

private static void reverse(int[] a, int i, int j) {
    while (i < j) { int t = a[i]; a[i++] = a[j]; a[j--] = t; }
}

Dry run: [1,2,3,4,5], k=2

reverse all       -> [5,4,3,2,1]
reverse first 2   -> [4,5,3,2,1]
reverse rest      -> [4,5,1,2,3]   ✓

Time O(n) · Space O(1)

13. Merge two sorted arrays / find the missing number
// MERGE two sorted arrays - the merge step of merge sort
public static int[] merge(int[] a, int[] b) {
    int[] out = new int[a.length + b.length];
    int i = 0, j = 0, k = 0;

    while (i < a.length && j < b.length)
        out[k++] = (a[i] <= b[j]) ? a[i++] : b[j++];

    while (i < a.length) out[k++] = a[i++];    // drain whichever is left
    while (j < b.length) out[k++] = b[j++];
    return out;
}

// MISSING NUMBER in 1..n - the maths trick, O(1) space
public static int missingNumber(int[] nums, int n) {
    long expected = (long) n * (n + 1) / 2;    // sum of 1..n
    long actual   = Arrays.stream(nums).asLongStream().sum();
    return (int) (expected - actual);
}

// XOR version - immune to integer overflow
public static int missingXor(int[] nums, int n) {
    int x = 0;
    for (int i = 1; i <= n; i++) x ^= i;       // XOR all expected values
    for (int v : nums)           x ^= v;       // XOR all actual values
    return x;                                  // pairs cancel out; the missing one remains
}
merge([1,3,5], [2,4,6]) -> [1,2,3,4,5,6] missingNumber([1,2,4,5], 5) -> 3

Time O(n+m) / O(n) · Space O(n+m) / O(1)

Say this

"I'd use XOR over the sum formula in production — the sum can overflow for large n, and XOR can't."

14. Reverse a singly linked list
What's being asked

Turn 1→2→3→null into 3→2→1→null by re-pointing the arrows, not by copying values.

Thinking

Walk the list with three pointers: prev, current, and next. At each node: remember the next node (or you lose the rest of the list), point the current node backwards at prev, then shuffle all three forward. When current is null, prev is the new head.

class Node { int val; Node next; Node(int v){ val = v; } }

public static Node reverse(Node head) {
    Node prev = null;
    Node current = head;

    while (current != null) {
        Node next = current.next;   // 1. save the rest of the list
        current.next = prev;        // 2. flip the arrow backwards
        prev = current;             // 3. move prev forward
        current = next;             // 4. move current forward
    }
    return prev;                    // prev is now the new head
}

Dry run on 1→2→3

start:  prev=null  cur=1
step1:  next=2, 1.next=null, prev=1, cur=2      list so far: 1→null
step2:  next=3, 2.next=1,    prev=2, cur=3      list so far: 2→1→null
step3:  next=null, 3.next=2, prev=3, cur=null   list: 3→2→1→null ✓

Time O(n) · Space O(1)

15. Detect a cycle in a linked list, and find the middle (slow/fast pointers)
In simple words — the racetrack analogy

Two runners on a track, one twice as fast as the other. On a straight track the fast one finishes and it's over. On a circular track the fast one eventually laps the slow one and they meet. That meeting is proof of a loop.

The same two pointers also give you the middle for free: when the fast pointer reaches the end, the slow one is exactly halfway.

// CYCLE DETECTION (Floyd's tortoise and hare)
public static boolean hasCycle(Node head) {
    Node slow = head, fast = head;

    while (fast != null && fast.next != null) {
        slow = slow.next;            // 1 step
        fast = fast.next.next;       // 2 steps
        if (slow == fast) return true;   // they met -> there's a loop
    }
    return false;                    // fast reached the end -> no loop
}

// FIND THE MIDDLE
public static Node middle(Node head) {
    Node slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow;                     // for even length, this is the second middle
}

// FIND WHERE THE CYCLE STARTS (the follow-up)
public static Node cycleStart(Node head) {
    Node slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next; fast = fast.next.next;
        if (slow == fast) {                        // meeting point found
            Node p = head;
            while (p != slow) { p = p.next; slow = slow.next; }
            return p;                              // this is the loop entry
        }
    }
    return null;
}

Time O(n) · Space O(1) — beating the obvious HashSet solution's O(n) space

16. Balanced brackets (Stack)
What's being asked

Is "{[()]}" correctly nested? Yes. Is "{[(])}"? No. This is the standard "do you know when to use a Stack" question.

Thinking

Brackets must close in reverse order of opening — that's literally what a stack does. Push every opening bracket. On a closing bracket, pop and check it matches. At the end the stack must be empty (otherwise something never closed).

public static boolean isBalanced(String s) {
    Deque<Character> stack = new ArrayDeque<>();   // ArrayDeque, not the legacy Stack class
    Map<Character, Character> pairs = Map.of(')', '(', ']', '[', '}', '{');

    for (char c : s.toCharArray()) {
        if (c == '(' || c == '[' || c == '{') {
            stack.push(c);
        } else if (pairs.containsKey(c)) {
            // must have something to close, and it must be the matching opener
            if (stack.isEmpty() || stack.pop() != pairs.get(c)) return false;
        }
    }
    return stack.isEmpty();       // anything left = unclosed bracket
}
isBalanced("{[()]}") -> true isBalanced("{[(])}") -> false isBalanced("((") -> false (stack not empty at the end)

Time O(n) · Space O(n)

Say this

"I use ArrayDeque rather than java.util.Stack — Stack extends Vector, so every method is synchronised for no reason, and it exposes index-based access that breaks LIFO semantics."

17. Implement a Stack using a Queue (or a Queue using two Stacks)
What's being asked

A design puzzle testing whether you truly understand LIFO vs FIFO. The two-stack queue is the more common version.

Thinking — queue from two stacks

Stack A takes new items. When someone wants to dequeue, pour A into B — the pouring reverses the order, so B's top is the oldest item. Only pour when B is empty, which makes the average cost O(1) per operation.

class QueueFromStacks<T> {
    private final Deque<T> inbox  = new ArrayDeque<>();
    private final Deque<T> outbox = new ArrayDeque<>();

    public void enqueue(T item) {
        inbox.push(item);                     // always cheap
    }

    public T dequeue() {
        if (outbox.isEmpty()) {
            if (inbox.isEmpty()) throw new NoSuchElementException();
            while (!inbox.isEmpty()) outbox.push(inbox.pop());   // pour = reverse
        }
        return outbox.pop();
    }
}
enqueue(1); enqueue(2); enqueue(3); dequeue() -> 1 (pours 3,2,1 into outbox as 1,2,3) dequeue() -> 2 enqueue(4); dequeue() -> 3 (outbox not empty, so no pour needed)

Time O(1) amortised — each element is moved at most once · Space O(n)

Say this

"The word to use is amortised. A single dequeue can cost O(n), but any sequence of n operations costs O(n) total, because each element moves between stacks at most once."

18. Binary tree traversals (DFS and BFS)
In simple words

Four ways to visit every node:

  • Inorder (left, root, right) — on a binary search tree this gives you sorted order. That's the fact interviewers want.
  • Preorder (root, left, right) — used to copy/serialise a tree.
  • Postorder (left, right, root) — used to delete a tree, or evaluate an expression.
  • Level order / BFS (row by row) — uses a Queue, not recursion.
class TreeNode { int val; TreeNode left, right; TreeNode(int v){ val = v; } }

// DFS - recursion. Note only the ORDER of the three lines changes.
void inorder(TreeNode n, List<Integer> out) {
    if (n == null) return;
    inorder(n.left, out);
    out.add(n.val);
    inorder(n.right, out);
}

void preorder(TreeNode n, List<Integer> out) {
    if (n == null) return;
    out.add(n.val);
    preorder(n.left, out);
    preorder(n.right, out);
}

// BFS - level order, uses a Queue
List<List<Integer>> levelOrder(TreeNode root) {
    List<List<Integer>> levels = new ArrayList<>();
    if (root == null) return levels;

    Queue<TreeNode> queue = new LinkedList<>();
    queue.add(root);

    while (!queue.isEmpty()) {
        int levelSize = queue.size();          // how many nodes on THIS level
        List<Integer> level = new ArrayList<>();

        for (int i = 0; i < levelSize; i++) {
            TreeNode n = queue.poll();
            level.add(n.val);
            if (n.left  != null) queue.add(n.left);
            if (n.right != null) queue.add(n.right);
        }
        levels.add(level);
    }
    return levels;
}
Tree: 1 / \ 2 3 / \ 4 5 inorder -> [4, 2, 5, 1, 3] preorder -> [1, 2, 4, 5, 3] levelOrder -> [[1], [2,3], [4,5]]

Time O(n) · Space O(h) for DFS recursion (h = height), O(w) for BFS (w = widest level)

19. Tree height, and check if a binary tree is balanced
// HEIGHT - the base case does all the work
public static int height(TreeNode n) {
    if (n == null) return 0;
    return 1 + Math.max(height(n.left), height(n.right));
}

// IS IT BALANCED? (no subtree pair differs in height by more than 1)
// Naive version calls height() at every node -> O(n²).
// This version computes height and balance in ONE pass, using -1 as "unbalanced".
public static boolean isBalanced(TreeNode root) {
    return check(root) != -1;
}

private static int check(TreeNode n) {
    if (n == null) return 0;

    int left = check(n.left);
    if (left == -1) return -1;              // early exit - already unbalanced

    int right = check(n.right);
    if (right == -1) return -1;

    if (Math.abs(left - right) > 1) return -1;
    return 1 + Math.max(left, right);
}

Time O(n) for the single-pass version (vs O(n²) naive) · Space O(h)

Say this

"The naive version recomputes the height of every subtree repeatedly. Returning a sentinel value that carries both the height and the failure flag turns it into one pass — that's the optimisation they're looking for."

20. Validate a Binary Search Tree, and find the lowest common ancestor
The trap in "validate a BST"

Checking only node.left.val < node.val < node.right.val at each node is wrong. Every node in the entire left subtree must be smaller — not just the immediate child. The fix is to pass down a valid range.

public static boolean isValidBST(TreeNode root) {
    return valid(root, Long.MIN_VALUE, Long.MAX_VALUE);
}

private static boolean valid(TreeNode n, long min, long max) {
    if (n == null) return true;
    if (n.val <= min || n.val >= max) return false;

    // going left: everything must be BELOW n.val
    // going right: everything must be ABOVE n.val
    return valid(n.left, min, n.val) && valid(n.right, n.val, max);
}

// LOWEST COMMON ANCESTOR in a BST - exploit the ordering, no searching needed
public static TreeNode lca(TreeNode root, int a, int b) {
    TreeNode node = root;
    while (node != null) {
        if (a < node.val && b < node.val)      node = node.left;   // both smaller -> go left
        else if (a > node.val && b > node.val) node = node.right;  // both bigger  -> go right
        else return node;   // they diverge here (or one IS this node) -> this is the LCA
    }
    return null;
}
10 / \ 5 15 / \ 6 20 <- 6 is in the RIGHT subtree of 10 but is smaller than 10 isValidBST -> false (a naive parent-child check would wrongly say true)

Time O(n) validate, O(h) for the BST LCA · Space O(h)

21. Binary search (and the overflow bug)
What's being asked

Find a value in a sorted array by repeatedly halving the search range. 1 million items takes about 20 comparisons.

public static int binarySearch(int[] sorted, int target) {
    int low = 0, high = sorted.length - 1;

    while (low <= high) {                       // <= not < : the last window is a single element
        int mid = low + (high - low) / 2;       // NOT (low+high)/2 -- see below

        if (sorted[mid] == target) return mid;
        if (sorted[mid] < target) low  = mid + 1;   // discard the left half
        else                      high = mid - 1;   // discard the right half
    }
    return -1;
}
The famous bug worth mentioning

(low + high) / 2 overflows when both are large — it goes negative and throws ArrayIndexOutOfBoundsException. This bug existed in the JDK's own Arrays.binarySearch for nine years. low + (high - low) / 2 can't overflow. Mentioning this unprompted is a strong signal.

Dry run: find 7 in [1,3,5,7,9,11]

low=0 high=5  mid=2 (5)   5<7  -> low=3
low=3 high=5  mid=4 (9)   9>7  -> high=3
low=3 high=3  mid=3 (7)   found -> return 3

Time O(log n) · Space O(1)

22. Sorting algorithms — what to know and what to say
In simple words

You will not be asked to implement quicksort in a Java interview for a senior role. You will be asked which one Java uses and why — because that question separates people who memorised a table from people who understand trade-offs.

AlgorithmAverageWorstSpaceStable?
Bubble / InsertionO(n²)O(n²)O(1)Yes
Merge sortO(n log n)O(n log n)O(n)Yes
Quick sortO(n log n)O(n²)O(log n)No
Heap sortO(n log n)O(n log n)O(1)No

What Java actually does

  • Arrays.sort(int[])dual-pivot quicksort. Primitives have no identity, so stability is meaningless and quicksort's speed and O(1) space win.
  • Arrays.sort(Object[]) and Collections.sortTimSort, a hybrid of merge sort and insertion sort. Objects need stability (equal elements keep their relative order, which matters when you sort by one field then another), and TimSort is very fast on partially sorted real-world data.
// Insertion sort - the one worth being able to write, and what TimSort uses for small runs
public static void insertionSort(int[] a) {
    for (int i = 1; i < a.length; i++) {
        int key = a[i], j = i - 1;
        while (j >= 0 && a[j] > key) {    // shift bigger elements right
            a[j + 1] = a[j];
            j--;
        }
        a[j + 1] = key;                   // drop the key into the gap
    }
}
Say this

"Primitives get quicksort, objects get TimSort — the difference is stability, which only matters when elements are distinguishable. That's also why sorting by department then salary works correctly with chained comparators."

23. Recursion — factorial, Fibonacci and why memoisation matters
In simple words

A recursive method solves a problem by calling itself on a smaller version of the same problem. Every recursion needs two things: a base case (when to stop) and progress towards that base case.

Naive Fibonacci is the classic demonstration of why recursion alone isn't enough — it recomputes the same values exponentially many times.

// Factorial - clean recursion
public static long factorial(int n) {
    if (n < 0) throw new IllegalArgumentException();
    if (n <= 1) return 1;                   // BASE CASE
    return n * factorial(n - 1);            // progress toward the base case
}

// Fibonacci - NAIVE. fib(50) takes minutes: it recomputes fib(30) over a million times.
public static long fibSlow(int n) {
    return n <= 1 ? n : fibSlow(n - 1) + fibSlow(n - 2);       // O(2^n) !!
}

// MEMOISED - remember what you've already computed. O(n).
public static long fib(int n, Map<Integer, Long> memo) {
    if (n <= 1) return n;
    return memo.computeIfAbsent(n, k -> fib(k - 1, memo) + fib(k - 2, memo));
}

// ITERATIVE - O(n) time, O(1) space. Usually the best answer.
public static long fibIterative(int n) {
    long prev = 0, curr = 1;
    for (int i = 2; i <= n; i++) {
        long next = prev + curr;
        prev = curr;
        curr = next;
    }
    return n == 0 ? 0 : curr;
}
factorial(10) -> 3628800 fibIterative(50) -> 12586269025 (instant) fibSlow(50) -> the same answer, after a very long wait

Time O(2ⁿ) naive → O(n) memoised or iterative · Space O(n) recursion stack → O(1) iterative

Say this

"Java has no tail-call optimisation, so deep recursion risks StackOverflowError — around 10,000 frames by default. For anything that could be deep, I convert to iteration or use an explicit stack."

24. Group anagrams / group a list by a property
What's being asked

Group ["eat","tea","tan","ate","nat","bat"] into [[eat,tea,ate],[tan,nat],[bat]]. The insight: anagrams share the same sorted letters, so use that as the map key.

public static Collection<List<String>> groupAnagrams(String[] words) {
    Map<String, List<String>> groups = new LinkedHashMap<>();

    for (String w : words) {
        char[] letters = w.toCharArray();
        Arrays.sort(letters);
        String key = new String(letters);              // "eat" and "tea" both -> "aet"
        groups.computeIfAbsent(key, k -> new ArrayList<>()).add(w);
    }
    return groups.values();
}

// Stream version - the same idea, one expression
Map<String, List<String>> grouped = Arrays.stream(words)
    .collect(Collectors.groupingBy(w -> {
        char[] c = w.toCharArray(); Arrays.sort(c); return new String(c);
    }));
groupAnagrams(["eat","tea","tan","ate","nat","bat"]) -> [[eat, tea, ate], [tan, nat], [bat]]

Time O(n · k log k) where k = word length · Space O(n·k)

Say this

"computeIfAbsent is the idiom for building a multi-map — it replaces the if (map.get(k) == null) map.put(k, new ArrayList<>()) boilerplate and it's atomic on a ConcurrentHashMap."

25. Design an LRU cache
What's being asked

A cache with a fixed capacity. When it's full and a new item arrives, evict the least recently used one. Every get counts as "using" an item, so it moves to the front.

Interviewers ask this because the ideal solution combines two data structures: a HashMap for O(1) lookup and a doubly-linked list for O(1) reordering.

// THE JAVA ANSWER - LinkedHashMap already IS a HashMap + doubly-linked list
public class LruCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    public LruCache(int capacity) {
        super(16, 0.75f, true);        // true = ACCESS order, not insertion order
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;      // called after each put; true = evict the eldest
    }
}
LruCache<Integer,String> cache = new LruCache<>(2); cache.put(1, "a"); cache.put(2, "b"); cache.get(1); // 1 is now the MOST recently used cache.put(3, "c"); // capacity exceeded -> evicts 2, not 1 cache.keySet() -> [1, 3]

Time O(1) for get and put · Space O(capacity)

Say this

"In production I'd use Caffeine rather than hand-rolling it — it adds TTL, size-based eviction, async refresh and hit-rate metrics, and it's thread-safe. The LinkedHashMap version is not synchronised."

26. Producer–consumer with a bounded buffer (threading round)
What's being asked

The standard concurrency coding question. Producers add items, consumers take them, and the buffer has a maximum size so producers must wait when it's full.

// THE ANSWER TO GIVE FIRST - BlockingQueue does everything
public class OrderPipeline {
    private final BlockingQueue<Order> queue = new ArrayBlockingQueue<>(100);

    public void produce(Order o) throws InterruptedException {
        queue.put(o);           // blocks when full -> automatic back-pressure
    }

    public void startConsumers(int n) {
        ExecutorService pool = Executors.newFixedThreadPool(n);
        for (int i = 0; i < n; i++) {
            pool.submit(() -> {
                try {
                    while (!Thread.currentThread().isInterrupted()) {
                        Order o = queue.take();     // blocks when empty
                        process(o);
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();   // restore the flag and exit
                }
            });
        }
    }
}
// IF THEY ASK FOR wait/notify - the manual version
class BoundedBuffer<T> {
    private final Queue<T> buffer = new LinkedList<>();
    private final int capacity;
    private final Object lock = new Object();

    public void put(T item) throws InterruptedException {
        synchronized (lock) {
            while (buffer.size() == capacity) lock.wait();   // WHILE, not if
            buffer.add(item);
            lock.notifyAll();          // wake any waiting consumers
        }
    }

    public T take() throws InterruptedException {
        synchronized (lock) {
            while (buffer.isEmpty()) lock.wait();
            T item = buffer.poll();
            lock.notifyAll();          // wake any waiting producers
            return item;
        }
    }
}
Say this

"while rather than if around wait(), because of spurious wakeups and because with notifyAll another thread may have taken the item before this one runs. That single detail is what the question is really testing."

27. Matrix problems: spiral print and rotate 90°
// ROTATE a square matrix 90° clockwise, IN PLACE
// Trick: transpose (flip along the diagonal), then reverse each row.
public static void rotate90(int[][] m) {
    int n = m.length;

    for (int i = 0; i < n; i++)                  // transpose
        for (int j = i + 1; j < n; j++) {
            int t = m[i][j]; m[i][j] = m[j][i]; m[j][i] = t;
        }

    for (int[] row : m) {                        // reverse each row
        for (int l = 0, r = n - 1; l < r; l++, r--) {
            int t = row[l]; row[l] = row[r]; row[r] = t;
        }
    }
}

// SPIRAL print - shrink four boundaries inward
public static List<Integer> spiral(int[][] m) {
    List<Integer> out = new ArrayList<>();
    if (m.length == 0) return out;

    int top = 0, bottom = m.length - 1, left = 0, right = m[0].length - 1;

    while (top <= bottom && left <= right) {
        for (int j = left; j <= right; j++)  out.add(m[top][j]);     top++;
        for (int i = top;  i <= bottom; i++) out.add(m[i][right]);   right--;

        if (top <= bottom)                                            // guard for thin matrices
            { for (int j = right; j >= left; j--) out.add(m[bottom][j]); bottom--; }
        if (left <= right)
            { for (int i = bottom; i >= top; i--) out.add(m[i][left]); left++; }
    }
    return out;
}
[[1,2,3], rotate90 [[7,4,1], [4,5,6], ----------------> [8,5,2], [7,8,9]] [9,6,3]] spiral([[1,2,3],[4,5,6],[7,8,9]]) -> [1,2,3,6,9,8,7,4,5]

Time O(n²) · Space O(1) for rotate, O(n²) output for spiral

28. Employee/Order object problems (the realistic ones)
Why these matter more

For an experienced full stack role, you're more likely to get "here's a list of Employee objects, do X" than a pure algorithm puzzle — because it's closer to the actual job.

record Employee(String name, String dept, BigDecimal salary, int age, String managerId) {}

// 1. Highest-paid employee per department
Map<String, Optional<Employee>> topPerDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::dept,
             Collectors.maxBy(Comparator.comparing(Employee::salary))));

// 2. Average salary per department, sorted by department name
Map<String, Double> avgByDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::dept, TreeMap::new,
             Collectors.averagingDouble(e -> e.salary().doubleValue())));

// 3. Total salary bill
BigDecimal total = employees.stream()
    .map(Employee::salary)
    .reduce(BigDecimal.ZERO, BigDecimal::add);

// 4. Names of employees earning above the overall average
double avg = employees.stream().mapToDouble(e -> e.salary().doubleValue()).average().orElse(0);
List<String> aboveAvg = employees.stream()
    .filter(e -> e.salary().doubleValue() > avg)
    .map(Employee::name)
    .sorted()
    .toList();

// 5. Count employees per department, descending by count
LinkedHashMap<String, Long> countDesc = employees.stream()
    .collect(Collectors.groupingBy(Employee::dept, Collectors.counting()))
    .entrySet().stream()
    .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
                              (a, b) -> a, LinkedHashMap::new));

// 6. Build an org chart: manager -> direct reports
Map<String, List<Employee>> reports = employees.stream()
    .filter(e -> e.managerId() != null)
    .collect(Collectors.groupingBy(Employee::managerId));

// 7. Partition into eligible / not eligible, then sort each group
Map<Boolean, List<Employee>> eligibility = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.age() >= 40));
Say this

"For money I keep BigDecimal all the way through and use reducing rather than summingDouble — converting to double for an average is acceptable for display, but never for a total that someone gets paid."

21. Written Test — Stream API Programs

Java 8 stream questions are almost guaranteed for a 10-year Java role. Every program below shows the data, the code and the exact output.

The sample data used throughout this section
record Employee(int id, String name, String dept, double salary, int age, String city) {}

List<Employee> employees = List.of(
    new Employee(1, "Arun",   "IT",      95000, 34, "Chennai"),
    new Employee(2, "Bhavna", "IT",      82000, 29, "Pune"),
    new Employee(3, "Chetan", "HR",      54000, 41, "Chennai"),
    new Employee(4, "Divya",  "Finance", 76000, 37, "Mumbai"),
    new Employee(5, "Esha",   "IT",     120000, 45, "Pune"),
    new Employee(6, "Farid",  "HR",      58000, 26, "Mumbai"),
    new Employee(7, "Gita",   "Finance", 91000, 31, "Chennai")
);

List<String> words   = List.of("banana","apple","cherry","apple","date","banana","apple");
List<Integer> numbers = List.of(5, 3, 9, 1, 7, 3, 8, 2, 9);
Tip for the room

When you're given a stream problem, say the shape out loud before typing: "this is a group-by with a downstream count" or "this is a filter then map then collect". Naming the shape stops you flailing.

1–5. Filtering, mapping and sorting basics
// 1. Names of all IT employees, sorted alphabetically
List<String> itNames = employees.stream()
    .filter(e -> e.dept().equals("IT"))
    .map(Employee::name)
    .sorted()
    .toList();

// 2. Employees earning more than 80,000, highest first
List<Employee> wellPaid = employees.stream()
    .filter(e -> e.salary() > 80_000)
    .sorted(Comparator.comparingDouble(Employee::salary).reversed())
    .toList();

// 3. All distinct departments
List<String> depts = employees.stream()
    .map(Employee::dept)
    .distinct()
    .sorted()
    .toList();

// 4. Uppercase names, comma separated
String csv = employees.stream()
    .map(e -> e.name().toUpperCase())
    .collect(Collectors.joining(", ", "[", "]"));

// 5. Sort by department, then salary DESCENDING within each department
List<Employee> sorted = employees.stream()
    .sorted(Comparator.comparing(Employee::dept)
                      .thenComparing(Employee::salary, Comparator.reverseOrder()))
    .toList();
1 -> [Arun, Bhavna, Esha] 2 -> Esha(120000), Arun(95000), Gita(91000), Bhavna(82000) 3 -> [Finance, HR, IT] 4 -> [ARUN, BHAVNA, CHETAN, DIVYA, ESHA, FARID, GITA] 5 -> Gita, Divya | Farid, Chetan | Esha, Arun, Bhavna
6–10. Grouping and counting
// 6. Group employees by department
Map<String, List<Employee>> byDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::dept));

// 7. Count employees per department
Map<String, Long> countByDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::dept, Collectors.counting()));

// 8. Only the NAMES grouped by department (downstream 'mapping')
Map<String, List<String>> namesByDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::dept,
             Collectors.mapping(Employee::name, Collectors.toList())));

// 9. Two-level grouping: department, then city
Map<String, Map<String, List<String>>> byDeptThenCity = employees.stream()
    .collect(Collectors.groupingBy(Employee::dept,
             Collectors.groupingBy(Employee::city,
             Collectors.mapping(Employee::name, Collectors.toList()))));

// 10. Word frequency, sorted by count descending
LinkedHashMap<String, Long> freq = words.stream()
    .collect(Collectors.groupingBy(w -> w, Collectors.counting()))
    .entrySet().stream()
    .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
                              (a, b) -> a, LinkedHashMap::new));
7 -> {Finance=2, HR=2, IT=3} 8 -> {Finance=[Divya, Gita], HR=[Chetan, Farid], IT=[Arun, Bhavna, Esha]} 9 -> {IT={Chennai=[Arun], Pune=[Bhavna, Esha]}, HR={...}, Finance={...}} 10 -> {apple=3, banana=2, cherry=1, date=1}
Say this

"groupingBy with a downstream collector is the one to know cold — counting(), mapping(), summingDouble(), maxBy() and a nested groupingBy cover about 90% of what gets asked."

11–15. Aggregation — sum, average, max, statistics
// 11. Total salary bill
double total = employees.stream().mapToDouble(Employee::salary).sum();

// 12. Average salary per department
Map<String, Double> avgByDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::dept,
             Collectors.averagingDouble(Employee::salary)));

// 13. Highest-paid employee overall
Optional<Employee> topEarner = employees.stream()
    .max(Comparator.comparingDouble(Employee::salary));

// 14. Highest-paid employee PER department
Map<String, Optional<Employee>> topPerDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::dept,
             Collectors.maxBy(Comparator.comparingDouble(Employee::salary))));

// 15. All the statistics in ONE pass
DoubleSummaryStatistics stats = employees.stream()
    .mapToDouble(Employee::salary)
    .summaryStatistics();
11 -> 576000.0 12 -> {Finance=83500.0, HR=56000.0, IT=99000.0} 13 -> Optional[Employee[id=5, name=Esha, ...]] 14 -> {Finance=Gita, HR=Farid, IT=Esha} 15 -> count=7, min=54000, average=82285.71, max=120000, sum=576000
Say this

"summaryStatistics() gives count, min, max, sum and average in a single pass — much better than streaming the same collection four times, which I see a lot in code review."

16–20. Converting to Map, partitioning and flatMap
// 16. Map of id -> name
Map<Integer, String> idToName = employees.stream()
    .collect(Collectors.toMap(Employee::id, Employee::name));

// 17. Map of name -> salary, handling duplicate keys safely
Map<String, Double> nameToSalary = employees.stream()
    .collect(Collectors.toMap(Employee::name, Employee::salary,
                              (existing, duplicate) -> existing));   // keep the first

// 18. Partition into "above 35" and "35 or under"
Map<Boolean, List<String>> byAge = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.age() > 35,
             Collectors.mapping(Employee::name, Collectors.toList())));

// 19. flatMap: all distinct characters used in all names
List<String> letters = employees.stream()
    .map(Employee::name)
    .flatMap(n -> n.toLowerCase().chars().mapToObj(c -> String.valueOf((char) c)))
    .distinct().sorted().toList();

// 20. flatMap over nested lists
List<List<Integer>> nested = List.of(List.of(1,2), List.of(3,4), List.of(5));
List<Integer> flat = nested.stream().flatMap(List::stream).toList();
16 -> {1=Arun, 2=Bhavna, ..., 7=Gita} 18 -> {false=[Bhavna, Divya, Farid, Gita], true=[Arun, Chetan, Esha]} 20 -> [1, 2, 3, 4, 5]
The toMap trap they test for

Without the third merge argument, a duplicate key throws IllegalStateException: Duplicate key. And unlike groupingBy, toMap throws a NullPointerException if a value is null. Both bite on real data.

21–25. String and number puzzles with streams
// 21. Second highest number (distinct)
Optional<Integer> second = numbers.stream()
    .distinct()
    .sorted(Comparator.reverseOrder())
    .skip(1)
    .findFirst();

// 22. Sum of squares of even numbers
int sumSquares = numbers.stream()
    .filter(n -> n % 2 == 0)
    .mapToInt(n -> n * n)
    .sum();

// 23. Find duplicates in a list
Set<Integer> seen = new HashSet<>();
Set<Integer> duplicates = numbers.stream()
    .filter(n -> !seen.add(n))          // add() returns false if already present
    .collect(Collectors.toSet());

// 24. First non-repeating character of a string
Character firstUnique = "swiss".chars().mapToObj(c -> (char) c)
    .collect(Collectors.groupingBy(c -> c, LinkedHashMap::new, Collectors.counting()))
    .entrySet().stream()
    .filter(e -> e.getValue() == 1)
    .map(Map.Entry::getKey)
    .findFirst().orElse(null);

// 25. Reverse each word in a sentence, keeping word order
String reversedWords = Arrays.stream("the sky is blue".split(" "))
    .map(w -> new StringBuilder(w).reverse().toString())
    .collect(Collectors.joining(" "));
21 -> Optional[8] 22 -> 68 (2² + 8² = 4 + 64) 23 -> [3, 9] 24 -> 'w' 25 -> "eht yks si eulb"
26–30. Advanced: custom collectors, teeing, and infinite streams
// 26. Sum with BigDecimal - the correct way to total money
BigDecimal totalMoney = orders.stream()
    .map(Order::amount)
    .reduce(BigDecimal.ZERO, BigDecimal::add);

// 27. teeing (Java 12): two collectors, one pass, combined result
record MinMax(double min, double max) {}
MinMax range = employees.stream().collect(Collectors.teeing(
    Collectors.summarizingDouble(Employee::salary),
    Collectors.counting(),
    (stats, count) -> new MinMax(stats.getMin(), stats.getMax())
));

// 28. Infinite stream, bounded by limit - generate the first 10 even numbers
List<Integer> evens = Stream.iterate(0, n -> n + 2).limit(10).toList();

// 3-argument iterate (Java 9) has the condition built in
List<Integer> powers = Stream.iterate(1, n -> n < 100, n -> n * 2).toList();

// 29. takeWhile vs filter - the difference matters
List<Integer> nums = List.of(1, 2, 3, 10, 4, 5);
nums.stream().takeWhile(n -> n < 5).toList();   // STOPS at the first failure
nums.stream().filter(n   -> n < 5).toList();    // checks EVERY element

// 30. A custom collector - collect into an unmodifiable sorted set
TreeSet<String> names = employees.stream()
    .map(Employee::name)
    .collect(Collectors.toCollection(TreeSet::new));
28 -> [0, 2, 4, 6, 8, 10, 12, 14, 16, 18] powers = [1, 2, 4, 8, 16, 32, 64] 29 -> takeWhile = [1, 2, 3] (stops at 10) filter = [1, 2, 3, 4] (keeps checking after 10)
Say this

"takeWhile versus filter is a nice one to know — takeWhile short-circuits at the first element that fails, which matters both for performance and for correctness on sorted data."

Common Stream API mistakes an interviewer will look for
// ❌ Reusing a stream
Stream<String> s = list.stream();
s.filter(...).count();
s.map(...).toList();          // IllegalStateException: stream has already been operated upon

// ❌ Side effects inside a stream (breaks with parallel, and hides intent)
List<String> out = new ArrayList<>();
list.stream().forEach(out::add);            // use .toList() instead

// ❌ Modifying the source while streaming it
list.stream().forEach(x -> list.remove(x)); // ConcurrentModificationException

// ❌ peek() for real logic - the JVM may skip it entirely
list.stream().peek(x -> save(x)).count();

// ❌ Boxing in a hot loop
list.stream().map(Integer::valueOf).reduce(0, Integer::sum);   // use mapToInt(...).sum()

// ❌ Assuming a stream is faster than a loop
// For a small list, a plain for-loop is often faster. Streams win on readability
// and on parallelism, not automatically on speed.

// ❌ parallelStream() around blocking I/O
ids.parallelStream().map(client::fetch).toList();   // starves the common ForkJoinPool
Say this

"I use streams where they make the intent clearer — filtering, grouping, aggregating. For a simple loop with an early exit and a side effect, an ordinary for-loop is usually more readable, and I'd say so in review rather than forcing streams everywhere."

22. Hands-on Task — Build a Spring Boot Feature

The "build this small feature in 45 minutes" round, or a take-home. Here's the complete vertical slice they expect, with the details that earn marks.

Task: build a CRUD REST API for Orders — the complete slice
What they're really assessing

Not whether you can write a controller — anyone can. They're checking layering, validation, error handling, correct HTTP semantics, transaction boundaries, and whether you wrote a test. Do all six even if the task doesn't mention them.

1. Entity

@Entity
@Table(name = "orders", indexes = @Index(name="ix_orders_status", columnList = "status,created_at"))
public class Order {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
    @SequenceGenerator(name = "order_seq", sequenceName = "order_seq", allocationSize = 50)
    private Long id;

    @Column(nullable = false, unique = true, length = 40)
    private String reference;

    @Enumerated(EnumType.STRING)                    // STRING, never ORDINAL - see note below
    @Column(nullable = false, length = 20)
    private OrderStatus status = OrderStatus.NEW;

    @Column(nullable = false, precision = 19, scale = 2)
    private BigDecimal total;

    @Version private long version;                  // optimistic locking
    @CreatedDate private Instant createdAt;         // needs @EnableJpaAuditing

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderLine> lines = new ArrayList<>();

    public void addLine(OrderLine l){ lines.add(l); l.setOrder(this); }
    // getters/setters omitted
}
Why EnumType.STRING

ORDINAL stores the enum's position as a number. Insert a new value in the middle of the enum later and every existing row silently means something different. This is a genuine data-corruption bug, and mentioning it unprompted scores well.

2. DTOs — never expose the entity

public record CreateOrderRequest(
    @NotBlank @Size(max = 40) String reference,
    @NotEmpty @Valid List<LineRequest> lines) {}

public record OrderResponse(Long id, String reference, String status,
                            BigDecimal total, Instant createdAt) {

    public static OrderResponse from(Order o){
        return new OrderResponse(o.getId(), o.getReference(),
                                 o.getStatus().name(), o.getTotal(), o.getCreatedAt());
    }
}

3. Repository

public interface OrderRepository extends JpaRepository<Order, Long> {

    Optional<Order> findByReference(String reference);
    boolean existsByReference(String reference);

    @EntityGraph(attributePaths = "lines")               // avoids N+1
    Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}

4. Service — where the transaction and the business rules live

@Service
@Transactional(readOnly = true)                          // read-only by default
public class OrderService {

    private final OrderRepository repo;

    public OrderService(OrderRepository repo){ this.repo = repo; }

    public Page<OrderResponse> list(OrderStatus status, Pageable pageable){
        return repo.findByStatus(status, pageable).map(OrderResponse::from);
    }

    public OrderResponse get(Long id){
        return repo.findById(id).map(OrderResponse::from)
                   .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @Transactional                                        // write -> override readOnly
    public OrderResponse create(CreateOrderRequest req){
        if (repo.existsByReference(req.reference()))
            throw new DuplicateOrderException(req.reference());

        Order order = new Order();
        order.setReference(req.reference());
        req.lines().forEach(l -> order.addLine(toLine(l)));
        order.setTotal(order.getLines().stream()
                .map(OrderLine::lineTotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add));

        return OrderResponse.from(repo.save(order));
    }

    @Transactional
    public void cancel(Long id){
        Order o = repo.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
        if (o.getStatus() == OrderStatus.SHIPPED)
            throw new IllegalStateTransitionException("Cannot cancel a shipped order");
        o.setStatus(OrderStatus.CANCELLED);               // dirty checking - no save() needed
    }
}

5. Controller — thin, no business logic

@RestController
@RequestMapping("/api/v1/orders")
@Validated
public class OrderController {

    private final OrderService service;
    public OrderController(OrderService service){ this.service = service; }

    @GetMapping
    Page<OrderResponse> list(@RequestParam(defaultValue = "NEW") OrderStatus status,
                             @PageableDefault(size = 20, sort = "createdAt",
                                              direction = Sort.Direction.DESC) Pageable pageable){
        return service.list(status, pageable);
    }

    @GetMapping("/{id}")
    OrderResponse get(@PathVariable Long id){ return service.get(id); }

    @PostMapping
    ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest req){
        OrderResponse created = service.create(req);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                          .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);   // 201 + Location header
    }

    @PatchMapping("/{id}/cancel")
    @ResponseStatus(HttpStatus.NO_CONTENT)                       // 204
    void cancel(@PathVariable Long id){ service.cancel(id); }
}

6. Global error handling

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail notFound(OrderNotFoundException e){
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }

    @ExceptionHandler(DuplicateOrderException.class)
    ProblemDetail duplicate(DuplicateOrderException e){
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());   // 409
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail invalid(MethodArgumentNotValidException e){
        var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setProperty("errors", e.getBindingResult().getFieldErrors().stream()
            .collect(toMap(FieldError::getField, FieldError::getDefaultMessage, (a,b)->a)));
        return pd;
    }
}
Say this while you build

"I'll keep the controller thin and put the transaction boundary on the service, return DTOs rather than entities, and add the error handler — even for a small task, because those are the three things that are painful to retrofit."

Task: write the tests for that feature
// ---------- UNIT TEST: business logic, no Spring, milliseconds ----------
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock OrderRepository repo;
    @InjectMocks OrderService service;

    @Test
    void createsOrderAndCalculatesTotal(){
        given(repo.existsByReference("R-1")).willReturn(false);
        given(repo.save(any())).willAnswer(inv -> inv.getArgument(0));

        var req = new CreateOrderRequest("R-1",
                      List.of(new LineRequest("SKU1", 2, new BigDecimal("50.00"))));

        OrderResponse out = service.create(req);

        assertThat(out.total()).isEqualByComparingTo("100.00");
        assertThat(out.status()).isEqualTo("NEW");
    }

    @Test
    void rejectsDuplicateReference(){
        given(repo.existsByReference("R-1")).willReturn(true);

        assertThatThrownBy(() -> service.create(new CreateOrderRequest("R-1", List.of(line()))))
            .isInstanceOf(DuplicateOrderException.class);

        then(repo).should(never()).save(any());      // and nothing was written
    }

    @Test
    void cannotCancelAShippedOrder(){
        Order shipped = orderWithStatus(OrderStatus.SHIPPED);
        given(repo.findById(1L)).willReturn(Optional.of(shipped));

        assertThatThrownBy(() -> service.cancel(1L))
            .isInstanceOf(IllegalStateTransitionException.class);
    }
}
// ---------- WEB SLICE: routing, validation, JSON, status codes ----------
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;
    @MockBean OrderService service;

    @Test
    void returns201WithLocationHeader() throws Exception {
        given(service.create(any())).willReturn(
            new OrderResponse(7L, "R-1", "NEW", new BigDecimal("100.00"), Instant.now()));

        mvc.perform(post("/api/v1/orders")
               .contentType(MediaType.APPLICATION_JSON)
               .content("""
                        { "reference": "R-1",
                          "lines": [{ "sku": "SKU1", "qty": 2, "price": 50.00 }] }
                        """))
           .andExpect(status().isCreated())
           .andExpect(header().string("Location", containsString("/api/v1/orders/7")))
           .andExpect(jsonPath("$.reference").value("R-1"));
    }

    @Test
    void returns400WhenReferenceMissing() throws Exception {
        mvc.perform(post("/api/v1/orders")
               .contentType(MediaType.APPLICATION_JSON).content("{\"lines\":[]}"))
           .andExpect(status().isBadRequest())
           .andExpect(jsonPath("$.errors.reference").exists());
    }
}
// ---------- INTEGRATION: real Postgres, real SQL, real migrations ----------
@SpringBootTest
@Testcontainers
class OrderRepositoryIT {

    @Container
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r){
        r.add("spring.datasource.url", db::getJdbcUrl);
        r.add("spring.datasource.username", db::getUsername);
        r.add("spring.datasource.password", db::getPassword);
    }

    @Autowired OrderRepository repo;

    @Test
    void enforcesUniqueReference(){
        repo.saveAndFlush(order("R-1"));
        assertThatThrownBy(() -> repo.saveAndFlush(order("R-1")))
            .isInstanceOf(DataIntegrityViolationException.class);
    }
}
Say this

"Three levels: unit tests for the rules, a web slice for the HTTP contract, and one integration test against a real database for the things only a real database can tell you — constraints, migrations and generated SQL."

Task: add pagination, filtering and sorting to an existing endpoint
// 1. Accept Pageable directly - Spring binds ?page=0&size=20&sort=createdAt,desc
@GetMapping
Page<OrderResponse> search(
        @RequestParam(required = false) OrderStatus status,
        @RequestParam(required = false) String customerId,
        @RequestParam(required = false) @DateTimeFormat(iso = DATE) LocalDate from,
        @PageableDefault(size = 20, sort = "createdAt", direction = DESC) Pageable pageable) {

    return service.search(new OrderFilter(status, customerId, from), pageable);
}

// 2. Dynamic filtering with a Specification - only apply the filters that were supplied
public Page<OrderResponse> search(OrderFilter f, Pageable pageable) {

    Specification<Order> spec = (root, query, cb) -> {
        List<Predicate> predicates = new ArrayList<>();

        if (f.status() != null)
            predicates.add(cb.equal(root.get("status"), f.status()));
        if (f.customerId() != null)
            predicates.add(cb.equal(root.get("customerId"), f.customerId()));
        if (f.from() != null)
            predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"),
                           f.from().atStartOfDay(ZoneOffset.UTC).toInstant()));

        return cb.and(predicates.toArray(new Predicate[0]));
    };

    return repo.findAll(spec, pageable).map(OrderResponse::from);
}
Three things that earn extra marks here
  • Cap the page sizespring.data.web.pageable.max-page-size=100. Otherwise ?size=1000000 is a denial-of-service.
  • Allow-list the sort fields. Passing user input straight into Sort.by() lets a caller sort by any column, including ones you don't expose — and on a native query it's an injection risk.
  • Index the sort column. Sorting by an unindexed created_at on a large table is a full scan plus a disk sort.
Say this

"For deep pagination I'd switch to keyset — WHERE (created_at, id) < (?, ?) — because OFFSET makes the database read and throw away every skipped row."

Task: consume an external API with retry, timeout and a fallback
// 1. The typed client interface
@HttpExchange("/api/pricing")
public interface PricingClient {
    @GetExchange("/{sku}")
    PriceDto price(@PathVariable String sku);
}

// 2. Configuration - timeouts are NOT optional
@Configuration
class PricingClientConfig {

    @Bean
    PricingClient pricingClient(@Value("${pricing.base-url}") String baseUrl) {
        var factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(1_000);          // TCP connect
        factory.setReadTimeout(2_000);             // waiting for the response

        RestClient client = RestClient.builder()
                .baseUrl(baseUrl)
                .requestFactory(factory)
                .defaultHeader("X-Trace-Id", MDC.get("traceId"))
                .build();

        return HttpServiceProxyFactory
                .builderFor(RestClientAdapter.create(client))
                .build()
                .createClient(PricingClient.class);
    }
}

// 3. Wrap it with resilience
@Service
public class PricingService {

    private final PricingClient client;
    private final Cache<String, PriceDto> cache =
            Caffeine.newBuilder().maximumSize(10_000)
                    .expireAfterWrite(Duration.ofMinutes(10)).build();

    @CircuitBreaker(name = "pricing", fallbackMethod = "fallbackPrice")
    @Retry(name = "pricing")
    public PriceDto price(String sku) {
        PriceDto p = client.price(sku);
        cache.put(sku, p);
        return p;
    }

    // Signature = original params + Throwable
    private PriceDto fallbackPrice(String sku, Throwable t) {
        log.warn("pricing unavailable for sku={}, serving cached", sku, t);
        PriceDto stale = cache.getIfPresent(sku);
        if (stale != null) return stale.markStale();
        throw new PricingUnavailableException(sku, t);      // no cache -> fail honestly
    }
}
# application.yml
resilience4j:
  retry:
    instances.pricing:
      maxAttempts: 3
      waitDuration: 200ms
      enableExponentialBackoff: true
      retryExceptions: [java.io.IOException, org.springframework.web.client.HttpServerErrorException]
  circuitbreaker:
    instances.pricing:
      slidingWindowSize: 50
      failureRateThreshold: 50
      waitDurationInOpenState: 30s
Say this

"Note the retry only lists IOException and 5xx — retrying a 400 is pointless and retrying a non-idempotent POST is dangerous. And the fallback serves stale data rather than pretending everything is fine, so the UI can show a 'prices may be out of date' banner."

Task: fix this code (the "code review" round)
What this round looks like

They hand you 30 lines of deliberately flawed code and ask what's wrong. Work through it in layers: correctness → security → performance → readability. Say what you'd block versus what's a suggestion.

// GIVEN THIS - how many problems can you find?
@RestController
public class OrderController {

    @Autowired
    private OrderRepository repo;

    @GetMapping("/orders")
    public List<Order> getOrders(@RequestParam String status) {
        List<Order> all = repo.findAll();
        List<Order> result = new ArrayList<>();
        for (Order o : all) {
            if (o.getStatus().equals(status)) {
                result.add(o);
            }
        }
        return result;
    }

    @PostMapping("/orders")
    public Order create(@RequestBody Order order) {
        try {
            return repo.save(order);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
}

The findings, in review order

  • [blocking] Loads the entire table into memory then filters in Java. Should be repo.findByStatus(status, pageable) — a query plus pagination.
  • [blocking] No pagination. This endpoint gets slower every day and will eventually OOM.
  • [blocking] Returns the entity directly — leaks the schema into the API, risks LazyInitializationException during serialization, and may expose internal fields.
  • [blocking] Accepts the entity as the request body — mass-assignment risk: a caller can set id, status or version. Use a request DTO.
  • [blocking] Swallows the exception, prints a stack trace to stdout, and returns null with a 200 OK. The client is told everything succeeded. Should propagate to a @RestControllerAdvice.
  • [blocking] No validation — no @Valid, no constraints.
  • [blocking] NPE risko.getStatus() assumes non-null; safer as status.equals(o.getStatus()) or Objects.equals.
  • [suggestion] Field injection — use constructor injection so the field can be final and the class is testable without Spring.
  • [suggestion] No API versioning/api/v1/orders.
  • [suggestion] POST should return 201 with a Location header, not 200.
  • [suggestion] status should be typed as the enum, so an invalid value is rejected at binding.
  • [nit] The filtering loop is a stream one-liner — but it shouldn't exist at all (see the first point).
Say this

"I'd lead with the two that would actually take the site down — loading the whole table and swallowing the exception — and group the rest. Listing twelve issues flatly overwhelms the author; prioritising them is the part that makes a review useful."

23. Hands-on Task — Build an Angular Feature

Task: build a searchable, paginated list component
What they're assessing

Whether you debounce, whether you use switchMap (not mergeMap), whether you handle loading and error states, whether you set OnPush, and whether you clean up subscriptions. Hit all five and it's a strong pass.

@Component({
  selector: 'app-order-list',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, RouterLink],
  changeDetection: ChangeDetectionStrategy.OnPush,          // 1. OnPush
  template: `
    <input [formControl]="search" placeholder="Search orders…" />

    @if (vm(); as state) {
      @switch (state.status) {
        @case ('loading') { <app-spinner /> }
        @case ('error')   { <app-error [msg]="state.message" (retry)="reload()" /> }
        @case ('empty')   { <p>No orders match “{{ search.value }}”</p> }
        @case ('loaded')  {
          <table>
            @for (o of state.data.content; track o.id) {       <!-- 2. track -->
              <tr>
                <td><a [routerLink]="['/orders', o.id]">{{ o.reference }}</a></td>
                <td>{{ o.total | currency:'INR' }}</td>
                <td>{{ o.createdAt | date:'medium' }}</td>
              </tr>
            }
          </table>
          <app-paginator [page]="page()" [total]="state.data.totalPages"
                         (pageChange)="page.set($event)" />
        }
      }
    }
  `
})
export class OrderListComponent {
  private api = inject(OrderApi);

  readonly search = new FormControl('', { nonNullable: true });
  readonly page   = signal(0);

  private readonly search$ = this.search.valueChanges.pipe(
    debounceTime(300),                    // 3. don't fire on every keystroke
    distinctUntilChanged(),
    tap(() => this.page.set(0)),          // new search resets to page 1
    startWith('')
  );

  readonly vm = toSignal(
    combineLatest([this.search$, toObservable(this.page)]).pipe(
      switchMap(([q, page]) =>            // 4. switchMap cancels the stale request
        this.api.search(q, page).pipe(
          map(data => data.content.length
                ? { status: 'loaded', data } as const
                : { status: 'empty' } as const),
          startWith({ status: 'loading' } as const),
          catchError(e => of({ status: 'error', message: friendly(e) } as const))
        ))
    )
  );                                      // 5. toSignal cleans up automatically

  reload(){ this.search.setValue(this.search.value); }
}
Talk through it as you write

"debounceTime so we don't hammer the API; switchMap so a slow earlier response can't overwrite a newer one; the state is a discriminated union so loading and error can't both be true; OnPush plus track for rendering; and toSignal handles the unsubscribe."

Task: build a reactive form with a custom and an async validator
@Component({
  selector: 'app-order-form',
  standalone: true,
  imports: [ReactiveFormsModule, CommonModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">

      <input formControlName="reference" placeholder="Reference" />
      @if (ref.touched && ref.errors) {
        @if (ref.errors['required'])  { <small>Reference is required</small> }
        @if (ref.errors['taken'])     { <small>That reference already exists</small> }
      }

      <div formArrayName="lines">
        @for (line of lines.controls; track $index) {
          <div [formGroupName]="$index">
            <input formControlName="sku" />
            <input formControlName="qty" type="number" />
            <button type="button" (click)="removeLine($index)">Remove</button>
          </div>
        }
      </div>

      <button type="button" (click)="addLine()">Add line</button>
      <button type="submit" [disabled]="form.invalid || form.pending || saving()">
        Save
      </button>
    </form>
  `
})
export class OrderFormComponent {
  private fb  = inject(FormBuilder);
  private api = inject(OrderApi);
  readonly saving = signal(false);

  readonly form = this.fb.nonNullable.group({
    reference: ['', [Validators.required, Validators.maxLength(40)],
                    [this.referenceTaken()]],                     // async validator
    lines: this.fb.array([this.newLine()], [Validators.required])
  }, { validators: [atLeastOneLine] });                           // group-level validator

  get ref()   { return this.form.controls.reference; }
  get lines() { return this.form.controls.lines; }

  private newLine(){
    return this.fb.nonNullable.group({
      sku: ['', Validators.required],
      qty: [1, [Validators.required, Validators.min(1)]]
    });
  }
  addLine(){ this.lines.push(this.newLine()); }
  removeLine(i: number){ this.lines.removeAt(i); }

  // ASYNC VALIDATOR - debounced so it doesn't call the API on every keystroke
  private referenceTaken(): AsyncValidatorFn {
    return (control: AbstractControl) => timer(400).pipe(
      switchMap(() => this.api.referenceExists(control.value)),
      map(exists => exists ? { taken: true } : null),
      catchError(() => of(null))          // a failed check should not block the user
    );
  }

  submit(){
    if (this.form.invalid) { this.form.markAllAsTouched(); return; }   // reveal all errors
    this.saving.set(true);
    this.api.create(this.form.getRawValue())
        .pipe(finalize(() => this.saving.set(false)))
        .subscribe({ next: () => this.router.navigate(['/orders']),
                     error: e  => this.toast.error(e) });
  }
}

// CUSTOM SYNC VALIDATOR at group level
export const atLeastOneLine: ValidatorFn = (group: AbstractControl) => {
  const lines = group.get('lines') as FormArray;
  return lines.length > 0 ? null : { noLines: true };
};
Say this

"Three details worth pointing out: the async validator is debounced with timer, a failed availability check returns null rather than blocking the user, and markAllAsTouched() on submit is what makes hidden errors appear."

Task: write an auth interceptor that refreshes an expired token
The hard part

If five requests fail with 401 at the same time, a naive implementation fires five refresh calls. You need the first one to refresh and the rest to wait for that result — that's what the interviewer is watching for.

let refreshing = false;
const refreshed$ = new BehaviorSubject<string | null>(null);

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);

  // never attach the token to the refresh call itself, or to third-party URLs
  if (req.url.includes('/auth/refresh')) return next(req);

  const token = auth.accessToken();
  const authed = token ? withToken(req, token) : req;

  return next(authed).pipe(
    catchError((err: HttpErrorResponse) => {
      if (err.status !== 401) return throwError(() => err);

      if (!refreshing) {
        refreshing = true;
        refreshed$.next(null);                     // make everyone else wait

        return auth.refresh().pipe(
          switchMap(newToken => {
            refreshing = false;
            refreshed$.next(newToken);             // release the queued requests
            return next(withToken(req, newToken)); // retry THIS request
          }),
          catchError(refreshError => {
            refreshing = false;
            auth.logout();                         // refresh failed -> session is over
            return throwError(() => refreshError);
          })
        );
      }

      // another request is already refreshing - queue up behind it
      return refreshed$.pipe(
        filter((t): t is string => t !== null),
        take(1),
        switchMap(newToken => next(withToken(req, newToken)))
      );
    })
  );
};

const withToken = (req: HttpRequest<unknown>, token: string) =>
    req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
Things to mention unprompted
  • Exclude the refresh endpoint, or you get infinite recursion.
  • Don't attach your token to third-party domains — that's a token leak.
  • Retry the failed request once only.
  • HttpRequest is immutable, hence req.clone().
Task: build a reusable presentational component with content projection
@Component({
  selector: 'app-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <section class="card" [class.card--danger]="variant() === 'danger'">
      <header>
        <ng-content select="[card-title]" />      <!-- named slot -->
      </header>

      <div class="card__body">
        <ng-content />                            <!-- default slot -->
      </div>

      @if (hasFooter()) {
        <footer><ng-content select="[card-actions]" /></footer>
      }
    </section>
  `
})
export class CardComponent {
  readonly variant   = input<'default' | 'danger'>('default');
  readonly hasFooter = input(true);
}
<!-- Using it -->
<app-card variant="danger">
  <h3 card-title>Cancel this order?</h3>
  <p>This cannot be undone.</p>
  <div card-actions>
    <button (click)="confirm()">Confirm</button>
  </div>
</app-card>
Say this

"Content projection is what makes a component reusable without a dozen configuration inputs. A card that takes titleText, bodyText and buttonLabel as inputs is inflexible; one that projects content adapts to any use case."

Task: write unit tests for an Angular component and service
// ---------- SERVICE ----------
describe('OrderApi', () => {
  let api: OrderApi;
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [OrderApi, provideHttpClient(), provideHttpClientTesting()]
    });
    api  = TestBed.inject(OrderApi);
    http = TestBed.inject(HttpTestingController);
  });

  afterEach(() => http.verify());          // fails if any unexpected request was made

  it('sends the search query and page as params', () => {
    api.search('abc', 2).subscribe(res => expect(res.content.length).toBe(1));

    const req = http.expectOne(r => r.url === '/api/v1/orders');
    expect(req.request.method).toBe('GET');
    expect(req.request.params.get('q')).toBe('abc');
    expect(req.request.params.get('page')).toBe('2');

    req.flush({ content: [{ id: 1 }], totalPages: 1 });
  });

  it('maps a 500 to a friendly error', () => {
    api.search('abc', 0).subscribe({
      error: e => expect(e.message).toContain('temporarily unavailable')
    });
    http.expectOne(() => true).flush('boom', { status: 500, statusText: 'Server Error' });
  });
});
// ---------- COMPONENT ----------
describe('OrderListComponent', () => {
  let fixture: ComponentFixture<OrderListComponent>;
  const apiSpy = jasmine.createSpyObj('OrderApi', ['search']);

  beforeEach(async () => {
    apiSpy.search.and.returnValue(of({ content: [{ id: 1, reference: 'R-1' }],
                                       totalPages: 1 }));
    await TestBed.configureTestingModule({
      imports: [OrderListComponent],                       // standalone -> import
      providers: [{ provide: OrderApi, useValue: apiSpy }]
    }).compileComponents();

    fixture = TestBed.createComponent(OrderListComponent);
    fixture.detectChanges();
  });

  it('renders one row per order', () => {
    const rows = fixture.nativeElement.querySelectorAll('tbody tr');
    expect(rows.length).toBe(1);
    expect(rows[0].textContent).toContain('R-1');
  });

  it('debounces the search input', fakeAsync(() => {
    const input = fixture.nativeElement.querySelector('input');
    input.value = 'ja';
    input.dispatchEvent(new Event('input'));

    tick(100);
    expect(apiSpy.search).toHaveBeenCalledTimes(1);     // still only the initial load

    tick(300);                                          // now past debounceTime(300)
    expect(apiSpy.search).toHaveBeenCalledTimes(2);
  }));
});
Say this

"I assert against the rendered DOM by text or role rather than internal CSS classes, so a styling change doesn't break the test. And fakeAsync/tick instead of real waiting — that's what keeps the suite fast and non-flaky."

Task: fix this Angular code (the review round)
// GIVEN THIS - what's wrong?
@Component({
  selector: 'app-orders',
  template: `
    <input (keyup)="search($event)" />
    <div *ngFor="let o of orders">
      {{ formatTotal(o) }}
    </div>
  `
})
export class OrdersComponent implements OnInit {
  orders: Order[] = [];

  constructor(private http: HttpClient) {}

  ngOnInit() {
    this.http.get<Order[]>('http://api.example.com/orders')
        .subscribe(data => this.orders = data);
  }

  search(event: any) {
    this.http.get<Order[]>('http://api.example.com/orders?q=' + event.target.value)
        .subscribe(data => this.orders = data);
  }

  formatTotal(o: Order) {
    return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' })
             .format(o.total);
  }
}

The findings

  • [blocking] No debounce on the search — one HTTP request per keystroke. Typing "chennai" fires seven requests.
  • [blocking] Race condition — responses can arrive out of order, so a slow early response overwrites a newer one. Needs switchMap.
  • [blocking] Method call in the templateformatTotal() runs on every change-detection cycle for every row, and it constructs a new Intl.NumberFormat each time. Use the currency pipe.
  • [blocking] Hard-coded absolute URL — should come from injected configuration, so the same build works in every environment.
  • [blocking] No error handling — a failed request leaves the user staring at an empty screen with no message.
  • [suggestion] No trackBy/track — the whole list is destroyed and rebuilt on every update.
  • [suggestion] No OnPush.
  • [suggestion] HttpClient used directly in the component — belongs in a typed service.
  • [suggestion] event: any — loses type safety; and reading event.target.value is fragile. Use a FormControl.
  • [suggestion] No loading state.
  • [nit] (keyup) misses paste-by-mouse; valueChanges on a FormControl catches everything.
Say this

"The two I'd genuinely block on are the missing debounce plus switchMap — that's a user-visible race condition, not just inefficiency — and the method call in the template, which is the most common cause of Angular performance complaints I've seen."