Idioms & Common Gotchas — Write It Like a Java Dev, Dodge the Traps
You can write Java that compiles and runs. This phase is about the gap between that and code that looks like a seasoned Java developer wrote it — plus the short list of traps that have caught every Java programmer who ever lived. (Genuinely. The == versus .equals() one alone has cost the industry more debugging hours than anyone wants to count. You're about to skip past it.)
Two halves, like a good knife. First the idioms — the conventions that make Java code feel coherent instead of arbitrary. The mental model there: Java rewards small contracts and unchanging data. You depend on interfaces, not concrete classes; you make things final and immutable so they can't change behind your back; you say "no value" out loud with Optional instead of the silent landmine that is null.
Then a scannable gotcha cheat-card — the surprises named before they bite, so when you hit one you'll recognize it instead of staring at a stack trace. The mental model there: objects are accessed through references, and Java does exactly what the rules say, not what you assumed. Almost every trap below is a reference doing something a beginner didn't expect.
Idioms — the way it's written today
Program to interfaces, not implementations
What it actually is. When you declare a variable, a parameter, or a return type, name the interface (List, Map, Collection) rather than the concrete class (ArrayList, HashMap). You still create the concrete thing with new — you just don't let the rest of your code depend on which one it is.
// Idiomatic: the type is the contract, not the implementation.
names ;
// Not idiomatic: now everything downstream is welded to ArrayList.
names2 ;
What just happened: both lines create an ArrayList. But the first one types the variable as List — the interface. That means a method taking List<String> accepts an ArrayList, a LinkedList, or List.of(...) without changes, and you can swap the implementation later by editing one line. Depending on the interface keeps the contract narrow: callers know they get "a list," not "this exact class." This is why you'll see method signatures like void process(List<Order> orders) everywhere and almost never void process(ArrayList<Order> orders).
💡 Key point. The rule of thumb: declare with the most general interface that does the job (List, Map, Set, Collection), construct with the concrete class. Flexible on the outside, specific on the inside.
Prefer immutability
What it actually is. An immutable object is one whose state can't change after it's built. You reach for it by marking fields final, not exposing setters, and assigning everything in the constructor. Once it exists, it's frozen.
What just happened: Point has no setters and every field is final, so once constructed it can never change. Need a different x? withX hands you a brand-new Point and leaves the original untouched. This sounds wasteful but it's the opposite of a problem: immutable objects are automatically thread-safe (nothing can race to change them), they're safe to share and cache, and they can never be in a half-updated state. When in doubt, make value objects immutable. (In Phase 13 you'll see record automate this entire pattern down to one line.)
⚠️ Gotcha — final on a reference freezes the reference, not the object. final List<String> items = new ArrayList<>(); stops you from reassigning items, but you can still items.add(...) all day. final means "this variable always points at the same object," not "that object can't change." For true immutability you need the object itself to be unchangeable (e.g. List.copyOf(items)).
Use Optional instead of returning null
What it actually is. Optional<T> is a small box that either holds a value or is explicitly empty. When a method might legitimately have "no answer" (no user found, no config set), returning Optional<User> instead of User (which might secretly be null) makes the absence visible in the type — the caller can't forget to handle it.
// Caller is forced to deal with the empty case:
String shown ;
System.out.;
(none)
What just happened: findNickname returns an Optional<String>, so its signature announces "there might be nothing here." The caller used .orElse("(none)") to supply a fallback. Compare that to returning null: nothing in the signature warns you, and the day you forget a null check, you get a NullPointerException in production instead of a compiler nudge. Optional turns a silent runtime trap into an explicit, visible decision.
💡 Key point. Use Optional for return types that may be absent. Don't use it for fields or method parameters (it adds ceremony without much payoff there), and never call .get() without checking first — that just reinvents the NPE.
Loop with the enhanced for and streams
What it actually is. The enhanced for-loop (for (var x : items)) iterates without a manual index. Streams go further: they let you describe what to do to a collection — filter, map, collect — instead of spelling out how with a counter.
names ;
// Enhanced for — no index to fat-finger.
for
// Stream — describe the transformation as a pipeline.
longOnes ;
System.out.;
ADA
BOB
CLEO
[cleo]
What just happened: the enhanced for-loop walked the list without an int i to manage or an off-by-one to get wrong. The stream then expressed "keep the names longer than three characters" as a readable pipeline — filter describes the intent, not the mechanics. Streams shine when you're chaining transformations; for a simple walk, the enhanced for is perfectly idiomatic. (We go deep on streams and lambdas in Phase 12.)
Write meaningful equals, hashCode, and toString
What it actually is. By default, equals on your class checks identity (are these the same object in memory?), and toString prints something useless like Point@1b6d3586. If two objects with the same field values should count as "equal" — and especially if you'll put them in a HashMap or HashSet — you need to override equals and hashCode together, as a pair.
public boolean
public int
public String
What just happened: equals now compares the actual field values, so two Point(1, 2) objects are equal even though they're separate objects in memory. hashCode uses the same fields — this is non-negotiable, because hash-based collections use hashCode to find the bucket and equals to confirm the match. If they disagree, your objects vanish into a HashMap and you can't get them back out. toString makes debugging humane. (Yes — records generate all three for you. Until then, this is the contract to honor.)
💡 The umbrella idiom: make intent explicit and make illegal states impossible. Interfaces narrow the contract, immutability removes a whole class of "who changed this?" bugs,
Optionalmakes absence visible, and a properequals/hashCodemakes equality mean what you actually mean. Favor composition (holding objects as fields) over deep inheritance hierarchies for the same reason — clarity over cleverness.
The gotcha cheat-card
Hit something baffling? Find the symptom here, then read the note below. These trap everyone — recognizing them on sight is most of the battle.
| The trap | What bites you | The fix |
|---|---|---|
== vs .equals() |
== compares references, so two equal-looking objects/strings can be "not equal" |
Compare objects with .equals(); reserve == for primitives and identity |
null / NPE |
Calling a method on null throws NullPointerException at runtime |
Optional, explicit null checks, Objects.requireNonNull on inputs |
| Autoboxing surprises | Integer caches −128..127, so == "works" then mysteriously breaks; unboxing a null Integer throws NPE |
Use .equals() for Integer; keep arithmetic in primitives |
| Mutating a list while looping it | Removing during a for-each throws ConcurrentModificationException |
Use an Iterator's remove(), or removeIf(...), or collect-then-remove |
| Integer division | 5 / 2 is 2, not 2.5 — the fraction is silently discarded |
Cast one operand to double: 5 / 2.0 |
| Shared mutable state / arrays | Passing a list or array hands over a reference; the callee can mutate your data | Defensive copies (List.copyOf) or immutable objects |
Catching Exception too broadly |
A blanket catch (Exception e) {} swallows bugs and hides real failures |
Catch specific types; never leave a catch block empty |
Now the why behind the sharpest three.
== vs .equals()
This is the big one. == asks "are these the same object in memory?" .equals() asks "do these objects represent the same value?" For primitives (int, double, boolean) == is correct and the only option. For objects — including String — == almost never means what you want.
String a ;
String b ;
System.out.; // false — two different objects
System.out.; // true — same characters
false
true
What just happened: new String("hello") built two separate String objects that happen to hold the same characters. == compared their references — different objects, so false. .equals() compared their contents — same text, so true. The reason this is so dangerous: with string literals ("hello" == "hello") Java often returns true because it pools identical literals, which lulls beginners into thinking == works on strings. Then one day a string comes from user input or a file, the pool doesn't apply, and == quietly returns false. Always compare strings and objects with .equals().
Autoboxing and the Integer cache
Java auto-converts between primitives (int) and their object wrappers (Integer) for you — that's autoboxing. Convenient, and the source of two classic traps. First, == on two Integer objects compares references, not values — but the JVM caches small Integer objects from −128 to 127, so == accidentally "works" in that range and then breaks above it.
Integer a , b ;
System.out.; // true — both from the cache (-128..127)
Integer c , d ;
System.out.; // false — outside cache, two real objects
System.out.; // true — value comparison, correct
true
false
true
What just happened: 100 falls inside the cached range, so a and b point at the same cached Integer and == is true. 200 is outside the cache, so c and d are separate objects and == is false — even though both hold 200. This is a vicious bug because it passes every test using small numbers and fails in production on large ones. Use .equals() for wrapper objects. The second autoboxing trap: unboxing a null Integer (e.g. int x = someInteger; when someInteger is null) throws a NullPointerException from a line that doesn't even mention null. Keep arithmetic in primitives and you sidestep both.
Integer division
Dividing two ints gives an int — Java throws away the remainder rather than producing a fraction. This bites every beginner computing an average or a percentage.
int total , count ;
System.out.; // 2 — fraction discarded!
System.out.; // 2.5 — cast first
2
2.5
What just happened: 5 / 2 is integer arithmetic, so the result is 2 with the .5 silently dropped — no error, no warning, just a wrong number. Casting one operand to double ((double) total / count) forces floating-point division and you get 2.5. The rule: if you want a fractional result, make sure at least one operand is a double before the division happens. (double)(total / count) is too late — the integer division already ran.
📝 The other three, in one line each. ConcurrentModificationException — you removed from a list inside a for-each loop; use list.removeIf(...) or an explicit Iterator.remove() instead. Shared mutable state — handing out your internal list or array lets the receiver mutate it; return List.copyOf(...) or an immutable copy. Over-broad catch — catch (Exception e) {} swallows the very bug you need to see; catch the specific exception you can actually handle, and never leave the block empty.
Recap
- Program to interfaces — declare
List/Map, constructArrayList/HashMap. Narrow contracts, swappable implementations. - Prefer immutability —
finalfields, no setters, "change" returns a new object. Thread-safe and bug-resistant by construction. (Butfinalon a reference freezes only the reference.) - Return
Optional, notnull— make "no value" visible in the type so callers can't forget it. - Loop with enhanced for and streams, and write real
equals/hashCode/toString(as a matched pair for the first two) — records will automate this later. - ⚠️ The cheat-card —
==compares references (use.equals()for objects and strings); NPEs come fromnull;Integercaching makes==lie outside −128..127;5/2is2; shared references let others mutate your data; don't swallow exceptions.
That's idiomatic Java. You can now read other people's Java and write code that looks like it belongs — and you've met the traps before they meet you. Next we go deep on generics: how List<T> really works, wildcards, and why the compiler sometimes argues with you about types.
Quick check
Test yourself on the three traps that catch everyone:
[
{
"q": "You have two String objects built with `new String(\"hi\")`. What do `a == b` and `a.equals(b)` return?",
"choices": [
"`a == b` is false (different objects), `a.equals(b)` is true (same characters)",
"Both are true — strings always compare by value",
"Both are false — the strings are stored separately",
"`a == b` is true, `a.equals(b)` is false"
],
"answer": 0,
"explain": "`==` compares references, and `new String(...)` makes two distinct objects, so `a == b` is false. `.equals()` compares contents, so it's true. Always use `.equals()` for strings and objects."
},
{
"q": "Why does `Integer a = 100, b = 100; a == b` print `true`, but `Integer c = 200, d = 200; c == d` print `false`?",
"choices": [
"Java caches Integer objects from −128 to 127, so 100 reuses one cached object while 200 creates two separate ones",
"200 is too large to fit in an int, so it overflows",
"`==` rounds large numbers differently",
"It's undefined behavior and the result is random"
],
"answer": 0,
"explain": "The JVM caches small Integer objects (−128..127), so `a` and `b` are the same cached object and `==` is true. 200 is outside the cache, so `c` and `d` are separate objects and `==` is false. Use `.equals()` for wrapper objects."
},
{
"q": "What does `5 / 2` evaluate to in Java, and how do you get `2.5`?",
"choices": [
"It's `2` (integer division discards the fraction); cast an operand to double, e.g. `5 / 2.0` or `(double) 5 / 2`",
"It's `2.5` already — Java promotes to double automatically",
"It's `3` — Java rounds to the nearest integer",
"It throws an ArithmeticException for non-divisible numbers"
],
"answer": 0,
"explain": "Dividing two ints gives an int, dropping the remainder, so `5 / 2` is `2`. Force floating-point division by making at least one operand a double before the division runs."
}
]
← Phase 8: Packages, Build & Tooling · Guide overview · Phase 10: Generics, Deep →
Check your understanding
1. You have two String objects built with `new String("hi")`. What do `a == b` and `a.equals(b)` return?
2. Why does `Integer a = 100, b = 100; a == b` print `true`, but `Integer c = 200, d = 200; c == d` print `false`?
3. What does `5 / 2` evaluate to in Java, and how do you get `2.5`?