Learn Java by building one message service ☕

No walls of theory. We build a tiny backend, one need at a time. Each need teaches one Java idea, and each idea starts with the problem it solves and a real-world picture.

Our app is MiniChat: a Spring Boot backend that stores chat messages and serves them to thousands of users at once. We start with a single class holding one message and end with a concurrent REST service. Every section below adds the next piece the service needs.
Opened 0/16 · tap any section to expand
1

How your code actually runsJVM · bytecode · JIT

🤔 Goal: ship MiniChat once and have it run on the ops team's Linux boxes, your Mac, and a Windows laptop. C would need recompiling for each. How does one build run everywhere?
🌍 Think of it like
A movie script vs a live translator. You write the script (source). The compiler turns it into a universal screenplay (bytecode) that no CPU speaks directly. On each machine a translator (the JVM) reads that screenplay and performs it in the local language. Same script, any theater.

You compile .java into .class bytecode. That bytecode is not machine code. A program called the JVM runs it and, for the hot parts, quietly recompiles them into fast native code while the app is running.

source → bytecode → any machine
// MiniChat.java  (you write this)
public class MiniChat {
  public static void main(String[] args) {
    System.out.println("MiniChat is up");
  }
}
// javac MiniChat.java  → MiniChat.class (bytecode)
// java MiniChat        → JVM runs it on THIS machine

Now the words: javac is the compiler. The .class file is bytecode. The JVM (Java Virtual Machine) interprets it and the JIT (Just-In-Time compiler) turns frequently-run methods into native code. "Write once, run anywhere" is the whole point.

Interview line: Java is both compiled (to bytecode) and interpreted (by the JVM), with JIT giving near-native speed after warm-up.
2

Model a messageclass · interface · abstract class

🤔 Goal: MiniChat has text messages, image messages, and system notices. They share behavior (all have a sender) but differ (how they render). How do we share the common part without copy-paste, and force every type to provide the different part?
🌍 Think of it like
An interface is a job posting (must be able to render(), no instructions how). An abstract class is a half-built house (walls up, one room left for you to finish). A class is the finished house you can actually move into.

A class is a blueprint you create objects from. An interface is a pure contract: "whoever signs this must have these methods." An abstract class is a partial blueprint: it has some real code plus holes subclasses must fill.

contract → partial → concrete
interface Renderable {           // the contract
  String render();
}
abstract class Message implements Renderable {
  final String sender;            // shared state
  Message(String s){ sender = s; }
  String who(){ return sender; }      // shared behavior
  public abstract String render();  // hole to fill
}
class TextMessage extends Message {
  final String text;
  TextMessage(String s,String t){ super(s); text=t; }
  public String render(){ return sender+": "+text; }
}

Now the words: implements takes an interface, extends takes a class. A class can implement many interfaces but extend only one class. Reach for an interface to define "can-do" capabilities; an abstract class when subtypes genuinely share code and state.

When each: No shared code, just a capability → interface. Shared fields + partial logic → abstract class. Fully usable on its own → concrete class.
3

Store & look up messages fastList · Set · Map · HashMap internals

🤔 Goal: keep every message, and instantly fetch one by its id. Scanning a list of a million messages to find id #843921 is far too slow for a request. We need lookup in roughly one step, not a million.
🌍 Think of it like
A coat check. You don't walk the whole rack looking for your coat. The attendant hashes your ticket number to a specific hook (a bucket). Go straight to that hook. That's a HashMap: the key is hashed to a bucket, so lookup is nearly instant no matter how many coats.

List = ordered, duplicates allowed (the message log). Set = no duplicates (unique active user ids). Map = key → value (id → message). A HashMap keeps an array of buckets; the key's hashCode() picks the bucket. Type a key and watch which bucket it lands in:

live · hashCode % buckets = which hook your key hangs on
enter a key…
Collision: two keys can land in the same bucket. Java then chains them in that bucket (a linked list, upgraded to a balanced tree once a bucket gets large). That is why a bad hashCode() that returns the same number for everything turns your O(1) map into an O(n) list.

