A finance team finds one-cent differences between invoices and the ledger, a few hundred a month, always on VAT. The cause is a pricing method written years ago with double, following advice that Java guides of the time repeated: choose float or double depending on how much precision you need. Most of the Java best practices worth following in 2026 can be checked by the compiler, a static analyser or the build, so a rule like "money is never a double" does not depend on someone remembering it in code review. Each practice below comes with Java 25 code and the tool that keeps it in place.

Java best practices at a glance, and what enforces each one

The practices fall into eight themes. The footer of each tile names the check that keeps the theme in place on every build or test run, which is what lets a rule hold when the team changes, a deadline is close or an AI assistant wrote the pull request.

Java best practices for 2026 in eight themes, with the check that keeps each theme in place. Code: Immutable records; Sealed types and pattern matching; Small, final, private APIs. Checked by: javac: exhaustive switch. Nulls: JSpecify null markers; Optional for return values only; requireNonNull at boundaries. Checked by: NullAway on Error Prone. Data: BigDecimal for money; java.time with an injected Clock; Streams where they read better. Checked by: Tests, Sonar rules, review. Errors: Never swallow, keep the cause; try-with-resources; Restore the interrupt flag. Checked by: SpotBugs, Sonar. Concurrency: Virtual threads for blocking I/O; Semaphores as explicit limits; Scoped values, not ThreadLocal; Preview APIs kept out of production. Checked by: Load tests, JFR. Dependencies: Java 25 LTS; BOMs, one version per library; OpenRewrite for upgrades; Scan in CI, keep an SBOM. Checked by: Maven Enforcer, CI scan. Tests: JUnit 6 on business flows; Testcontainers, real database; Static analysis with a baseline. Checked by: CI pipeline. Team: Domain names; Comments say why; Conventional Commits; AI code reviewed like any PR. Checked by: Pull request review.JAVA BEST PRACTICES BY THEME, AND THE CHECK THAT KEEPS EACH IN PLACECodeImmutable recordsSealed types andpattern matchingSmall, final, privateAPIsChecked byjavac: exhaustiveswitchNullsJSpecify nullmarkersOptional for returnvalues onlyrequireNonNull atboundariesChecked byNullAwayon Error ProneDataBigDecimal formoneyjava.time with aninjected ClockStreams wherethey read betterChecked byTests, Sonarrules, reviewErrorsNever swallow,keep the causetry-with-resourcesRestore theinterrupt flagChecked bySpotBugs, SonarConcurrencyVirtual threads forblocking I/OSemaphores asexplicit limitsScoped values, notThreadLocalPreview APIs keptout of productionChecked byLoad tests, JFRDependenciesJava 25 LTSBOMs, one versionper libraryOpenRewrite forupgradesScan in CI, keep anSBOMChecked byMaven Enforcer,CI scanTestsJUnit 6 onbusiness flowsTestcontainers,real databaseStatic analysis witha baselineChecked byCI pipelineTeamDomain namesComments saywhyConventionalCommitsAI code reviewedlike any PRChecked byPull request review
Java best practices for 2026 by theme, with the compiler check, analyser or pipeline stage that keeps each group in place.

Model data with records, sealed types and pattern matching

Most bugs in business code come from objects in states nobody planned for: an order with no lines, a payment that is both settled and refunded. Modern Java lets you describe the valid states in types, so the compiler rejects the rest.

Records and immutability instead of setters

Encapsulation used to be taught as private fields with a getter and a setter for each. Setters keep the field private but let any caller put the object into any state at any time, so validation gets repeated wherever the object changes. For data carriers, Java 16 and later offer records: final fields, a canonical constructor, accessors, equals, hashCode and toString, and nothing that mutates.

Validation goes in the compact constructor, so an invalid instance cannot exist:

public record Employee(String name, LocalDate birthDate) {

    public Employee {
        Objects.requireNonNull(birthDate, "birthDate");
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Employee name must not be blank");
        }
    }

    public int ageOn(LocalDate date) {
        return Period.between(birthDate, date).getYears();
    }
}

