Skip to content

Latest commit

 

History

History
266 lines (183 loc) · 12.8 KB

File metadata and controls

266 lines (183 loc) · 12.8 KB

Claude's Guide to Java for Humans

Java fails humans in 3 places before anything else: treating checked exceptions as bureaucratic overhead to suppress, abusing inheritance where composition belongs, and writing verbose boilerplate so reflexively that simpler solutions become invisible. Java has improved dramatically since Java 8 — most bad Java being written today is written by people who stopped learning in 2012.


Checked Exceptions Are a Contract, Not a Nuisance

Java's checked exceptions are the most complained-about feature and one of the most valuable when understood correctly. A checked exception is a compiler-enforced statement: this operation can fail in a way the caller should explicitly handle.

The 3 wrong responses to a checked exception:

// Wrong 1 — swallow it silently
try {
    riskyOperation();
} catch (IOException e) {
    // nothing
}

// Wrong 2 — log and pretend it didn't happen
try {
    riskyOperation();
} catch (IOException e) {
    e.printStackTrace();
}

// Wrong 3 — wrap in RuntimeException to escape the contract
try {
    riskyOperation();
} catch (IOException e) {
    throw new RuntimeException(e);
}

Wrong 3 is sometimes correct — at a boundary where checked exceptions genuinely cannot propagate, like inside a lambda. As a habit it is a design failure. You are discarding the compiler's attempt to make failure handling explicit.

The correct response is to handle it meaningfully, propagate it with throws, or wrap it in a domain-specific exception that carries context:

try {
    riskyOperation();
} catch (IOException e) {
    throw new ConfigLoadException("Failed to load config from " + path, e);
}

Always chain the original exception as the cause. Stack traces without cause chains are archaeology without a map.


Inheritance Is Not a Code Sharing Tool

Java's single-inheritance model attracts misuse because it appears to solve code reuse. It doesn't — it creates brittle hierarchies where changes to a superclass break subclasses in ways the compiler cannot catch.

Inheritance is correct for 1 purpose: a true is-a relationship where the subtype genuinely extends and specializes the supertype's behavior, and where Liskov Substitution holds — a subtype must be usable everywhere its supertype is expected without surprising the caller.

Everything else is composition's job. If you are inheriting to reuse 2 methods from a base class, you want composition:

// Wrong — inheritance for code reuse
class EmailNotifier extends MessageFormatter {
    void notify(String msg) {
        send(format(msg)); // just wants format()
    }
}

// Right — composition
class EmailNotifier {
    private final MessageFormatter formatter;
    
    void notify(String msg) {
        send(formatter.format(msg));
    }
}

The practical test: if you find yourself overriding a method just to disable or no-op it, the hierarchy is wrong. Real is-a relationships don't produce subclasses that need to undo superclass behavior.

Prefer interfaces over abstract classes for defining contracts. Use abstract classes only when shared implementation state is genuinely required across all subtypes.


Modern Java You Should Be Using

Java 8 was released in 2014. If your Java looks like it was written before 2014, it is carrying 10 years of unnecessary weight.

Records for data carriers. Stop writing POJOs with private fields, getters, equals(), hashCode(), and toString() by hand. Records do all of it in 1 line:

// Wrong — 40 lines of boilerplate
public class Point {
    private final int x;
    private final int y;
    // getters, equals, hashCode, toString...
}

// Right
public record Point(int x, int y) {}

Sealed classes for controlled hierarchies. When you have a fixed set of subtypes — a Result that is either Success or Failure, a Shape that is Circle, Rectangle, or Triangle — sealed classes make the set explicit and enable exhaustive pattern matching:

sealed interface Shape permits Circle, Rectangle, Triangle {}

Pattern matching in switch. Java 21+ switch expressions with pattern matching eliminate entire categories of casting and branching boilerplate:

double area = switch (shape) {
    case Circle c -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> r.width() * r.height();
    case Triangle t -> 0.5 * t.base() * t.height();
};

The compiler enforces exhaustiveness. Add a new sealed subtype and every switch that doesn't handle it becomes a compile error. This is the correct behavior.

Text blocks for multiline strings. String concatenation for SQL, JSON, or HTML is over:

String query = """
    SELECT u.id, u.name
    FROM users u
    WHERE u.active = true
    ORDER BY u.name
    """;

var for local type inference. When the type is obvious from the right-hand side, var reduces noise without reducing clarity:

var users = new ArrayList<User>(); // obvious
var result = service.findActiveUsers(); // use explicit type — not obvious

Nulls

Java's null is a 50-year-old mistake that cannot be removed from the language. The correct response is to contain it aggressively.

Use Optional<T> for return types where absence is a legitimate outcome. Never use Optional as a field type or parameter type — it was designed for return values only:

// Right — signals to caller that absence is possible
Optional<User> findById(long id);

// Wrong — use as field
class UserService {
    private Optional<Cache> cache; // no
}

Never return null from a method that returns a collection. Return an empty collection. Callers should never need to null-check before iterating.