Now the words: the hashCode/equals contract: equal objects must return equal hash codes. The map uses hashCode() to find the bucket, then equals() to find the exact entry inside it. Break the contract and you'll put a key but never get it back.

ArrayList vs LinkedList: ArrayList = array under the hood, O(1) random access, slow inserts in the middle. LinkedList = nodes with pointers, O(1) insert at the ends, O(n) to reach index i. In practice ArrayList wins almost always.
4

One container, any message typegenerics · bounded wildcards · PECS

🤔 Goal: a store that holds messages but is type-safe. Before generics you stored Object and cast on the way out, and a stray String that slipped in blew up at runtime. We want the compiler to catch that mistake.
🌍 Think of it like
Labeled shipping boxes. A box stamped "BOOKS ONLY" refuses a toaster at packing time, not when the customer opens it. Generics stamp the type on the container so the compiler rejects the wrong item up front.

A type parameter <T> lets one class work for any type while staying checked. Wildcards handle "some subtype I don't name": ? extends for reading, ? super for writing.

PECS: Producer Extends, Consumer Super
class Store<T> {              // T = whatever type you pick
  private final List<T> items = new ArrayList<>();
  void add(T item){ items.add(item); }
  T get(int i){ return items.get(i); }
}
// PRODUCER (you read out of it): ? extends
void printAll(List<? extends Message> src){
  for(Message m : src) System.out.println(m.render());
}
// CONSUMER (you write into it): ? super
void fill(List<? super TextMessage> dst){
  dst.add(new TextMessage("sys","hi"));
}

Now the words: PECS = "Producer Extends, Consumer Super." If a collection produces values you read, use ? extends. If it consumes values you write, use ? super. Generics are erased at runtime (type erasure), so they're a compile-time safety net.

5

When the database call failschecked vs unchecked · try-with-resources

🤔 Goal: saving a message can fail (DB down) and we open a connection that must always close, even on error. Ignoring failure corrupts state; forgetting to close leaks connections until the service dies.
🌍 Think of it like
A fire alarm vs a spilled coffee. A checked exception is a fire alarm: the building code forces you to have a plan (handle or declare it). An unchecked exception is spilled coffee: a bug you should have prevented, not something you plan around.

Checked exceptions (extend Exception) must be caught or declared. Unchecked (extend RuntimeException, like NullPointerException) signal programming bugs. try-with-resources auto-closes anything you open.

the resource closes itself, pass or fail
// connection auto-closes at the end of the block
try (Connection c = pool.get()) {
  c.save(message);
} catch (SQLException e) {        // checked: must handle
  log.error("save failed", e);
  throw new SaveFailedException(e);  // unchecked wrapper
}
// no finally needed — try-with-resources calls c.close()
Never swallow an exception with an empty catch {}. And never catch (Exception e) just to hide it. Log it, wrap it, or rethrow. A silent failure is the worst kind.

Now the words: a class in try-with-resources must implement AutoCloseable. The finally block always runs; try-with-resources is sugar that generates the close-in-finally for you.

6

Process the message logStreams · map/filter/reduce · lazy

🤔 Goal: from today's messages, get the uppercased text of the first 3 non-empty ones from user42. The for-loop version is a nest of ifs, a counter, and a result list. Noisy and easy to get wrong.
🌍 Think of it like
A factory conveyor belt. Messages ride the belt through stations: a filter station drops the empties, a map station stamps each one, a collect station boxes the survivors. You describe the stations, the belt does the walking.

A stream is a pipeline over a collection. filter keeps some, map transforms each, collect gathers the result. It's lazy: nothing runs until a terminal op like collect, so limit(3) can stop early.

live · run the pipeline on sample messages
List<String> result = messages.stream()
  .filter(m -> m.sender().equals("user42"))
  .filter(m -> !m.text().isBlank())
  .map(m -> m.text().toUpperCase())
  .limit(3)                     // lazy: stops after 3
  .collect(Collectors.toList());