The record stores a birth date rather than an age, which would go stale the day after it was set. A change produces a new instance. A record that holds a collection should copy it in the constructor with List.copyOf, which also rejects null elements. Immutable values can be shared between threads without locks, which counts for more now that a service may run thousands of virtual threads.

The rest of Joshua Bloch's advice in Effective Java still applies: make each class and member as inaccessible as it can be. A class nobody needs to extend is final, and a helper that one package uses stays package-private.

Sealed interfaces and exhaustive switches for domain states

A payment can be approved, declined or waiting for review, and each state carries different data. The old model was a class with a status enum and a handful of nullable fields, each meaningful for one status only. A sealed interface with one record per state models it directly, and the compiler checks a switch over it for exhaustiveness (sealed classes in JEP 409, final in Java 17; pattern matching for switch in JEP 441 and record patterns in JEP 440, both final in Java 21):

public sealed interface PaymentResult permits Approved, Declined, PendingReview {}

record Approved(String authorizationCode) implements PaymentResult {}
record Declined(String reason, boolean retryable) implements PaymentResult {}
record PendingReview(Duration expectedWait) implements PaymentResult {}

static String describe(PaymentResult result) {
    return switch (result) {
        case Approved(var code) -> "Approved, authorization " + code;
        case Declined(var reason, var retryable) when retryable -> "Declined, retry later: " + reason;
        case Declined(var reason, _) -> "Declined: " + reason;
        case PendingReview(var wait) -> "In review, about " + wait.toMinutes() + " minutes";
    };
}

There is no default branch, on purpose. When someone adds a Refunded state, every switch that does not handle it stops compiling, instead of falling into a default written before the state existed. The _ pattern, final since Java 22 (JEP 456), skips a component the branch does not use.

Null safety in Java: JSpecify annotations, NullAway and Optional

Java's type system still cannot say whether a reference may be null. The old advice was to check for null before every use and throw an exception by hand, which spreads the same check over dozens of places and still misses the one that fails in production. The 2026 practice is to declare nullness once and let a checker enforce it.

Declaring nullness with JSpecify

JSpecify is a common set of nullness annotations developed by consensus of the main stakeholders in Java static analysis; its 1.0.0 release is the first tool-neutral artifact for them. @NullMarked on a package makes every type usage in it non-null by default, and @Nullable marks the exceptions:

// package-info.java
@NullMarked
package com.example.billing;

import org.jspecify.annotations.NullMarked;
public final class Invoice {

    private final InvoiceNumber number;
    private final @Nullable String purchaseOrder;   // not every customer sends one

    public Invoice(InvoiceNumber number, @Nullable String purchaseOrder) {
        this.number = number;
        this.purchaseOrder = purchaseOrder;
    }

    public InvoiceNumber number() {
        return number;
    }

    public Optional<String> purchaseOrder() {
        return Optional.ofNullable(purchaseOrder);
    }
}

Spring Framework 7, the base of Spring Boot 4, annotates its own APIs with JSpecify and names NullAway as a checker for them, so in a Spring application the checker also knows which framework methods can return null. The Spring Boot 4 migration guide covers the rest of that upgrade.

NullAway, a plugin for Google's Error Prone compiler, enforces the annotations on every build. Its authors measure the build-time overhead as usually under 10%. Configure it with OnlyNullMarked so that it checks only the packages you have marked, and on an existing codebase mark packages one at a time, starting with the code that changes most.

Optional belongs in return types

Optional was added for one job: a return value that may be absent, where returning null would invite a NullPointerException at the call site. It belongs in return types and nowhere else. It is a poor fit for fields (it is not serializable and adds an object per field), for parameters (callers end up passing Optional.empty(), or null) and for collections, where an empty list already means nothing was found. The Invoice class above keeps a nullable field and exposes an Optional, which is the pattern:

Customer customer = customers.findByEmail(email)
        .orElseThrow(() -> new CustomerNotFoundException(email));

String reference = invoice.purchaseOrder().orElse("none");

At system boundaries, where data arrives from outside and annotations cannot help, check once with Objects.requireNonNull(value, "name") so the message names the parameter. Since JDK 15 the JVM's helpful NullPointerException messages (JEP 358) are on by default and name the expression that was null, so a hand-written throw new NullPointerException(...) after an if adds nothing.

