Guide
Java fundamentals explained
A payment processor settles thousands of card transactions per minute. Each charge must be idempotent, auditable, and consistent across three data centers. The service that does this work is often written in Java: a statically typed, object-oriented language that compiles to portable bytecode and runs on the Java Virtual Machine (JVM). Java’s longevity is not nostalgia — the JVM delivers mature garbage collection, excellent JIT optimization, decades of battle-tested libraries, and a hiring pool that spans banking, Android, and cloud-native backends. Modern Java (17 LTS and 21 LTS) adds records, pattern matching, and virtual threads that make concurrent I/O far cheaper than the thread-per-request model of the 2000s. This guide covers the JVM and JDK, classes and interfaces, generics and the Collections Framework, exception handling, Streams and modern syntax, build tools, concurrency basics, a Harbor Payments ledger worked example, a language decision table, common pitfalls, and a production checklist — alongside our Go, Python, and TypeScript language guides.
What Java is: JVM, JDK, and bytecode
Java source (.java files) compiles to
bytecode (.class files) via javac.
The JVM executes bytecode on any supported OS — the original
“write once, run anywhere” pitch still holds for server-side code
shipped as JAR files or container images. The JDK (Java Development
Kit) bundles the compiler, runtime, standard library, and tools like
jlink for custom runtimes. The JRE (runtime only) is
largely subsumed by modular JDK images since Java 11.
Long-term support (LTS) releases matter in enterprise: Java 17 and Java 21 are the current adoption targets. Non-LTS releases (18–20, 22+) preview features that may land in the next LTS. Pin your production JDK version in CI, Docker base images, and deployment manifests — mixing 11 and 21 on the same cluster causes subtle bytecode and library incompatibilities.
Why the JVM still wins workloads
- JIT compilation — hot methods compile to native code after profiling; throughput rivals hand-tuned C++ on long-running services.
- Garbage collection — generational and low-latency collectors (G1, ZGC, Shenandoah) trade memory for developer velocity.
- Ecosystem depth — JDBC drivers, Kafka clients, gRPC, Spring, Hibernate, and observability agents are first-class.
- Polyglot JVM — Kotlin, Scala, and Clojure compile to the same bytecode and call Java libraries directly.
Classes, objects, and the type system
Everything in Java (except primitives) is an object referenced by
pointer-like handles. A class defines fields and methods;
new Order("ord_42", 1999) allocates on the heap and returns a reference.
Access modifiers (public, protected, package-private,
private) control visibility across packages.
public record PaymentIntent(String id, long amountCents, String currency) {
public PaymentIntent {
if (amountCents <= 0) throw new IllegalArgumentException("amount");
}
}
Records (Java 16+) are immutable data carriers with generated
constructors, accessors, equals, and hashCode — use
them for DTOs and domain events instead of verbose POJOs. Sealed classes
restrict which types may extend a hierarchy, pairing well with
pattern matching in switch (Java 21) for exhaustive
domain modeling.
Primitives vs wrappers
Primitives (int, long, double, boolean)
live on the stack or inline in objects; wrapper classes (Integer,
Long) box values for generics and nullable fields. Autoboxing hides the
conversion but adds allocation pressure in hot loops — prefer primitives in
performance-critical code and OptionalInt-style APIs where absence is
meaningful.
Inheritance, interfaces, and composition
Java supports single inheritance of classes (extends) and multiple
inheritance of type through interfaces (implements).
Favor composition over inheritance: inject dependencies through constructors
rather than deep class hierarchies that break when requirements shift.
public interface LedgerRepository {
void saveEntry(LedgerEntry entry);
List<LedgerEntry> findByAccount(String accountId);
}
public final class PostgresLedgerRepository implements LedgerRepository {
private final DataSource dataSource;
public PostgresLedgerRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
// ...
}
Abstract classes share code among related subclasses; interfaces define contracts. Default methods on interfaces (Java 8+) let you evolve APIs without breaking implementors. Keep interfaces small — the interface segregation principle applies to Java service boundaries as much as HTTP resources.
Generics and the Collections Framework
Generics add compile-time type parameters:
List<PaymentIntent> prevents adding a raw String at
compile time. Type erasure removes generic type information at runtime — you
cannot do new T() without reflection or factories. Wildcards
(? extends T, ? super T) express producer/consumer variance
when writing utility methods over collections.
The Collections Framework in java.util is the default
toolkit:
- List — ordered, indexed (
ArrayListfor random access,LinkedListrarely wins today). - Set — unique elements (
HashSet,LinkedHashSet,TreeSetfor sorted). - Map — key-value (
HashMap,ConcurrentHashMapfor thread-safe maps). - Queue / Deque — FIFO work queues (
ArrayDeque,LinkedBlockingQueue).
Prefer interfaces in field types (Map<String, Order>) and concrete
classes in constructors. Immutable collections via
List.of(...) and Map.copyOf(...) (Java 9+) reduce accidental
mutation when passing data across threads.
Exception handling: checked vs unchecked
Java distinguishes checked exceptions (must be declared or caught,
e.g. IOException) from unchecked
RuntimeException subclasses (e.g. IllegalArgumentException).
Checked exceptions force callers to handle failure modes but can clutter APIs with
throws clauses. Modern libraries and Spring often wrap checked exceptions in unchecked
ones; domain code should throw meaningful runtime exceptions with context rather than
swallowing errors.
try (Connection conn = dataSource.getConnection()) {
// JDBC auto-closes via try-with-resources (Java 7+)
insertEntry(conn, entry);
} catch (SQLException ex) {
throw new LedgerPersistenceException("save failed: " + entry.id(), ex);
}
Try-with-resources closes AutoCloseable objects reliably
— always use it for JDBC connections, streams, and HTTP clients. Never catch
Exception broadly without rethrowing or logging; empty catch blocks hide
production incidents.
Streams, lambdas, and modern Java syntax
Java 8 introduced lambda expressions and the Stream API for functional-style collection processing:
long totalCents = payments.stream()
.filter(p -> p.currency().equals("USD"))
.mapToLong(PaymentIntent::amountCents)
.sum();
Streams can be sequential or parallel; parallel streams help CPU-bound transforms on
large in-memory datasets but rarely beat a database GROUP BY for aggregation.
Use Optional<T> for return types that may be absent — never
pass Optional as a method parameter (overloads or nullable annotations are clearer).
Text blocks (Java 15+) simplify multiline SQL and JSON templates.
var (local variable type inference, Java 10+) reduces boilerplate when
the right-hand type is obvious: var entries = repository.findByAccount(id);
Build tools: Maven and Gradle
Most Java projects use Maven (pom.xml) or
Gradle (build.gradle.kts). Both resolve dependencies from
Maven Central, compile sources, run tests, and package JARs. Maven favors convention
over configuration; Gradle offers a programmable DSL and faster incremental builds in
large monorepos.
- Coordinates —
groupId:artifactId:versionpin library versions; use a BOM (Bill of Materials) for Spring ecosystems. - Multi-module — split API, domain, and infrastructure modules so business logic does not depend on web frameworks.
- Reproducible builds — commit lockfiles or use Gradle dependency locking; scan with OWASP Dependency-Check or Snyk in CI.
Package fat JARs with spring-boot-maven-plugin or Gradle’s
bootJar task for executable microservices; use jlink for
trimmed CLI tools where image size matters.
Concurrency and virtual threads
Traditional Java servers allocated one platform thread per request — simple but memory-heavy at tens of thousands of concurrent connections. Virtual threads (Java 21, Project Loom) are lightweight threads scheduled on a small pool of carrier threads; blocking I/O no longer wastes an OS thread.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> processWebhook(payload));
}
Use java.util.concurrent utilities (ExecutorService,
CompletableFuture, ConcurrentHashMap) for structured
concurrency. Avoid manual thread creation in request handlers. For CPU-bound work,
size thread pools to available cores; for I/O-bound work with virtual threads, focus
on backpressure and downstream timeouts instead of thread count.
Synchronization primitives (synchronized, ReentrantLock,
volatile) still matter for shared mutable state — immutability and
message-passing (queues) reduce lock contention in payment and ledger systems.
Worked example: Harbor Payments ledger service
Harbor Payments needs a double-entry ledger: every card capture writes a debit and credit pair, idempotent on webhook replay, queryable by account for support dashboards.
- Domain model — immutable
record LedgerEntry(UUID id, String debitAccount, String creditAccount, long amountCents, Instant postedAt)and aLedgerServicethat validates balanced entries before persistence. - Repository —
LedgerRepositoryinterface with a JDBC implementation using PostgreSQL and anINSERT ... ON CONFLICT DO NOTHINGidempotency key on webhook event ID. - HTTP layer — Spring Boot or plain
com.sun.net.httpserverfor a POST/webhooks/captureendpoint; validate HMAC signature before parsing JSON body. - Transactions — wrap debit/credit inserts in a single JDBC transaction; rollback on any constraint violation.
- Observability — structured JSON logs with correlation IDs; Micrometer metrics for entries/sec and duplicate webhook rate.
- Deploy — fat JAR in a
Docker image on
Eclipse Temurin 21; health check on
/actuator/health; horizontal scale behind a load balancer with sticky sessions disabled (stateless handlers).
Result: a service that survives webhook retries without duplicate postings, exposes clear seams for testing (mock repository in unit tests, Testcontainers Postgres in integration tests), and matches the reliability expectations of card networks.
Language decision table
| Need | Prefer Java | Consider instead |
|---|---|---|
| Enterprise microservices with Spring ecosystem | Yes — mature tooling and hiring pool | Kotlin on JVM for less boilerplate |
| Android mobile apps | Yes (with Kotlin as modern default) | Kotlin-first for new projects |
| Small static binary CLI | No — JVM startup and image size | Go or Rust |
| Data science / ML notebooks | No | Python |
| High-throughput HTTP with minimal deps | Maybe with virtual threads | Go for simpler deploy artifacts |
| Browser / full-stack TypeScript | No | Node.js / TypeScript |
| Hard real-time or embedded | No | C, C++, Rust |
| Long-running batch on JVM (Spark, Kafka streams) | Yes — Scala/Java native fit | Python for small ETL scripts |
Common pitfalls
- NullPointerException by default — validate inputs early; use records and Optional returns; enable static analysis (NullAway, Checker Framework).
- Mutating shared collections — defensive copies on getters; prefer immutable collections for cross-thread handoff.
- Raw types —
List list = new ArrayList()loses type safety; always parameterize generics. - Parallel stream misuse — parallelizing small lists or I/O-bound work adds overhead without benefit.
- Logging PII or card data — mask account numbers; never log full PAN or CVV in payment services.
- Default timezone assumptions — store
Instantin UTC; convert at display with explicitZoneId. - Classpath dependency conflicts — duplicate SLF4J bindings or conflicting Guava versions cause subtle runtime failures; use dependency convergence in Maven Enforcer.
- Ignoring GC and heap tuning — monitor pause times; choose G1 or ZGC for latency-sensitive services.
Production checklist
- Pin LTS JDK (17 or 21) in CI, Docker, and production; run
jlinkor distroless Temurin images. - Multi-module layout separating domain logic from framework adapters.
- Unit tests (JUnit 5) plus integration tests with Testcontainers for Postgres/Kafka.
- Structured logging (Logback JSON encoder); correlation IDs propagated from HTTP headers.
- Metrics via Micrometer/Prometheus; traces via OpenTelemetry agent.
- Dependency scanning in CI; renovate or Dependabot for security patches.
- Graceful shutdown hooks flushing buffers and draining executor queues.
- Connection pools (HikariCP) sized to database limits; statement timeouts on JDBC.
- Secrets from environment or vault — never hard-coded in
application.propertiescommitted to git. - Load test with realistic webhook replay and idempotency scenarios before peak traffic.
Key takeaways
- Java compiles to portable JVM bytecode with a deep enterprise ecosystem and modern language features through Java 21.
- Use records, interfaces, and immutability to keep domain models clear; prefer composition over deep inheritance.
- The Collections Framework and Streams handle most in-memory data transforms; push heavy aggregation to SQL.
- Virtual threads make blocking I/O cheap again — ideal for webhook and REST services without reactive complexity.
- Choose Java when JVM maturity, hiring, and library breadth outweigh the deploy footprint of lighter runtimes.
Related reading
- Go (Golang) fundamentals explained — static binaries and goroutines for cloud-native services
- PostgreSQL fundamentals explained — JDBC-backed persistence for Java ledger and order services
- gRPC and Protocol Buffers explained — typed service contracts from Java and other languages
- Docker fundamentals explained — container images for JVM microservices