GitHub Copilot for Java

GitHub Copilot Programming LanguagesAcademy lesson 30Cluster 3 · Lesson 5 of 13Intermediate15 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for JavaGitHub Copilot Programming Languages5Intermediate/github-copilot/languages/java/

Java is verbose, and that turns out to be an advantage.

A Java method signature carries more information than the equivalent in any dynamic language. Types on every parameter, a declared return type, checked exceptions in the throws clause, visibility, and — since records — an entire data shape declared in one line. Copilot reads all of that. The suggestions it produces for a well-typed Java codebase are among the most reliably shaped in this cluster.

Where Java gets interesting is everything around the code: a build file that resolves dependencies from a remote repository, a framework whose major versions renamed an entire package namespace, and a generics system that erases at runtime the very information the compiler used to accept your code.

Project structure is context

Java’s conventional layout is unusually informative, and Copilot uses it.

A conventional Maven project

service/ ├── pom.xml ├── src/ │ ├── main/ │ │ ├── java/com/example/service/ │ │ │ ├── OrderController.java │ │ │ ├── OrderService.java │ │ │ └── model/Order.java │ │ └── resources/application.yaml │ └── test/ │ └── java/com/example/service/OrderServiceTest.java

A file under src/test/java whose name ends in Test is unambiguously a test. A class in a model package is unambiguously data. A class named OrderController in a project whose pom.xml includes Spring Boot is unambiguously a web controller. None of that has to be explained; the layout says it.

Two practical consequences.

Keep pom.xml or build.gradle open. It is the single most valuable context file in a Java project. It carries the Java version, the framework, the framework version, the test library, and every dependency you have. Without it, the model is inferring your stack from class names.

Name the file before you generate. Creating OrderServiceTest.java next to OrderService.java and then asking for tests gives a far better result than asking for tests and letting the model decide where they live.

Core workflows

Records. For any immutable data carrier, ask for a record explicitly. Suggestions still default to a class with fields, a constructor, getters, equals, hashCode and toString — sixty lines that a record expresses in one. That default is not the model being wrong; it is the weight of two decades of public Java. Compact constructors are the right place for validation, and worth asking for by name.

Interfaces and abstract classes. Copilot is good at implementing an interface you have written and prone to inventing interfaces you did not ask for. A generated OrderServiceImpl behind an OrderService interface with one implementation is ceremony, not design. Ask for the concrete class and add the interface when a second implementation exists.

Generics. See the section below; this is the highest-risk area in the language.

Streams. Fluent and frequently over-applied. A four-stage stream pipeline with a nested lambda is harder to review and harder to debug than the loop it replaced, and the stack trace when it throws is worse. Watch specifically for Collectors.toMap without a merge function — it throws on a duplicate key rather than choosing — and for stateful lambdas, which break the moment someone adds .parallel().

Exceptions. Java’s checked exceptions are a genuine constraint on the model, and mostly a helpful one. The failure to look for is the swallow: catch (Exception e) { e.printStackTrace(); } appears throughout public code and therefore throughout suggestions. It converts a failure into a log line and lets execution continue with invalid state. Ask for the specific exception types and for the failure to be wrapped and rethrown or handled deliberately.

Optional. Use it as a return type, not as a field or a parameter. Generated code sometimes does the latter, and Optional fields do not serialise the way people expect.

Generics, erasure and unchecked warnings

Java’s generics are compile-time only. At runtime a List<String> is a List. That is why a suggestion can compile and still be wrong in a way the compiler explicitly warns you about.

The signals to look for in generated code:

  • An unchecked cast warning. (List<Order>) someRawList compiles with a warning that says, precisely, “I cannot verify this.” A ClassCastException will surface later, at the first use, far from the cast.
  • @SuppressWarnings("unchecked"). The same thing with the warning turned off. This is Java’s version of the escape hatches discussed in GitHub Copilot for TypeScript: a place the model gave up, in a form that looks deliberate.
  • A raw type. List instead of List<Order>. Compiles, and disables generic checking for everything downstream.
  • A wildcard that is the wrong way round. List<? extends Order> can be read from and not written to; List<? super Order> the reverse. Getting this backwards produces a compile error, so the risk is not correctness but the model working around it by widening to a raw type.

Compiling with -Xlint:all and treating unchecked warnings as errors turns this whole category into a build failure. That is a one-line change to a build file and it is the highest-value Java-specific setting for reviewing generated code.

Practical project: a small REST service