Exceptions and resources done right

Error handling is where a small shortcut turns into a production incident months later. Two rules cover most of it: never lose a failure, and never leak a resource.

Never swallow an exception, and keep the cause

An empty catch block turns a failure into wrong data. Take a method that sums a list of numeric strings and ignores what it cannot parse: given "123" and "1abc", it returns 123, and nobody learns that half the input was rejected. Translate the exception into one that says what failed, and pass the original as the cause:

static int sum(List<String> values) {
    int sum = 0;
    for (String value : values) {
        try {
            sum += Integer.parseInt(value);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("Not an integer: '" + value + "'", e);
        }
    }
    return sum;
}
  • Catch the narrowest type you can handle. catch (Exception e) also catches the bugs you wanted to see.
  • Log or rethrow, not both. Logging at every layer an exception passes through prints the same stack trace several times and makes incidents harder to read.
  • Use unchecked exceptions for programming errors and invalid input. For outcomes the caller is expected to handle, such as a declined payment, a sealed result type like PaymentResult is often clearer than an exception.
  • When you catch InterruptedException, restore the flag before you rethrow or return, or the code above never learns the thread was asked to stop:
try {
    return quotes.take();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new IllegalStateException("Interrupted while waiting for a quote", e);
}

SpotBugs and Sonar both report exceptions that are caught and ignored, so this rule can fail a build rather than wait for a reviewer.

try-with-resources for anything that must be closed

Connections, statements, result sets, file streams and executors hold something outside the heap. Closing them in a finally block by hand is verbose, and an exception thrown by close() can replace the one that caused the failure. try-with-resources closes in reverse order and attaches any exception from close() to the original as a suppressed exception:

record OverdueInvoice(String number, LocalDate dueDate) {}

List<OverdueInvoice> findOverdue(LocalDate today) throws SQLException {
    var sql = "SELECT number, due_date FROM invoice WHERE due_date < ?";
    try (var connection = dataSource.getConnection();
         var statement = connection.prepareStatement(sql)) {
        statement.setObject(1, today);
        try (var rows = statement.executeQuery()) {
            var result = new ArrayList<OverdueInvoice>();
            while (rows.next()) {
                result.add(new OverdueInvoice(rows.getString("number"),
                        rows.getObject("due_date", LocalDate.class)));
            }
            return result;
        }
    }
}

Since Java 19, ExecutorService is AutoCloseable too: close() waits for submitted tasks to finish, which the virtual thread example further down relies on.

Memory leaks are references you keep

The garbage collector frees whatever nothing references, so a Java memory leak is a reference held by mistake: a static map used as a cache with no size limit, listeners registered and never removed, a ThreadLocal left on a pooled thread. Use a bounded cache such as Caffeine instead of a static map. When heap use keeps growing, take a heap dump and open it in Eclipse Memory Analyzer, or record allocations with JDK Flight Recorder; our guide to Java profilers compares the tools, and common Java performance problems covers the other usual suspects.

Choosing the right types for money, time and collections

BigDecimal for money, never float or double

float and double are binary floating point. They cannot represent most decimal fractions exactly, so arithmetic on prices drifts, one rounding at a time. The old advice to choose between them by precision, and to prefer float to save memory, is wrong for money on both counts. Use BigDecimal, created from a string, with the rounding mode stated:

double total = 0.1 + 0.2;                       // 0.30000000000000004

BigDecimal net = new BigDecimal("19.99");
BigDecimal vat = net.multiply(new BigDecimal("0.23"))
        .setScale(2, RoundingMode.HALF_EVEN);   // 4.60
BigDecimal gross = net.add(vat);                // 24.59

new BigDecimal(0.1);                            // 0.1000000000000000055511151231257827...
new BigDecimal("2.0").equals(new BigDecimal("2.00"));      // false: the scale differs
new BigDecimal("2.0").compareTo(new BigDecimal("2.00"));   // 0: the same amount