Now the words: intermediate ops (filter/map/limit) are lazy and return a new stream. terminal ops (collect/forEach/reduce/count) trigger execution. reduce folds the stream into one value (e.g. total character count).

Don't reuse a stream after a terminal op (it's consumed), and avoid side-effects inside map. Streams are for transforming data, not for mutating outside state.
7

The message might not existOptional

🤔 Goal: findMessage(id) may find nothing. Return null and every caller forgets a null check exactly once, then production throws NullPointerException. We want "maybe absent" to be impossible to ignore.
🌍 Think of it like
A gift box that might be empty. Instead of handing someone the gift or thin air, you always hand them a box. They must open it (isPresent / orElse) before using what's inside. The box itself is never null.

Optional<T> is a container that holds a value or nothing. It makes absence part of the type, so the compiler nudges callers to deal with it.

safe unwrap with a fallback
public Optional<Message> find(String id){
  return Optional.ofNullable(store.get(id));
}
// caller — no null check, no NPE:
String text = find("m1")
    .map(Message::text)
    .orElse("[deleted]");
Avoid .get(). Calling get() on an empty Optional throws, which is exactly the NPE you were escaping. Use orElse, orElseGet, orElseThrow, map, or ifPresent instead.

Now the words: use Optional as a return type for "might be missing." Don't use it for fields or method parameters, and never wrap a collection in Optional (return an empty list instead).

8

Write the message type in 3 linesrecords · sealed · switch expr · var

🤔 Goal: a plain immutable Message needs fields, a constructor, getters, equals, hashCode, and toString. That's 40 lines of boilerplate for a bag of data. And we want the compiler to know the message kinds are a closed set.
🌍 Think of it like
A pre-printed form vs a blank page. A record is a form where you just fill the fields; Java prints the rest (constructor, getters, equals). A sealed type is a guest list: only the named types may join the family, so a switch can be sure it covered everyone.

records = one-line immutable data carriers. sealed = restrict who can extend/implement. switch expressions return a value and are exhaustive. var = infer the local type from the right side.

the whole model, tersely
sealed interface Message
    permits TextMsg, ImageMsg, SystemMsg {}

record TextMsg(String sender, String text) implements Message {}
record ImageMsg(String sender, String url)  implements Message {}
record SystemMsg(String text)              implements Message {}

var m = new TextMsg("user42", "hey");   // var infers TextMsg

String preview = switch (m) {          // exhaustive, returns a value
  case TextMsg t  -> t.text();
  case ImageMsg i -> "📷 " + i.url();
  case SystemMsg s -> "⚙ " + s.text();
};                                       // no default needed — sealed!

Now the words: a record auto-generates the canonical constructor, accessors, equals/hashCode/toString, and is implicitly final and immutable. Because Message is sealed, the compiler knows every case is handled and rejects the switch if you add a type and forget it.

Don't overuse var. var x = getData(); hides the type from the reader. Use it when the right side already names the type (var list = new ArrayList<Message>()).
9

Thousands of users at oncethreads · executors · locks · atomics

🤔 Goal: MiniChat counts total messages sent. Under load, 1000 requests do count = count + 1 at the same moment. That line is read, add, write (three steps). Two threads read the same old value and one increment is lost. The counter drifts below the truth.
🌍 Think of it like
One shared whiteboard, many people writing. Two people both read "5", both write "6". Two messages happened but the board says 6, not 7. That's a race condition. A lock is a marker only one person can hold: you must grab it before touching the board.

A Thread runs code in parallel. You almost never create raw threads; an ExecutorService is a managed pool that reuses them. To protect shared data, use synchronized/Lock, or an atomic that does read-add-write as one unbreakable step. Run the race:

live · 1000 increments across "threads" — watch the count get lost, then fixed
expected after 1000 increments: 1000
// UNSAFE: three steps, interleaves, loses updates
int count = 0;  count++;

// SAFE option A — atomic single step
AtomicInteger count = new AtomicInteger();
count.incrementAndGet();