Practical example

An order-total endpoint with a record, a service and JUnit 5 tests

A minimal Spring Boot slice showing the shape a well-prompted Java suggestion should take, and the specific things to check in it.

Status
Example implementation
Not executed here
This code was written against the documentation cited at the end of the lesson but was not run while writing it. Treat the commands as the ones to run, not as output that has been observed.
Requires
A JDK (21 or later), Maven or Gradle, and network access to resolve dependencies

Files

copilot-java-demo

copilot-java-demo/ ├── pom.xml └── src/ ├── main/java/com/example/orders/ │ ├── Order.java │ ├── OrderTotals.java │ └── OrderController.java └── test/java/com/example/orders/ └── OrderTotalsTest.java

The prompt

Copilot promptGenerate the sliceCopilot Chat, with pom.xml open

Spring Boot 3, Java 21, Maven.

Create a record Order with id (String), quantity (int) and unitPrice (BigDecimal). Reject a negative quantity or price in the compact constructor.

Create OrderTotals with a static method total(List of Order) returning BigDecimal, using BigDecimal arithmetic throughout — never double.

Create a REST controller: POST /orders/total accepts a list of orders as JSON and returns the total. Return 400 on a validation failure.

Use jakarta.* imports, not javax.*.

That last line is not optional pedantry. Spring Boot 3 moved from the javax namespace to jakarta, and every Spring Boot 2 example in public code uses javax. Omit the instruction and you have a decent chance of getting imports that do not resolve.

Implementation

package com.example.orders;

import java.math.BigDecimal;

public record Order(String id, int quantity, BigDecimal unitPrice) {

    public Order {
        if (id == null || id.isBlank()) {
            throw new IllegalArgumentException("id must not be blank");
        }
        if (quantity < 0) {
            throw new IllegalArgumentException("quantity must not be negative");
        }
        if (unitPrice == null || unitPrice.signum() < 0) {
            throw new IllegalArgumentException("unitPrice must not be negative");
        }
    }

    public BigDecimal lineTotal() {
        return unitPrice.multiply(BigDecimal.valueOf(quantity));
    }
}
package com.example.orders;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;

public final class OrderTotals {

    private OrderTotals() {}

    public static BigDecimal total(List<Order> orders) {
        if (orders == null) {
            throw new IllegalArgumentException("orders must not be null");
        }
        return orders.stream()
                .map(Order::lineTotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add)
                .setScale(2, RoundingMode.HALF_UP);
    }
}

Three things to check in any Java suggestion that handles money, all visible here:

  • BigDecimal, never double. Generated arithmetic reaches for double because it is shorter and because most public examples are not about money.
  • BigDecimal.valueOf(quantity) rather than new BigDecimal(quantity) for the conversion, and never new BigDecimal(0.1) from a double — that constructor captures the binary representation exactly, digits and all.
  • An explicit setScale with a named RoundingMode. A suggestion that omits it produces a total with however many decimal places the inputs happened to have.

Tests

package com.example.orders;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.Test;

class OrderTotalsTest {

    @Test
    void totalsAnEmptyListAsZeroWithTwoDecimalPlaces() {
        assertThat(OrderTotals.total(List.of())).isEqualByComparingTo("0.00");
    }

    @Test
    void multipliesQuantityByUnitPrice() {
        var orders = List.of(
                new Order("a", 3, new BigDecimal("1.005")),
                new Order("b", 1, new BigDecimal("2.00")));
        assertThat(OrderTotals.total(orders)).isEqualByComparingTo("5.02");
    }

    @Test
    void rejectsNegativeQuantityAtConstruction() {
        assertThatThrownBy(() -> new Order("a", -1, BigDecimal.ONE))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("quantity");
    }
}

isEqualByComparingTo rather than isEqualTo is the detail worth stealing. BigDecimal.equals compares scale as well as value, so 2.0 and 2.00 are not equal. Generated assertions use isEqualTo and then fail confusingly.

Run

mvn -q verify

Maven and Gradle: the build file is the highest-risk file

Everything else in Java compiles or does not. The build file is where a plausible mistake survives longest, so it deserves its own treatment.

A Maven coordinate has three independently guessable parts. groupId, artifactId and version. A suggestion can get the group right and the artifact wrong, or both right and the version wrong, and each failure mode looks different: a wrong artifact fails to resolve immediately, while a wrong version resolves to a release that predates the method you are calling and fails at compile time with a confusing “cannot find symbol.”