The last three lines are the traps that still catch experienced developers: a BigDecimal built from a double carries the binary error with it, and equals compares scale as well as value. Which rounding mode is right, and whether to round per line or per invoice, is a business and tax rule; write it down next to the code.

A small value type keeps the amount and the currency together and refuses to add euros to pounds:

public record Money(BigDecimal amount, Currency currency) {

    public Money {
        // Rejects 19.999 EUR instead of rounding it silently
        amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.UNNECESSARY);
    }

    public Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("Cannot add " + other.currency + " to " + currency);
        }
        return new Money(amount.add(other.amount), currency);
    }
}

For quantities that are not money, double is the default. float holds about seven significant decimal digits, and the memory it saves matters only in large primitive arrays, such as embeddings or image data, once a profiler shows that it does.

java.time instead of Date and Calendar

java.util.Date and Calendar are mutable, and SimpleDateFormat is not thread-safe. Use the java.time types for what they mean: Instant for a moment on the timeline (timestamps, events, logs), LocalDate for a calendar date such as a due date, ZonedDateTime when the user's time zone changes the answer, and Duration or Period for amounts of time. Store instants in UTC and convert at the edges.

Business logic should not call LocalDate.now() directly. Inject a clock, and tests can pin the date that a month-end or leap-year rule depends on:

public final class InvoiceTerms {

    private final Clock clock;

    public InvoiceTerms(Clock clock) {
        this.clock = clock;
    }

    public LocalDate dueDate() {
        return LocalDate.now(clock).plusDays(30);
    }
}

// In production: new InvoiceTerms(Clock.systemUTC())
// In a test:     new InvoiceTerms(Clock.fixed(Instant.parse("2026-01-31T10:00:00Z"), ZoneOffset.UTC))

Streams where they read better than loops

A stream earns its place when the code is a pipeline: filter, sort, map, collect. Stream.toList(), added in Java 16, returns an unmodifiable list, which fits the immutability habit above:

List<String> overdueNumbers = invoices.stream()
        .filter(invoice -> invoice.dueDate().isBefore(today))
        .sorted(Comparator.comparing(Invoice::dueDate))
        .map(Invoice::number)
        .toList();

A loop reads better when the body has early exits, checked exceptions or more than one output, and a stream that updates a variable outside itself is a loop in disguise. Two newer additions remove common workarounds: sequenced collections in Java 21 (JEP 431) add getFirst(), getLast() and reversed() to lists and ordered sets, and stream gatherers, final in JDK 24 (JEP 485), cover custom intermediate steps such as sliding windows.

Concurrency in Java 25: virtual threads, limits and structured concurrency

Virtual threads have been final since Java 21 (JEP 444). JDK 24 removed the main cause of pinning, blocking inside synchronized (JEP 491), which makes Java 25 the first LTS release where they work without that caveat. For I/O-bound work the practice is one virtual thread per task:

List<Quote> fetchQuotes(List<Vendor> vendors, QuoteRequest request)
        throws InterruptedException, ExecutionException {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<Quote>> futures = vendors.stream()
                .map(vendor -> executor.submit(() -> vendor.quote(request)))
                .toList();
        var quotes = new ArrayList<Quote>();
        for (var future : futures) {
            quotes.add(future.get());
        }
        return quotes;
    }
}

Do not pool virtual threads: they are cheap to create, and a pool brings back the limit they remove. That limit often did a second job, though. A pool of 50 threads was also a cap of 50 concurrent calls to the database or a partner API, and with virtual threads the cap has to be explicit, usually a Semaphore sized to what the downstream system can take. Request context such as a trace ID belongs in a scoped value, final in Java 25 (JEP 506), rather than a ThreadLocal. In Spring Boot, spring.threads.virtual.enabled=true moves the web server and the task executors onto virtual threads.

Structured concurrency, which treats a group of subtasks as one unit that succeeds, fails or is cancelled together, is still a preview API: fifth preview in Java 25 (JEP 505), sixth in JDK 26 (JEP 525) and seventh in JDK 27, released in September 2026 (JEP 533). It needs --enable-preview to compile and run, so keep it out of code you cannot change when the API is finalized. Our guide to Java virtual threads covers the patterns, the pinning that remains and the connection-pool bottleneck that tends to follow a migration.

