GitHub Copilot for 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.
Java quick referenceVerified August 21, 2026
- Type system
- Statically typed and nominally typed; generics are erased at runtime, which is where most generated generic code goes wrong.
- Package management
- Maven or Gradle
- Manifest Copilot should see
- pom.xml or build.gradle(.kts)
- Testing
- JUnit 5, AssertJ, Mockito
- Formatting
- google-java-format or Spotless
- Linting
- Checkstyle
- Static analysis
- Error Prone, SpotBugs
- Common frameworks
- Spring Boot, Jakarta EE, Quarkus
- Typical Copilot work
- DTOs and records, Spring controllers, stream pipelines, JUnit tests, builder boilerplate
- First thing to review
- A dependency coordinate that does not exist, or a version that predates the API being called
Fastest honest checkmvn -q verify
Key takeaways
- The compiler catches shape errors, so the Java-specific review work is concentrated in three places: dependency coordinates, null handling, and concurrency.
- A hallucinated Maven or Gradle coordinate is the most common Java-specific
failure.
groupId:artifactId:versionis three chances to be plausibly wrong. - Generics are erased at runtime. Suggestions that compile with an unchecked warning are telling you the compiler could not verify something.
- Java’s training corpus spans twenty-five years. Expect suggestions in the style
of Java 8 unless you say otherwise, and expect
javaxwhere you usejakarta. - The fastest honest check is
mvn -q verify— compile, static analysis and tests in one command.
Project structure is context
Java’s conventional layout is unusually informative, and Copilot uses it.
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>) someRawListcompiles with a warning that says, precisely, “I cannot verify this.” AClassCastExceptionwill 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.
Listinstead ofList<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/ ├── pom.xml └── src/ ├── main/java/com/example/orders/ │ ├── Order.java │ ├── OrderTotals.java │ └── OrderController.java └── test/java/com/example/orders/ └── OrderTotalsTest.java
The prompt
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, neverdouble. Generated arithmetic reaches fordoublebecause it is shorter and because most public examples are not about money.BigDecimal.valueOf(quantity)rather thannew BigDecimal(quantity)for the conversion, and nevernew BigDecimal(0.1)from adouble— that constructor captures the binary representation exactly, digits and all.- An explicit
setScalewith a namedRoundingMode. 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 verifyMaven 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
- Run mvn -q verifyCompile, static analysis and tests. If Error Prone and SpotBugs are wired in, this is doing most of the review.
- Check every new dependency coordinateHuman judgementgroupId, artifactId and version, against the real repository page.
- Grep for @SuppressWarnings and raw typesHuman judgementEach one is a place the compiler could not verify something.
- Check null handling on every reference from outsideHuman judgementRequest bodies, database rows, map lookups, Optional.get().
- Look for shared mutable stateHuman judgementEspecially fields on singleton beans and anything reachable from two threads.
- 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.xmlorbuild.gradleopen, and state the Java and framework major versions in every prompt until they are in your repository instructions. - Ask for records,
java.timeand 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
BigDecimalfor 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
BigDecimalwithequals.
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.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.