You do not need all of Java before you start a framework. You do need a specific subset — and it is not the subset a beginner course covers, because a beginner course optimises for "you can write a program" and backend work optimises for "you can change someone else's program without breaking it".
This post is that subset, and the handful of JVM facts that explain the bugs you will actually hit.
What you can skip for now
Swing. Applets. Manual thread creation. The Date and Calendar classes —
they are still in the language and every one of their replacements is better. Serialization.
Reflection, until the day you need it, which may be never.
Do not skip: collections, equals/hashCode, exceptions, generics,
interfaces, and enough concurrency to know why your code is not single-threaded.
Collections — and which one to reach for
You will use these every hour. The interface is the type you declare; the class is what you
new.
| You want | Use | Cost of lookup |
|---|---|---|
| An ordered list, mostly read by index or iterated | ArrayList | O(1) by index, O(n) by value |
| To ask "have I seen this?" | HashSet | O(1) |
| To look something up by a key | HashMap | O(1) |
| Keys kept sorted | TreeMap | O(log n) |
| Insertion order preserved in a map | LinkedHashMap | O(1) |
| A map several threads write to | ConcurrentHashMap | O(1), and safe |
The one that matters most: if you find yourself looping over a list to find a matching
element, you probably wanted a map. That loop is O(n) and it is inside another loop more
often than you would like. The demo app does this deliberately when pricing an order — it loads
every topping the order mentions in one query and puts them in a Map, rather than
querying per line:
// Load every topping the order mentions in ONE query rather than one per line.
Map<UUID, Topping> toppingsByPublicId = loadToppings(dto);That single line is the difference between one database round trip and twenty. For why the costs in that table are what they are, the Data Structures & Algorithms track goes underneath them.
equals and hashCode — the pair that breaks things quietly
Two rules, and the second is the one people miss:
- If two objects are
equals, they must return the samehashCode. - If you override one, override the other.
Break rule 1 and a HashMap stops working — you put an object in, you look it up with
an equal object, and it is not there. Nothing throws. The entry is simply in a different bucket
forever.
In backend code this shows up in a specific place: entity classes. Lombok's
@Data generates equals, hashCode and toString
across every field — including the collection of children. On a JPA entity that is a bug in three
directions at once:
/**
* Sizes are owned by the product: saving a product saves its sizes, and removing a size
* from this list deletes the row (that is what orphanRemoval does).
*
* Excluded from equals/hashCode/toString. @Data would otherwise walk the collection —
* forcing a lazy load on every toString, and recursing forever through the child's
* parent reference. This is the single most common way @Data and JPA go wrong together.
*/
@ToString.Exclude
@EqualsAndHashCode.Exclude
@Builder.Default
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("price ASC")
private List<ProductSize> sizes = new ArrayList<>();Two exclusion annotations, and they prevent an infinite recursion, a surprise database query on every log line, and a hash code that changes as the collection loads. Read the comment, not just the code — that is what a real codebase looks like.
null, and Optional
NullPointerException is the most common runtime failure in Java. Most of it comes
from one habit: returning null to mean "nothing found", and the caller forgetting.
Optional<T> makes "there might be nothing" part of the type, so the compiler
makes you deal with it:
/** Convenience for pricing: the price for one size, if this product is sold in it. */
public Optional<BigDecimal> priceFor(SizeName size) {
return sizes.stream()
.filter(s -> s.getSize() == size)
.map(ProductSize::getPrice)
.findFirst();
}And at the call site, the "nothing found" branch cannot be skipped:
BigDecimal basePrice = product.priceFor(line.size())
.orElseThrow(() -> ApiException.badRequest(
product.getName() + " is not sold in size " + line.size()));Use Optional as a return type for a lookup that may find nothing.
Do not use it for fields or method parameters — that is not what it is for, and it makes everything
noisier for no benefit. Never call .get() without checking first; that is the same bug
you started with, wearing a hat.
Streams — and when not to
A stream expresses "for each of these, do this, keep those, collect the rest" without an index variable. Use it when it makes the intent clearer:
List<UUID> before = created.sizes().stream()
.map(s -> s.id())
.sorted()
.toList();Do not use it when the loop was already clear, and do not chain six operations because you can —
a stack trace from a deeply nested stream is genuinely worse to read than one from a
for loop. And never do database work inside one: a .map() that runs a
query is N+1 in disguise, which is post 6.
Exceptions that mean something
Two kinds. Checked exceptions must be declared or caught; unchecked
ones (anything extending RuntimeException) need not be. Backend code overwhelmingly uses
unchecked, because an error six layers down is almost never recoverable at layer five — it needs to
travel to the top and become an HTTP response.
The pattern worth copying: one application exception that carries its own status and message, with static factories so the call sites read like sentences.
public class ApiException extends RuntimeException {
private ApiError error;
/** 400 — the request is well-formed but semantically wrong. */
public static ApiException badRequest(String message) {
return new ApiException(HttpStatus.BAD_REQUEST, message);
}
/** 404 — no such row. */
public static ApiException notFound(String what, Object id) {
return new ApiException(HttpStatus.NOT_FOUND, what + " " + id + " was not found");
}
}Then throw ApiException.notFound("Product", id) reads as what it is. Two rules:
never catch an exception and do nothing with it, and never put a stack trace in an HTTP response —
it belongs in the log, where it tells you something and not an attacker.
Records
A record is an immutable data carrier: give it components, get a constructor,
accessors, equals, hashCode and toString for free. Backend
code is full of things that should be records — request bodies, response bodies, events,
configuration, the result of a calculation.
public record OrderPlacedEvent(
UUID orderPublicId, String contactEmail, BigDecimal total, OrderType orderType) {}Immutability is the point, not the brevity. A value that cannot change after construction cannot be changed by another thread while you are reading it, and cannot be modified by a method you handed it to. That eliminates an entire class of bug for free.
The JVM facts that explain your production bugs
You do not need to know how the garbage collector works. You do need these five.
- Your app is multi-threaded whether you wrote threads or not. The server runs
each request on a thread from a pool. Any field on a shared object — a
@Servicebean, a static — is touched by all of them at once. - Therefore: keep beans stateless. Mutable state on a singleton service is the most common source of "it works locally, it corrupts data in production". Locally there is one user.
- The heap is finite. Loading a whole table into a
Listworks on the 2,000 rows in dev and takes the process down on the 20 million in production. Paginate, stream, or aggregate in the database. - Blocking a thread costs a thread. While a request waits on a slow query or a third-party HTTP call, its thread is parked and unavailable. Enough of those at once and the pool is exhausted — your app is "down" while using almost no CPU. This is what virtual threads in Java 21 are for.
BigDecimalfor money, always.doublecannot represent 0.1 exactly, so0.1 + 0.2is not0.3. Every price and total in the demo app is aBigDecimal, and every comparison usesisEqualByComparingTorather thanequals— because2.50and2.5are equal in value and different in scale.
What to remember
- Collections,
equals/hashCode, exceptions, generics — that is the core. Skip the rest until you need it. - Looping to find a match usually means you wanted a
Map. - Override
equalsandhashCodetogether, and never over a lazy collection on an entity. Optionalas a return type for lookups. Never.get()unchecked.- One unchecked application exception carrying its own status beats exceptions scattered everywhere.
- Records for anything that is a value. Immutable is thread-safe for free.
- Your beans are shared across threads — keep them stateless. Money is
BigDecimal.
Next: what to learn in a framework — the small part of a large thing that you use every day.