Dependency hygiene: supported Java, BOMs, upgrades and scanning

Most of the code in a Java application is someone else's: the JDK, the framework and hundreds of libraries. Keeping them current and known is a practice in its own right, and it gets harder the longer it waits.

Staying on a supported LTS release

Java 25, released in September 2025, is the current long-term support release. JDK 26 (March 2026) and JDK 27 (September 2026) are feature releases with six months of updates each: good for trying what is coming, wrong for production systems that cannot upgrade twice a year. Oracle JDK 21 updates moved to a licence that is paid for production use in October 2026, and every LTS release in use today loses Oracle's extended support between 2029 and 2033. The dates, and what to do about them, are in our Java end of life calendar.

One version of each library, managed with BOMs

Most dependency trouble comes from transitive versions nobody chose. A bill of materials (BOM) fixes one version for a family of libraries, so the individual dependencies declare no version at all. Spring Boot's dependency management does this for everything it supports; for the rest, import the project's own BOM:

<properties>
    <junit.version>6.1.3</junit.version>
    <testcontainers.version>2.0.5</testcontainers.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.junit</groupId>
            <artifactId>junit-bom</artifactId>
            <version>${junit.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>${testcontainers.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

In Gradle the same import is implementation(platform(...)). Add the Maven Enforcer dependencyConvergence rule, or failOnVersionConflict() in Gradle's resolution strategy, so that a second version of a library fails the build instead of being picked silently. mvn dependency:tree or ./gradlew dependencies shows where each version comes from.

The old argument for using fewer libraries was memory. The real cost is maintenance: every dependency is code you have to keep patched, and an abandoned one can block a framework upgrade. When we moved a London wine merchant's store and back office from Java 17 to Java 25, several components had no version for Spring 6 and had to be replaced before the upgrade could proceed. The dependency scan before the work found 12 critical and 35 high-severity advisories; after it, there were none.

Automating upgrades with OpenRewrite

Mechanical changes across a codebase, such as moving javax imports to jakarta, replacing deprecated APIs or raising the Java version in every build file, are better done by a refactoring tool than by hand. OpenRewrite expresses them as recipes. Its UpgradeToJava25 recipe includes the move to Java 21 and the Java 25 changes, among them removing calls to the Security Manager, which is permanently disabled since JDK 24 (JEP 486). Check the licence before adding it to a commercial build: the recipe page lists the Moderne Source Available License, with precompiled artifacts for Moderne customers. On the wine merchant project, automated refactoring moved more than 800 files to the new namespaces, and every change was reviewed.

Scanning dependencies in CI

Scan the dependency tree on every build and fail on new critical findings; OSV-Scanner, OWASP Dependency-Check and GitHub Dependabot are free places to start. Generate a software bill of materials (CycloneDX has Maven and Gradle plugins), so that the question "do we ship this library?" takes minutes to answer when the next critical CVE is announced. Our guide to writing secure Java code covers the rest of the security checklist.

Testing and static analysis on every build

The practices above only hold if something checks them on every change. Tests check behaviour, static analysis checks the code itself, and both belong in the build rather than in a reviewer's memory.

JUnit 6 tests around what the business runs on

JUnit 6 is the current generation of the framework and requires Java 17. The habits count for more than the version: test behaviour through the public API, name each test after the rule it checks, and spend the effort where a failure costs money. A coverage percentage is a fair signal of untested code and a poor target. A suite at 60% that covers billing, pricing and the order flow protects more than one at 90% that tests getters.

Before a refactor or an upgrade, write the tests first. On the wine merchant upgrade, regression and browser tests added before any framework moved caught two breaks before release: one would have stopped all PDF generation, the other spreadsheet imports. Our overview of the types of software testing covers the layers above unit tests.

Testcontainers instead of in-memory databases

An in-memory database such as H2 accepts some SQL that PostgreSQL rejects and rejects some that it accepts, so integration tests against it prove little about production. Testcontainers starts the real database in Docker for the test run (version 2.0.5 at the time of writing):

@Testcontainers
class InvoiceRepositoryTest {

    @Container
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");

    InvoiceRepository repository;

    @BeforeEach
    void setUp() {
        // Flyway migrations run against the container, as they do in production
        repository = InvoiceRepository.connect(postgres.getJdbcUrl(),
                postgres.getUsername(), postgres.getPassword());
    }

    @Test
    void findsOnlyInvoicesPastTheirDueDate() throws SQLException {
        repository.save(new OverdueInvoice("INV-1", LocalDate.of(2026, 9, 1)));
        repository.save(new OverdueInvoice("INV-2", LocalDate.of(2026, 11, 1)));

        var overdue = repository.findOverdue(LocalDate.of(2026, 10, 5));

        assertEquals(List.of("INV-1"), overdue.stream().map(OverdueInvoice::number).toList());
    }
}

In a Spring Boot test, @ServiceConnection on the container field wires it into the application context, so the test needs no connection properties at all.

Static analysis that fails the build

  • Error Prone runs inside javac and turns common mistakes into compile errors; NullAway runs as one of its plugins.
  • SpotBugs analyses bytecode for bug patterns such as ignored exceptions, inconsistent equals and hashCode, and unsynchronized access to shared state.
  • SonarQube (Server or Cloud) tracks issues, coverage and duplication over time, and SonarQube for IDE, formerly SonarLint, shows the same rules in the editor.
  • Spotless or Checkstyle settle formatting automatically, so review time goes to design.

On an existing codebase, record today's findings as a baseline and fail the build only on new ones. Otherwise the first run reports thousands of issues, and someone switches the check off.

Naming, comments and commit messages for the next reader

Code is read far more often than it is written, by colleagues, by reviewers and now by AI assistants working from the same files. Names, comments and commit history are what they read first.

Java naming conventions

  • Classes and interfaces: UpperCamelCase nouns (InvoiceRepository), or adjectives for capability interfaces (Comparable).
  • Methods: lowerCamelCase, starting with a verb (calculateVat, isOverdue).
  • Fields, parameters and local variables: lowerCamelCase (dueDate).
  • Constants, meaning static final fields holding immutable values: UPPER_SNAKE_CASE (MAX_BATCH_SIZE).
  • Packages: all lowercase, starting with a reversed domain (com.example.billing).
  • Record components follow field naming, and the accessor is dueDate(), not getDueDate().

Beyond the syntax, name things after the domain. The words the finance team uses for an invoice's states make better class and method names than the ones a developer invents.

Comments that explain why

Code shows what it does, and a well-named method makes a comment saying the same thing redundant:

// Before: the reader has to decode the rule
if ((employee.flags() & HOURLY_FLAG) != 0 && employee.ageOn(today) >= 65) {
    grantFullBenefits(employee);
}

// After: the rule has a name and one place to live
if (employee.isEligibleForFullBenefits(today)) {
    grantFullBenefits(employee);
}

Comments earn their place where the code cannot speak for itself: why a constraint exists, why the obvious approach was rejected, which external rule the code follows. Javadoc belongs on the public APIs other teams call.

// Round VAT per line, not per invoice: the ledger rounds each line, and rounding
// the invoice total produced one-cent differences in the monthly reconciliation.
BigDecimal vat = line.net().multiply(rate).setScale(2, RoundingMode.HALF_EVEN);

Commit messages: Conventional Commits, with the why in the body

The old advice to describe what changed rather than why had it backwards: the diff already shows what changed. Conventional Commits 1.0.0 gives the subject line a structure, type(scope): description, that tools read to generate changelogs and version numbers, and the body is where the reason for the change goes:

fix(invoicing): round VAT per line instead of per invoice

Finance reported one-cent differences between invoices and the ledger
on multi-line invoices. The ledger rounds VAT on each line; we rounded
the invoice total. Per-line HALF_EVEN rounding matches the ledger.

Amounts were also computed as double in PriceCalculator; they now use
BigDecimal end to end.

Refs: FIN-482

Keep the subject in the imperative and around 50 characters, mark a breaking change with ! before the colon or a BREAKING CHANGE: footer, and reference the ticket in a footer.

Using AI coding assistants on a Java codebase responsibly

In the Stack Overflow 2025 Developer Survey, 84% of respondents use or plan to use AI tools in development, and 51% of professional developers use them daily. Trust lags behind: 46% distrust the accuracy of what the tools produce, against 33% who trust it.

On a Java codebase, the practices above are what make AI-written code safe to accept. An assistant that writes a double for a price, returns null from a lookup or swallows an exception is stopped by the same NullAway check, test and review that would stop a person doing it.

  • Put the project's rules in the repository, where the assistant reads them: Java 25, @NullMarked packages, records for data, BigDecimal for money, versions from the BOM. Without them, assistants often fall back on older idioms such as Date, setters and null returns.
  • Review AI-written code in the same pull request flow as any other change, with the same tests and static analysis. The person who opens the pull request owns the code in it.
  • Check every dependency an assistant adds against Maven Central before it reaches the build. Models invent plausible artifact names, and attackers register them.
  • Use deterministic tools for deterministic work. An OpenRewrite recipe changes 800 files the same way every time; an assistant is better at the cases no recipe covers.
  • Keep client code and data inside the boundaries the client approved, and keep secrets out of prompts.

We use AI coding agents on client systems on those terms. Our comparison of AI coding tools covers the tools and what the research says about their effect on delivery.

Old Java advice to drop in 2026

Java has changed faster in the last eight years than in the fifteen before, and some advice that best-practice lists still repeat was written for Java 6 or 8. Several of those practices no longer hold. The table pairs each with what to do instead; the performance row is covered in more depth in how to improve Java performance.

Old adviceWhy it no longer holdsDo this instead
Use float instead of double to save memoryfloat holds about seven significant digits, and neither type stores decimal fractions exactlyBigDecimal for money, double for measurements, float only in large arrays a profiler points to
Hide fields behind getters and settersAny caller can put the object into any state, so validation is scattered across the codebaseRecords or final fields, validated once in the constructor
Check for null and throw NullPointerException by handThe JVM throws it anyway, with a message naming the null variable since JDK 15Null-marked packages checked by NullAway; Objects.requireNonNull at system boundaries
Release connections in a finally blockVerbose, and an exception from close() can hide the original failuretry-with-resources
Optimize at the end of the development cycleBy then the expensive decisions (data model, I/O pattern, threading) are fixedLatency and throughput targets from the start, load tests and profiling throughout, fixes where the measurements point
Commit messages describe what changed, not whyThe diff already shows what changed; the reason is what gets lostA Conventional Commits subject line and the reason in the body
Use as few libraries as possible to save memoryMemory is rarely the cost; unpatched and abandoned dependencies areBOMs, a dependency scan in CI, and removing what nothing uses
Fail fast means stopping development at the first errorFail fast describes running code, not a team processValidate input at the boundary and configuration at startup, and fail with a clear message

Where to start on an existing Java codebase

On a codebase that has grown for ten years, adopting all of this at once would stall delivery. The order we use on client systems puts the cheapest checks first:

  1. Find out what you run: java -version in every environment, jdeps --jdk-internals app.jar for internal JDK APIs that will block an upgrade, and mvn dependency:tree or ./gradlew dependencies for the libraries.
  2. Add a dependency scan to CI and fix the critical findings.
  3. Put tests around the flows the business depends on, with Testcontainers wherever a database is involved.
  4. Move to Java 25 and current framework versions in tested steps, with OpenRewrite doing the mechanical part where its licence fits.
  5. Turn on Error Prone and NullAway with a baseline, and mark new packages @NullMarked.
  6. Apply the code practices (records, sealed types, BigDecimal, java.time) to the code you touch, rather than in a rewrite.

The wine merchant's upgrade ran in the same order: a dependency scan to set the baseline, tests before any framework moved, then Java 25 and Spring 6 in steps that each ended with both applications building and passing the suite. The core upgrade took four weeks, with no feature freeze. If your systems need the same, our application modernization sprint starts with a written inventory of what runs where and a ballpark estimate for the upgrade.