Annotate with @NonNull and @Nullable (from any major annotation library — they're all equivalent for tooling purposes) at API boundaries. IDE nullability analysis catches null dereferences statically. Use it.

Fail fast on null inputs to public methods:

public UserService(UserRepository repo) {
    this.repo = Objects.requireNonNull(repo, "repo must not be null");
}

Objects.requireNonNull at construction time means null-related failures surface at the point of misuse, not 3 call frames later when something actually tries to use the null.


Collections and Streams

Choose the right collection type. Most humans reach for ArrayList and HashMap by reflex. The right type depends on access patterns:

  • Frequent random access → ArrayList
  • Frequent insertion/removal at arbitrary positions → LinkedList
  • Sorted order required → TreeMap / TreeSet
  • Insertion-order iteration → LinkedHashMap
  • Frequency counting → HashMap<K, Integer> or a Multiset from Guava

Immutable collections by default. List.of(), Set.of(), Map.of() produce immutable collections in O(1). Use them for fixed data. Immutability eliminates an entire class of bugs where a collection is modified by code that shouldn't be touching it.

Streams for transformation pipelines. Streams are not a replacement for all loops — they are the correct tool for transformation, filtering, and aggregation over collections:

List<String> activeNames = users.stream()
    .filter(User::isActive)
    .map(User::getName)
    .sorted()
    .toList(); // Java 16+

3 stream pitfalls humans walk into:

Reusing a stream. Streams are single-use. Consuming a stream and then trying to use it again throws IllegalStateException. If you need to iterate data twice, collect to a list first.

Side effects in stream operations. map, filter, and flatMap must be side-effect-free. Mutating external state inside a stream operation is undefined behavior under parallel execution and bad design under sequential execution.

Parallel streams as a default optimization. parallelStream() is not free — it incurs thread coordination overhead. It outperforms sequential streams only for CPU-bound operations on large datasets. Measure before reaching for it.


Concurrency

Java concurrency has 3 layers: raw threads with synchronized, the java.util.concurrent utilities, and virtual threads from Java 21. Know which layer you are in.

Never use raw synchronized where a higher-level abstraction exists. ReentrantLock, CountDownLatch, Semaphore, ConcurrentHashMap, BlockingQueue — the java.util.concurrent package contains correct, well-tested implementations of patterns that humans routinely get wrong when implemented from scratch with raw locks.

volatile is not a replacement for synchronization. volatile guarantees visibility — a write to a volatile field is visible to all threads that subsequently read it. It does not guarantee atomicity for compound operations. count++ on a volatile field is still a data race:

// Wrong — read-modify-write is not atomic even on volatile
private volatile int count = 0;
count++; // race condition

// Right
private final AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();

Virtual threads in Java 21+ change the concurrency model. Virtual threads are cheap enough to create 1 per task — hundreds of thousands if needed. The thread-per-request model, long impractical due to OS thread overhead, is now correct for most IO-bound server applications. If you are on Java 21+, evaluate whether your thread pool complexity can be replaced with virtual threads before adding more concurrency infrastructure.

Immutability is the best concurrency strategy. An immutable object shared across threads requires no synchronization. Design data to be immutable where possible and move synchronization complexity to the edges where mutation genuinely must occur.


The Java Pitfalls Most Guides Skip

equals() and hashCode() must be implemented together or not at all. If you override equals(), you must override hashCode() consistently — objects that are equal must have the same hash code. Violating this contract breaks HashMap, HashSet, and anything that uses hashing. If you use records, this is handled correctly for free.

String concatenation in loops is O(n²). The + operator on strings creates a new object each time. In a loop this is quadratic. Use StringBuilder or String.join() for anything beyond trivial cases. The compiler optimizes single-expression concatenations, not loops.

interface default methods are not free inheritance. Default methods in interfaces allow adding methods to interfaces without breaking implementors. They are not an invitation to put substantial logic in interfaces. Interfaces should define contracts. Default methods should handle only trivial convenience cases.

Static initializers fail silently in ways that are hard to debug. Static initialization runs once at class load time. Exceptions thrown in static initializers produce ExceptionInInitializerError followed by NoClassDefFoundError on any subsequent use — not the original exception. Keep static initializers trivial.

Optional.get() without isPresent() is just a more verbose null dereference. The entire value of Optional is forcing the caller to handle absence. Calling get() without checking defeats that entirely. Use orElse(), orElseGet(), orElseThrow(), or ifPresent() instead:

// Wrong — defeats the purpose
String name = findUser(id).get();

// Right
String name = findUser(id).orElseThrow(() ->
    new UserNotFoundException(id));

Resource leaks are Java's most common production bug. Anything implementing AutoCloseable — database connections, streams, HTTP connections, prepared statements — must be in a try-with-resources block. Connection pool exhaustion in production almost always traces back to a code path where something closeable was not closed on an exception path.

// Right — closed on all paths including exceptions
try (var conn = dataSource.getConnection();
     var stmt = conn.prepareStatement(sql)) {
    // use stmt
}

What Good Java Actually Looks Like

Checked exceptions handled meaningfully with cause chains preserved. Composition over inheritance everywhere except genuine is-a relationships. Records for data, sealed classes for fixed hierarchies, pattern matching for exhaustive dispatch. Nulls contained at boundaries with Optional and requireNonNull. Streams for transformation pipelines, not for everything. Concurrency built on java.util.concurrent abstractions, not raw locks. Try-with-resources on every AutoCloseable.

Java is a verbose language that has gotten significantly less verbose since Java 16. The humans writing the best Java today are the ones who updated their instincts when the language updated. The ones writing the worst Java are the ones still solving 2024 problems with 2010 idioms — not because the old ways are wrong, but because the new ways eliminate entire categories of bugs by default.

The language improved. Update your defaults.