// SAFE option B — explicit lock
lock.lock();
try { count++; } finally { lock.unlock(); }
ExecutorService + Future/CompletableFuture: submit work, get a handle to the result later. CompletableFuture chains async steps without blocking, e.g. fetch messages, then enrich, then return.
ExecutorService pool = Executors.newFixedThreadPool(8);
CompletableFuture
  .supplyAsync(() -> repo.load(id), pool)   // off-thread
  .thenApply(Message::render)                // transform result
  .thenAccept(System.out::println);          // consume it

Now the words: synchronized = built-in lock on an object's monitor. Lock (ReentrantLock) = flexible explicit lock (tryLock, timeouts). volatile guarantees visibility (one thread's write is seen by others) but not atomicity. Atomics give lock-free atomic updates via CPU compare-and-swap.

volatile ≠ synchronized. volatile int count; count++ is still a race, because ++ is three ops. Use volatile for a simple flag one thread sets and others read; use atomics or locks for read-modify-write.
10

When are two messages equal?the equals/hashCode contract

🤔 Goal: we put two Message objects with the same id into a HashSet to dedupe. Both stay. By default Java compares by memory address, so two distinct objects are "different" even with identical fields. Dedup fails.
🌍 Think of it like
Identical twins with the same ID card. By default Java says "different people, different bodies." You want it to say "same ID = same person." You teach it what "same" means by overriding equals, and you must update the fingerprint (hashCode) to match, or the coat check (section 3) files them under different hooks.

Override both together. Equal objects must have equal hash codes, always. If you override one and not the other, HashMap/HashSet break in confusing ways.

a correct implementation
@Override public boolean equals(Object o){
  if (this == o) return true;
  if (!(o instanceof Message m)) return false;
  return id.equals(m.id);       // equal iff same id
}
@Override public int hashCode(){
  return Objects.hash(id);       // MUST match equals fields
}

Now the words: the contract: (1) equal objects → equal hashCodes; (2) equals is reflexive, symmetric, transitive, and consistent. Use the same fields in both. A record generates a correct equals/hashCode for you from all its components, another reason to prefer records for data.

The classic bug: override equals only. Now two "equal" messages get different buckets, so set.contains(msg) returns false for a message you just added.
11

Make a message impossible to corruptimmutability = free thread safety

🤔 Goal: a Message object is shared across many request threads. If any of them can mutate its text, we're back to races (section 9) and defensive locking everywhere. Locking is slow and easy to forget.
🌍 Think of it like
A printed newspaper vs a shared whiteboard. A hundred people can read the same printed page at once with zero coordination, because no one can change it. An immutable object is that printed page: safe to share freely, no locks required.

Make fields final, set them once in the constructor, expose no setters, and copy any mutable input in / mutable output out. Now the object can never change after birth, so no thread can ever see it half-updated.

immutable by construction
public final class Message {   // final: no subclass sneaks mutability
  private final String id;
  private final List<String> tags;
  public Message(String id, List<String> tags){
    this.id = id;
    this.tags = List.copyOf(tags);  // defensive copy → unmodifiable
  }
  public List<String> tags(){ return tags; } // already unmodifiable
}

Now the words: an immutable object's observable state never changes after construction. It's inherently thread-safe, safe to cache, and safe as a Map key. String and the boxed types (Integer, etc.) are immutable for exactly this reason. Records are shallowly immutable by default.

Shallow trap: a final field pointing at a mutable list is still mutable through that reference. Wrap with List.copyOf or Collections.unmodifiableList.
12

Where objects live and dieheap vs stack · generations · G1

🤔 Goal: MiniChat creates a Message per request. In C you'd free() each one and a single miss leaks memory forever. Who cleans up in Java, and how does it avoid pausing the whole service to do it?
🌍 Think of it like
A desk (stack) and a warehouse (heap). The stack is your desk: small, per-thread, holds the current call's locals, cleared the instant a method returns. The heap is a shared warehouse where all objects live. A janitor (the garbage collector) periodically hauls away boxes nobody references anymore.