Gradle adds a second guessable dimension. The Groovy DSL and the Kotlin DSL (build.gradle versus build.gradle.kts) have different syntax for the same thing, and public code contains vast quantities of both. A suggestion in the wrong dialect does not fail subtly; it fails to parse. Keeping the build file open resolves this immediately, which is a good argument for doing so even when you are not editing it.

Dependency management blocks matter. In a Spring Boot project the parent POM or the BOM already pins versions for a large set of libraries. A suggestion that adds an explicit <version> to a managed dependency overrides that pinning, and the result is a version skew that compiles and then fails at runtime with a NoSuchMethodError — the single most confusing failure the JVM produces. If the dependency is managed, the version tag should not be there.

Scopes are frequently wrong. A test library added without <scope>test</scope> ships to production. An annotation processor added as a normal dependency does not run. Neither produces an error at build time.

The habit that covers most of this: treat every build-file change as a change requiring its own review, separate from the code change that motivated it. It is a small file, it changes rarely, and a mistake in it is disproportionately expensive.

Spring Boot, at a high level

Framework guides belong elsewhere, but Spring dominates Java enough that three facts about it change how you review generated Java.

Annotations hide the wiring, which makes wrong wiring hard to see. A missing @Transactional, a @Component that was never scanned because it lives outside the application package, a @Value referring to a property that does not exist — none of these are compile errors, and the symptom appears at runtime or, worse, at the first request that exercises the path. Generated Spring code is syntactically confident and semantically unverified in exactly this way.

Constructor injection is the reviewable form. Field injection with @Autowired on a private field works, is common in public code, and makes the class impossible to instantiate in a test without a container. If a suggestion uses field injection, ask for constructor injection instead — it takes one line, it makes the dependencies explicit, and it turns a missing bean into a compile-time signature.

Configuration property names change between major versions. Spring Boot 3 renamed a substantial number of them. A wrong property name in application.yaml is silently ignored, so the application starts, appears healthy, and behaves as though you never set the value. This is the Spring-specific instance of the version-drift problem that runs through this whole cluster, and it is the one with the quietest failure.

Two more worth checking specifically in generated Spring code: @Transactional on a private or self-invoked method does nothing, because the proxy is never involved; and an exception thrown inside a @Transactional method only rolls back automatically if it is unchecked.

Documentation and Javadoc

Javadoc is one of the safer Copilot tasks in Java, with the same caveat this cluster keeps returning to: a generated Javadoc comment describes what the method appears to do. If the implementation is subtly wrong, you now have documentation asserting the wrong behaviour, which makes the bug harder to find rather than easier.

The useful inversion is to write the Javadoc first — including @throws for every condition the caller must handle and a sentence on the boundary behaviour — and let it constrain the implementation. In Java this works particularly well, because @throws clauses and checked exceptions reinforce each other: the documentation you wrote becomes a signature the compiler enforces.

Where generated Javadoc is unambiguously good: explaining an unfamiliar legacy class you are reading rather than writing. Asking chat to summarise what a four-hundred-line service actually does, then comparing that summary against the existing comments, is a fast way to find where the code and its documentation have drifted apart over the years — and in an old Java codebase, they always have.

Testing with JUnit 5

Copilot writes good JUnit 5. Three prompt additions raise the quality further.

Ask for @ParameterizedTest. The default output is five near-identical test methods. A @ParameterizedTest with a @CsvSource puts the cases in a table where the missing one is visible.

Ask for AssertJ if you use it. Suggestions default to JUnit’s built-in assertEquals, which produces less useful failure messages on collections and objects.

Name the mocking library, and question whether you need one. Generated tests reach for Mockito reflexively. A record and a pure function need no mock; a test that mocks the thing it is testing asserts only that the mock was called.

The warning from Python applies unchanged and is worth repeating: tests generated by reading the implementation encode the implementation’s bugs. Describe the required behaviour instead.

Static analysis: the Java advantage

Java has the strongest mainstream static-analysis ecosystem of any language in this cluster, and it is under-used.

Error Prone plugs into the compiler and catches bug patterns rather than style — comparing incomparable types, formatting strings with the wrong argument count, a @Test method that is not public, missing @Override. Many of its checks target exactly the mistakes fluent-but-wrong code makes.

NullAway, built on Error Prone, gives Java something close to strictNullChecks. Given @Nullable annotations, it fails the build on a dereference it cannot prove safe. For reviewing generated code this is transformative: the single largest category of Java runtime failure becomes a compile error.