Local variables and call frames live on the stack (fast, auto-freed on return). Objects live on the heap. The GC automatically reclaims objects nothing points to. Most objects die young, so the heap is split into generations to collect cheaply.

the generational idea
new Message()
born in Eden
Young Gen
most die here, fast sweep
Old Gen
survivors: long-lived

Now the words: the weak generational hypothesis: most objects die young. The GC collects the young generation often and cheaply (minor GC); survivors are promoted to the old generation. G1 (the default since Java 9) splits the heap into regions and does mostly-concurrent, low-pause collection. A StackOverflowError = too-deep recursion; OutOfMemoryError = heap exhausted (often a leak: objects still referenced but never used).

Interview line: you don't free memory in Java; you make objects unreachable and the GC reclaims them. A "memory leak" in Java = an unintended reference keeping objects alive (e.g. a static Map that grows forever).
13

Pass behavior, not just datalambdas · Function/Predicate/Supplier/Consumer

🤔 Goal: we want message-filtering rules the caller decides: block spam, or block muted users. Writing a new class per rule is heavy. We want to pass the rule itself as an argument.
🌍 Think of it like
Handing someone a recipe card instead of the cooked dish. A lambda is a little slip of behavior you pass around. The method that receives it decides when to "cook" (call) it.

A functional interface has exactly one abstract method, so a lambda can stand in for it. Four you'll use constantly: Function<T,R> (transform), Predicate<T> (test → boolean), Supplier<T> (produce), Consumer<T> (consume, no return).

the four workhorses, on messages
Predicate<Message> notSpam = m -> !m.text().contains("BUY NOW");
Function<Message,String> toLine = m -> m.sender()+": "+m.text();
Supplier<String> newId  = () -> UUID.randomUUID().toString();
Consumer<String> logIt  = s -> log.info(s);

messages.stream()
  .filter(notSpam)      // Predicate
  .map(toLine)          // Function
  .forEach(logIt);      // Consumer

Now the words: @FunctionalInterface marks a single-abstract-method interface. A lambda m -> ... is shorthand for implementing it. A method reference (Message::text) is even shorter when the lambda just calls one method. This is what makes the Streams API (section 6) read cleanly.

14

Expose MiniChat over HTTPcontroller · service · repository

🤔 Goal: a POST to save a message and a GET to fetch them. If HTTP parsing, business rules, and database code all live in one method, it's untestable and unchangeable. We want clean seams.
🌍 Think of it like
A restaurant. The waiter (controller) takes the order and speaks to customers. The chef (service) knows the recipes and rules. The pantry (repository) just stores and fetches ingredients. Each does one job; swap the pantry without retraining the chef.

Three layers: Controller handles HTTP (routes, JSON in/out). Service holds business logic. Repository talks to the database. Spring wires them together via dependency injection (constructor injection).

the three layers, top to bottom
@RestController @RequestMapping("/messages")
class MessageController {
  private final MessageService svc;
  MessageController(MessageService svc){ this.svc = svc; } // injected

  @PostMapping
  Message send(@RequestBody NewMessage req){ return svc.save(req); }

  @GetMapping("/{id}")
  Message get(@PathVariable String id){
    return svc.find(id).orElseThrow(() -> new NotFound(id));
  }
}
@Service class MessageService {   // business rules live here
  private final MessageRepository repo;
  MessageService(MessageRepository repo){ this.repo = repo; }
  Message save(NewMessage r){ return repo.save(new Message(r)); }
  Optional<Message> find(String id){ return repo.findById(id); }
}
@Repository interface MessageRepository
    extends JpaRepository<Message, String> {}  // Spring writes the SQL

Now the words: dependency injection means the framework supplies each layer's collaborators instead of the class new-ing them, which makes layers swappable and testable (inject a fake repo in tests). The controller returns POJOs/records; Spring serializes them to JSON automatically.

This is the whole service: the record from section 8, stored in the collection/DB from section 3, served safely to the concurrent clients from section 9, returning the Optional from section 7. Every earlier idea shows up here.
15

Interview rapid-firetap a card to flip

16

Mistakes that fail code reviewanti-patterns