SpotBugs analyses bytecode and finds a different class of problem — resource leaks, ignored return values, unsynchronised access to shared state.

Checkstyle handles convention. Less interesting for correctness, valuable for keeping generated code from arriving in a different style from the rest of the file.

Wire the first three into mvn verify and the review of generated Java becomes substantially cheaper, because the mechanical layer is genuinely mechanical.

Concurrency

This is the category where reading is least reliable and therefore where generated code deserves the most suspicion.

Look for shared mutable state reachable from more than one thread — a HashMap field on a singleton service, a non-volatile boolean flag used to stop a loop, a lazily initialised field with no synchronisation. All of these appear in public code, all compile, and all fail intermittently under load rather than in a test.

Prefer what the standard library already solved: ConcurrentHashMap, AtomicInteger, CompletableFuture, and the executor framework rather than hand-rolled thread management. If a suggestion creates threads directly, ask why it is not using an executor.

For Spring specifically: a singleton bean with mutable instance state is shared across every request, and it is an easy thing for a suggestion to produce because the code looks like an ordinary class.

Java-specific risks

Fabricated dependency coordinates. A Maven coordinate has three parts, and a plausible wrong one is easy to produce. Verify against the actual repository page before adding it, and check the version is one that exists.

Framework version mismatch. javax versus jakarta is the big one. Also: Spring Boot 2 and 3 configuration property names, JUnit 4 @Test annotations in a JUnit 5 project, WebSecurityConfigurerAdapter which Spring Security removed.

Obsolete APIs. new Date(), SimpleDateFormat (which is not thread-safe and has caused real production incidents), Vector, Hashtable. All still compile. java.time is the correct answer and the model uses it when asked.

Unchecked null handling. Covered above. NullAway is the systematic fix.

Poor exception design. Swallowed exceptions, throws Exception on every signature, custom exceptions that extend RuntimeException for conditions the caller must handle.

Resource leaks. Any InputStream, Connection, Statement or ResultSet not in a try-with-resources block. SpotBugs finds these reliably.

Debugging and refactoring

For debugging, paste the whole stack trace. Java traces are long, and the useful frame is rarely the first one — Caused by chains carry the actual origin, and a model reading the full chain will point at the right frame more often than a quick scan will.

Refactoring is where Java and Copilot combine best. The compiler enumerates every broken call site, so a large mechanical change is a loop: apply, compile, fix what javac names, repeat. Combined with agent mode this is genuinely reliable work — the closest thing in this cluster to a verified refactor, alongside Rust and TypeScript.

The caveat: a refactor that adds @SuppressWarnings or casts to make errors go away has not refactored anything. Check the diff for new suppressions.

Review workflow

Accepting a Java suggestion
  1. Run mvn -q verifyCompile, static analysis and tests. If Error Prone and SpotBugs are wired in, this is doing most of the review.
  2. Check every new dependency coordinateHuman judgementgroupId, artifactId and version, against the real repository page.
  3. Grep for @SuppressWarnings and raw typesHuman judgementEach one is a place the compiler could not verify something.
  4. Check null handling on every reference from outsideHuman judgementRequest bodies, database rows, map lookups, Optional.get().
  5. Look for shared mutable stateHuman judgementEspecially fields on singleton beans and anything reachable from two threads.
  6. Confirm resources are in try-with-resourcesHuman judgement

Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.

Best practices

  • Keep pom.xml or build.gradle open, and state the Java and framework major versions in every prompt until they are in your repository instructions.
  • Ask for records, java.time and try-with-resources by name; the defaults skew older.
  • Treat unchecked warnings as build errors.
  • Add Error Prone and NullAway. They convert the two biggest Java review categories into compile failures.
  • Use BigDecimal for money and say so explicitly in the prompt.

Common mistakes

  • Installing a dependency coordinate without checking it exists.
  • Accepting catch (Exception e) { e.printStackTrace(); } as error handling.
  • Letting @SuppressWarnings("unchecked") into the codebase unexamined.
  • Trusting a generated concurrency fix that you cannot reason about line by line.
  • Comparing BigDecimal with equals.

Where to go next

GitHub Copilot with IntelliJ IDEA covers the editor side — build-tool awareness, inline test running, and how IntelliJ’s own inspections overlap with the static analysis described here. GitHub Copilot for C# is the closest neighbouring lesson if you work across both ecosystems.

Sources

Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.

Primary sources