Java Patterns and Anti-Patterns for Agent-Driven Development. Part 2: Early Error Detection Patterns

This article continues Part 1 «Conceptual Foundations» and targets technical leads and architects who integrate LLM agents (primarily Claude Code) as a co-pilot or autonomous task executor in Java projects.
Part 1 laid the conceptual foundation and covered basic patterns; this one covers more advanced patterns and traps, grouped by stack layer.
The central thesis remains the same: agent-friendly code is code where a bug is either immediately visible to the compiler or instantly reproducible in a test. Everything else follows from this.
Sections follow the order in which errors surface: compile-time contracts (compiler catches it immediately), persistence layer (test or runtime), external integrations and async (load or failure), build system (CI or production).
Each section describes one pattern or a group of related traps using a consistent structure: anti-pattern → solution → why it works for the agent → usage guidelines.
Compile-time contracts and local reasoning patterns
The subsequent sections, on persistence, async, and external systems, all cover traps the compiler cannot catch. This section covers tools that prevent whole categories of errors before the application even runs.
The earlier the agent receives an error signal, the less context it must hold, and the lower the chance an error reaches production.
The «Return value must be used» contract via @CheckReturnValue
Problem: an API where a significant return value can be silently discarded without consequence.
Solution: annotate the method or package with @CheckReturnValue (Error Prone) — ignoring the return value becomes a compilation error.
When to apply: methods returning ValidationResult, Try, Either, Optional, UpdateCommand, patch objects, or new state — anywhere the semantics require the return value to be used.
Anti-pattern: the validator’s return value is produced but silently discarded. The code compiles, and happy-path tests pass.
Implementation:
Annotate the method or package with
@CheckReturnValue. With Error Prone enabled, ignoring an annotated call becomes a compilation error (severityERROR): «An error is triggered when one of these methods is called but the result is not used».Do not rely on «the developer will figure it out». The agent will not.
Note |
|
Why this helps the LLM agent:
Ignoring a return value that carries meaning is a typical agent mistake: the code compiles but behaves incorrectly.
Compile-time static checks are more reliable here than test coverage: they catch the problem before execution and do not depend on what context the agent happens to have.
Usage guidelines:
Introduce via a package-level rollout with explicit exceptions through
@CanIgnoreReturnValueto avoid drowning existing code in noise.If Error Prone is not yet integrated, the minimum step is to return a type such as
ValidationResultinstead ofbooleanand validate it in a single boundary layer.The Sealed interface pattern covered in Part 1 addresses the same class of problems.
// Bad — the validation result is returned but silently discarded
class UserService {
void changeEmail(UserId id, String email) {
validator.validate(email); // ValidationResult returned but dropped
repo.save(id, email);
}
}
// Validator interface: ValidationResult validate(String email)
// Error Prone with @CheckReturnValue will raise a compilation error on the line above
// Good
import com.google.errorprone.annotations.CheckReturnValue;
@CheckReturnValue
record ValidationResult(boolean ok, String message) {}
class UserService {
void changeEmail(UserId id, String email) {
ValidationResult r = validator.validate(email);
if (!r.ok()) throw new IllegalArgumentException(r.message());
repo.save(id, email);
}
}
Explicitly forbidden calls via @DoNotCall
Problem: unsupported methods that can be called and discovered only at runtime via UnsupportedOperationException.
Solution: annotate the method with @DoNotCall — any invocation becomes a compilation error.
When to apply: methods that exist to satisfy an interface or compatibility requirement but are forbidden by contract (read-only views, adapter classes, legacy APIs).
Anti-pattern: the add() method in ReadOnlyUsers exists because AbstractList requires an implementation. From the agent’s perspective this is a callable method with a normal signature, so it will call it.
Implementation:
- Annotate the forbidden method with
@DoNotCalland@Deprecated. The method must befinalorabstract: Error Prone requires this explicitly — «Methods annotated with @DoNotCall should be final (or abstract if the class is abstract)».
Why this helps the LLM agent:
If a method is accessible and its signature looks normal, the agent will call it.
@DoNotCallmakes the prohibition machine-visible: Error Prone disallows invocations and method references of the annotated method.Without
@DoNotCall, the error surfaces only at runtime: the compiler is silent, and by the timeUnsupportedOperationExceptionfires in production, the cause is no longer obvious.
Usage guidelines:
This matters most in SDKs, shared modules, and immutable-view types, where a runtime miss is expensive.
If Error Prone is not in use, isolate such methods in an internal package and do not export the type.
// Bad
final class ReadOnlyUsers extends AbstractList<User> {
@Override
public boolean add(User user) {
throw new UnsupportedOperationException("read only");
}
}
// Good
import com.google.errorprone.annotations.DoNotCall;
final class ReadOnlyUsers extends AbstractList<User> {
@Deprecated
@DoNotCall("read-only view")
@Override
public final boolean add(User user) {
throw new UnsupportedOperationException("read only");
}
}
Deep immutability of policy and data types
Problem: «almost immutable» objects (mutable member collections, mixed-mutability return types) introduce hidden aliasing effects.
Solution: @Immutable (Error Prone) + Guava’s ImmutableMap/ImmutableList — an immutability violation becomes a compilation error.
When to apply: rule objects (pricing, permissions), configuration, snapshots, decision objects, and DTOs for inter-module exchange.
An immutable object is easier to reason about locally. Hidden aliasing effects, silent mutations, and surprise regressions when someone edits nearby code all disappear.
Note | Error Prone applies a conservative definition of deep immutability. |
Usage guidelines:
Excessive immutability increases the number of intermediate objects and requires a builder/factory layer. Measure before applying on the hot path.
At a minimum, use
Map.copyOf,List.copyOf, records, and immutable return values at module boundaries.
// Bad
class PricingRules {
private final Map<String, BigDecimal> discounts = new HashMap<>();
Map<String, BigDecimal> discounts() { return discounts; }
}
// Good
import com.google.common.collect.ImmutableMap;
import com.google.errorprone.annotations.Immutable;
@Immutable
final class PricingRules {
private final ImmutableMap<String, BigDecimal> discounts;
PricingRules(ImmutableMap<String, BigDecimal> discounts) {
this.discounts = discounts;
}
ImmutableMap<String, BigDecimal> discounts() { return discounts; }
}
Database interaction
The persistence layer is the primary source of silent problems in agent-driven development: N+1 queries, LazyInitializationException, dirty checking, and cascade propagation all look like ordinary method calls in the code. None of them are visible before execution.
The more directly the code states what database interaction it performs, the less hidden behavior the agent can stumble over.
Multi-line queries via text blocks and named parameters
Problem: string concatenation and positional parameters (?1/?2) make SQL queries brittle under refactoring.
Solution: text blocks + @Param — the query reads as SQL and argument order becomes irrelevant.
When to apply: any JPQL/SQL query that will survive at least one refactoring.
Implementation:
Use text blocks (preview in Java 13 via JEP 355 and Java 14 via JEP 368; finalized in Java 15 via JEP 378). For named parameters, use
@Paramor the compiler flag-parameters.For dynamic queries with optional filters, use
Specificationfrom Spring Data JPA rather than string concatenation.
Why this helps the LLM agent:
A text block makes the query look like SQL/JPQL, and named parameters remove dependence on argument order. Silent semantic shifts during machine editing become much less likely.
Spring Data JPA states directly: «This makes query methods a little error-prone when refactoring regarding the parameter position. To solve this issue, you can use
@Paramannotation to give a method parameter a concrete name and bind the name in the query».Specificationeliminates a typical agent error in dynamic queries: string concatenation with conditional WHERE fragments that compiles but breaks when the query structure changes.
Usage guidelines:
Text blocks do not replace a type-safe DSL for truly dynamic queries: those require Criteria/Specification/QueryDSL.
Always annotate parameters with
@NonNull/@Nullable: combining=in a condition with a NULL value produces unexpected results.If a parameter accepts a bounded set of values, use an enum.
For additional parameter value validation, use an aspect: a class with
@Aspectand a method with@Before("execution(* com.example.repository.SomeRepository.someMethod(..))"). The@Aspectannotation goes on the implementation class, not the interface: Spring AOP applies aspects through the proxy mechanism, which operates on bean classes.
// Bad
@Query("select u from User u where u.firstname = ?1 or " +
"u.lastname = ?2")
User findByLastnameOrFirstname(String lastname, String firstname);
// Good — static query
@Query("""
select u
from User u
where u.firstname = :firstname
or u.lastname = :lastname
""")
User findByLastnameOrFirstname(
@Param("lastname") @NonNull String lastname,
@Param("firstname") @NonNull String firstname);
// Bad — dynamic query via string concatenation
List<Order> findOrders(String status, LocalDate from, LocalDate to) {
String jpql = "select o from Order o where 1=1";
if (status != null) jpql += " and o.status = '" + status + "'"; // SQL injection risk
if (from != null) jpql += " and o.createdAt >= '" + from + "'";
return em.createQuery(jpql, Order.class).getResultList();
}
// Good — dynamic query via Specification
// Repository: extends JpaSpecificationExecutor<Order>
static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) -> status == null
? cb.conjunction()
: cb.equal(root.get("status"), status);
}
static Specification<Order> createdAfter(LocalDate from) {
return (root, query, cb) -> from == null
? cb.conjunction()
: cb.greaterThanOrEqualTo(root.get("createdAt"), from);
}
// In the service:
List<Order> orders = repo.findAll(
hasStatus(status).and(createdAfter(from))
);
// Good — dynamic query using SQL itself
@Query("""
select *
from job_runs
where id = :id and (
:state::archive_state is null or archive_state = :state::archive_state
)
""")
JobRunEntity findById(
@Param("id") @NonNull UUID id,
// this is an optional filter parameter
@Param("state") @Nullable ArchiveStateEnum state
);
Queries formatted as self-contained text blocks are easy to paste into a database console. Using @Query adds two more benefits: Spring validates query syntax at startup, and parameterized queries can be covered by a test that cycles through valid null/not-null combinations and verifies that the query does not produce a full scan.
Replacing ORM with explicit SQL
Problem: the agent treats collections and repository methods as ordinary Java objects, unaware that every call issues an SQL query.
Solution: @Query with explicit SQL, @Modifying for bulk operations, jOOQ for dynamic queries.
When to apply: any aggregation, bulk operation, or query with multiple filter conditions; whenever a query is more complex than a single findBy….
This trap follows from the previous section. Text blocks and named parameters make the query form explicit but leave the choice open: whether to issue a query at all, or push the work to the Java Stream API.
The problem compounds as filtering and data-merging operations silently migrate to the Java side: the agent reaches for stream().filter(), groupingBy(), and other collectors where a single WHERE clause or GROUP BY on the database side would suffice. The Java Stream API has a lot in it, and the agent reaches for it because it is always at hand. Junior developers fall into the same trap for the same reason.
Why ORM is systemically dangerous in agent-driven development:
JPA is built on several layers of implicit behavior, none of which the agent can observe when reading code statically.
A getter backed by a SELECT.
A getter call and a lazy-collection access look identical in code. The agent sees no difference between order.getStatus() and order.getLines(), even though the cost of these operations differs by orders of magnitude. The problem is invisible at compile time and in unit tests. It surfaces only at runtime under load. Vlad Mihalcea characterizes unbounded fetch as a «terrible Anti-Pattern» in «The Spring Data findAll Anti-Pattern» (vladmihalcea.com, November 15, 2022).
FetchType.LAZY defers loading; it does not prevent it.
The collection is still loaded, often outside the transaction, which produces a LazyInitializationException. This is particularly insidious: the error does not surface where the collection access code is written, but where the transaction has already ended.
No cost model at edit time.
The agent does not observe SQL traffic. It does not know how many times a query executes inside a loop. Sound JPA model ownership requires continuous awareness of persistence layer state, which the agent simply does not have. N+1 problems do not reproduce in unit tests and are detected only in integration tests or in production.
Entity modification with unpredictable consequences.
When the agent adds a new field or association to an entity, it does not evaluate the impact on all existing @EntityGraph annotations, fetch plans, projections, and @Modifying queries. The dependency graph is not localized in any single file. It is distributed across the entire persistence layer, and the consequences of a change can surface in a completely unrelated use case.
Dirty checking as a hidden side effect.
The persistence context automatically generates an UPDATE on flush. The agent modifying a field of a managed entity may omit save, and the change still reaches the database. The detached case is the mirror: the agent assumes changes will apply, gets no error, and gets no effect. Both scenarios are invisible when reading code.
Cascade as viral operation propagation.
The agent adds cascade = CascadeType.ALL by analogy with existing code, unintentionally including graph branches where cascading is undesirable. The analogical error produces no compilation signal and is discovered only in integration testing or production.
None of these five traps are visible when reading code statically, and none produce a signal before execution. Any one of them is manageable in isolation; combined, they create more error surface than any reviewer can consistently track. If the team is not set up to review the persistence layer continuously, JPA costs more than it saves.
JPA Alternatives:
jOOQ
A type-safe DSL that generates SQL from the database schema. Every query is explicit in code; there is no lazy loading, no flush magic, no dirty checking. The agent works with dsl.select(…).from(…).where(…), a construct that reads as SQL and carries the same structural cost. Downside: code generation requires schema synchronization. Licensing: Apache 2.0 for open-source databases (PostgreSQL, MySQL, MariaDB, H2, etc.); commercial databases (Oracle, SQL Server, DB2) require a paid Pro/Enterprise license (this model has been in effect since jOOQ 3.2, October 2013). Check the current licensing model at jooq.org.
Spring Data JDBC
A deliberately simplified JPA successor with no lazy loading, no first-level cache, and no dirty checking. Aggregates are loaded in full or not at all: there are no intermediate states. Collections are allowed, but their loading is always eager and always explicit: «SQL statements are issued when and only when you invoke a repository method. The object returned as result of that method is fully loaded before the method returns». It preserves the familiar Spring Data repository abstractions and @Query. Downside: less flexibility for complex graphs; no JPQL.
JDBI / Spring JdbcTemplate
Minimal mapping of query results onto Java objects with no ORM semantics. Full control over SQL at the cost of full absence of automation. Justified for read-heavy services and reporting modules where queries are complex and unique.
// Bad: N+1 query — each department triggers a separate SELECT for employees,
// then filtering and grouping execute in JVM memory
List<Department> departments = departmentRepo.findAll(); // SELECT * FROM department
Map<String, Long> headcount = departments.stream()
.filter(d -> d.isActive())
.collect(Collectors.toMap(
Department::getName,
d -> (long) d.getEmployees().size() // SELECT * FROM employee WHERE department_id = ?
));
// Bad: bulk update by loading the collection — M entities × 1 UPDATE each
List<Employee> toRaise = employeeRepo
.findByDepartmentAndSalaryLessThan(dept, threshold); // SELECT ...
toRaise.forEach(e -> e.setSalary(e.getSalary() * 1.1)); // N × UPDATE
// Good: aggregation and filtering entirely on the database side — one query
public interface DepartmentRepo extends JpaRepository<Department, Long> {
@Query("""
SELECT d.name AS name, COUNT(e) AS headcount
FROM Department d
JOIN d.employees e
WHERE d.active = true
GROUP BY d.name
""")
List<HeadcountProjection> findActiveHeadcount();
interface HeadcountProjection {
String getName();
Long getHeadcount();
}
}
// Good: bulk update with a single SQL UPDATE — no entities loaded into memory
public interface EmployeeRepo extends JpaRepository<Employee, Long> {
@Modifying
@Query("""
UPDATE Employee e
SET e.salary = e.salary * :factor
WHERE e.department = :dept
AND e.salary < :threshold
""")
int raiseSalariesBelow(
@Param("dept") Department dept,
@Param("threshold") BigDecimal threshold,
@Param("factor") BigDecimal factor
);
}
Explicit fetch plans and projection return types
Problem: service code inadvertently relies on lazy loading and fully materialized entities.
Solution: closed projections for partial data retrieval, @EntityGraph for an explicit fetch plan.
When to apply: any use case that reads only part of an aggregate’s data or requires a specific association graph.
This pattern targets the «No cost model at edit time» trap. The previous section asked whether to use JPA at all; this one shows how to make fetch behavior visible and testable when you do.
Why this helps the LLM agent:
N+1 queries,
LazyInitializationException, unnecessary joins, and hidden transactional dependencies are all invisible at edit time.Spring Data JPA provides two mechanisms for stating fetch intent clearly: projections for partial aggregate retrieval and
@EntityGraphfor fetch plans.
Usage guidelines:
Proliferating projections bloats the repository layer; open projections with SpEL optimize less effectively than closed ones.
Use closed projections by default and introduce a naming convention:
findSummary…,findDetailed…,findForUpdate….
// Bad — implicit dependency on lazy loading and an open transaction
@Transactional
OrderView load(Long id) {
Order o = repo.findById(id).orElseThrow();
return new OrderView(
o.getId(),
o.getCustomer().getName(), // lazy — requires an open tx
o.getLines().stream().map(OrderLine::getSku).toList());
}
// Good — fetch plan and projection declared explicitly
@EntityGraph(attributePaths = {"customer", "lines"})
Optional<Order> findDetailedById(Long id);
interface OrderSummary {
Long getId();
CustomerOnly getCustomer();
interface CustomerOnly { String getName(); }
}
@Transactional traps: self-invocation, mixed I/O, and explicit transaction boundaries
Problem: @Transactional is treated as a «magic annotation» that guarantees atomicity for any code inside the method.
Solution: extract @Transactional methods into a separate bean, or use TransactionTemplate for an explicit boundary.
When to apply: orchestrator methods combining HTTP and SQL; any method that calls a @Transactional method on the same class.
All three stem from the same cause: the CGLIB proxy intercepts only calls through the bean, not calls within it.
Self-invocation.
Spring implements @Transactional via a CGLIB proxy. When processOrder() calls this.saveOrder(), the call bypasses the proxy and no transaction is opened. The error is invisible at compile time: the code compiles, the happy-path unit test passes, and the problem surfaces only in integration testing or production. Solutions: move saveOrder() into a separate Spring bean; use TransactionTemplate; enable AspectJ weaving via @EnableTransactionManagement(mode = AdviceMode.ASPECTJ). IntelliJ IDEA documents this as the SpringTransactionalMethodCallsInspection inspection.
Mixed I/O.
@Transactional on a method that executes SQL, then an HTTP call, then SQL again holds a connection pool slot for the entire duration of the HTTP call. This does not manifest in tests. In production under load, when an external service hangs, the connection pool is exhausted immediately. Solution: wrap only the SQL operations in TransactionTemplate.execute(…).
Implicit transaction boundary.
TransactionTemplate makes the boundary locally visible. Any reader, agent or reviewer, sees exactly where the transaction starts and ends, without hunting for an annotation on the method signature. Without this, it is easy to miss in review that a method holds a connection longer than necessary.
All three reduce to the same thing: the agent reads code. It does not run the application, does not observe metrics. The error signal arrives only in production under load.
Usage guidelines:
@Transactionalremains the right choice for simple CRUD services with no external I/O. UseTransactionTemplatefor orchestrator methods.Rule in
CLAUDE.md: «orchestrator methods (HTTP + multiple SQL) are not annotated with@Transactional; SQL blocks within them are wrapped inTransactionTemplate».ArchUnit rule as an additional gate: «classes containing
@Transactionalmethods must not contain public methods without@Transactionalwhose bodies call@Transactionalmethods on the same class».
// Bad — self-invocation: saveOrder() is called bypassing the proxy,
// @Transactional on saveOrder() has no effect
@Service
public class OrderService {
public void processOrder(Order order) {
validate(order);
this.saveOrder(order); // direct call — proxy bypassed
notifyCustomer(order);
}
@Transactional
public void saveOrder(Order order) {
repo.save(order);
}
}
// Good — saveOrder extracted into a separate bean,
// transaction opened through the proxy
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderPersistence persistence; // separate bean
public void processOrder(Order order) {
validate(order);
persistence.saveOrder(order); // through proxy — transaction works
notifyCustomer(order);
}
}
@Service
@RequiredArgsConstructor
class OrderPersistence {
private final OrderRepository repo;
@Transactional
void saveOrder(Order order) {
repo.save(order);
}
}
// Bad — transaction holds a DB connection for the entire HTTP call duration
@Transactional
public OrderConfirmation submitOrder(Order order) {
repo.save(order); // opens connection
PaymentResult result = paymentClient.charge(order); // HTTP — connection held
repo.updateStatus(order.id(), result.status());
return new OrderConfirmation(order.id());
}
// Good — TransactionTemplate limits the transaction to SQL operations only
@Service
@RequiredArgsConstructor
public class OrderService {
private final TransactionTemplate tx;
private final OrderRepository repo;
private final PaymentClient paymentClient;
public OrderConfirmation submitOrder(Order order) {
tx.executeWithoutResult(status -> repo.save(order)); // transaction closed
PaymentResult result = paymentClient.charge(order); // HTTP outside transaction
tx.executeWithoutResult(
status -> repo.updateStatus(order.id(), result.status())
);
return new OrderConfirmation(order.id());
}
}
The same error class (self-invocation through the proxy) applies to @Cacheable, @CacheEvict, and @Async. The agent annotates a private/final method or invokes it via this.: the annotation is silently ignored. An additional trap: without @EnableCaching / @EnableAsync on a configuration class, the annotation has no effect at all, without any warning. Both cases are invisible at compile time; the only protection is an integration test that verifies the actual behavior.
AggregateReference<T, ID> (Spring Data JDBC) instead of raw foreign keys
Problem: cross-aggregate references typed as Long or UUID are indistinguishable to the compiler — customerId and productId share the same type.
Solution: AggregateReference<T, ID> — the aggregate type is baked into the signature, so mixing them becomes a compile error.
When to apply: Spring Data JDBC; any foreign keys between aggregates.
The compile-time contract idea from the first section applies here too: domain references, not just API calls, get a type-safe wrapper.
Why this helps the LLM agent:
The agent does not keep track of «this is a FK to Customer and that one is to Product». Seeing a
Long, it treats it as a number.AggregateReference<Customer, UUID>turns type confusion into a compile error.Spring Data JDBC intentionally excludes bidirectional references and many-to-many, which cuts out the whole category of lazy joins and N+1 queries that come from a «helpful» collection.
Usage guidelines:
Applicable to Spring Data JDBC only. The JPA equivalent is named embedded types or
@Convertwith a record wrapper over the ID.AggregateReference.to(uuid)is the factory method for creation;ref.getId()reads the value. Deserialization via Jackson typically requires a customConverteror an explicit deserializer.
// Bad — all FKs are typed as Long, the compiler cannot prevent mixing them up
class Order {
@Id Long id;
Long customerId; // FK to Customer?
Long productId; // FK to Product?
Long warehouseId; // FK to Warehouse?
}
// Good — aggregate type is encoded explicitly, mixing them is a compile error
class Order {
@Id Long id;
AggregateReference<Customer, UUID> customer;
AggregateReference<Product, UUID> product;
AggregateReference<Warehouse, Long> warehouse;
}
// Creating:
AggregateReference<Customer, UUID> customerRef =
AggregateReference.to(customerId);
// Reading the FK for SQL:
UUID rawCustomerId = order.customer().getId();
Declarative integration with external systems
Service boundaries create the same visibility problem at a different layer. The agent cannot see how the service interacts with an external API. The contract has no obvious home, transport details are scattered across service code, and on the next task the agent has to reconstruct it from scratch.
A declarative interface puts the external system contract in a single file, where it can be swapped for a mock without touching business logic.
HTTP clients: @HttpExchange
Problem: transport details (URL, headers, retry) are scattered across service code; the external API contract is not expressed explicitly anywhere.
Solution: an annotated interface with @HttpExchange — Spring generates the implementation, and the contract is concentrated in a single file.
When to apply: any HTTP client, starting with Spring Framework 6.0 / Spring Boot 3.x.
Starting with Spring Framework 6.0, an external HTTP API is described as an interface with @HttpExchange (at the interface level: base path and headers) and @GetExchange / @PostExchange / @PutExchange / @PatchExchange / @DeleteExchange (at the method level). Spring generates the implementation via HttpServiceProxyFactory.
Spring Framework 7.0 / Spring Boot 4.0 introduced @ImportHttpServices, which registers the proxy as an ordinary Spring bean without explicitly declaring an HttpServiceProxyFactory.
Note | In Spring Framework 7 / Spring Boot 4, the base URL and transport settings should be placed in configuration via |
// Bad: transport details scattered across the service,
// the external API contract is not expressed explicitly anywhere
@Service
@RequiredArgsConstructor
public class GitHubService {
private final WebClient webClient;
public Repository getRepository(String owner, String repo) {
return webClient.get()
.uri("https://api.github.com/repos/{owner}/{repo}", owner, repo)
.header("Accept", "application/vnd.github.v3+json")
.retrieve()
.bodyToMono(Repository.class)
.block();
}
}
// Good (Spring Boot 3.x): GitHub API contract concentrated in one interface
@HttpExchange(url = "/repos/{owner}/{repo}",
accept = "application/vnd.github.v3+json")
public interface GitHubRepositoryClient {
@GetExchange
Repository getRepository(@PathVariable String owner,
@PathVariable String repo);
}
// Good (Spring Boot 4.x): declarative registration via @ImportHttpServices;
// base URL set in application.yml or via RestClientHttpServiceGroupConfigurer
@Configuration
@ImportHttpServices(group = "github", types = GitHubRepositoryClient.class)
public class GitHubClientConfig {
// transport settings in the configuration class, not on the interface
}
Supported transports:
RestClientAdapter— blocking; in Spring Boot 4.0, an HTTP service group is configured withRestClientby default: «An HTTP service group is configured with RestClient by default, but you can switch to WebClient through theclientTypeattribute of the annotation».WebClientAdapter— reactive, supportsMono<T>andFlux<T>; switched via theclientTypeattribute in@ImportHttpServices.RestTemplateAdapter— usesRestTemplate, which is in maintenance mode (no new features planned); available for incremental migration.
Message queues: @MessagingGateway
Problem: sending messages through RabbitTemplate/KafkaTemplate is imperative — exchange names and routing keys are scattered across business logic.
Solution: @MessagingGateway (Spring Integration) — a declarative sending interface, analogous to @HttpExchange for message producers.
When to apply: any RabbitMQ, Kafka, or JMS producer where transport details must not leak into business logic.
Spring AMQP, Spring Kafka, and Spring JMS do not provide a declarative interface for producers. Sending remains imperative. @MessagingGateway from Spring Integration closes this gap: an interface with annotated methods whose implementation the framework generates.
// Bad: sending details scattered across the service
@Service
@RequiredArgsConstructor
public class OrderService {
private final RabbitTemplate rabbitTemplate;
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public void submitOrder(Order order) {
// Magic exchange/routing key strings directly in business logic
rabbitTemplate.convertAndSend("orders.exchange", "orders.submitted",
new OrderSubmittedEvent(order.getId(), order.getTotal()));
// Kafka details right here too
kafkaTemplate.send("order-events",
order.getId().toString(),
new OrderEvent("SUBMITTED", order));
}
}
// Good: sending contract described as an interface,
// transport details in channel configuration
@MessagingGateway
public interface OrderGateway {
@Gateway(requestChannel = "orderSubmittedChannel")
void submitOrder(@Payload OrderSubmittedEvent event,
@Header("tenantId") String tenantId);
@Gateway(requestChannel = "orderCancelledChannel",
requestTimeout = 2000)
void cancelOrder(@Payload OrderCancelledEvent event);
}
Async, resources, and verifiability patterns
Concurrency and external failure introduce errors that only surface under load or when an upstream service goes down. The agent is working blind on all of them.
It sees only signatures and annotations, with no way to observe what the application actually does. These are the most expensive errors because they show up last.
Explicit Executor instances in CompletableFuture chains
Problem: supplyAsync/thenApplyAsync without an executor uses ForkJoinPool.commonPool() — behavior depends on global process state.
Solution: explicit named executors; separate I/O pool and CPU pool.
When to apply: any CompletableFuture code in an application service.
Why this helps the LLM agent:
CompletableFuture.supplyAsync()without an executor submits the task toForkJoinPool.commonPool(). Behavior depends on shared process state and neighboring tasks, which makes it harder to reproduce and harder to explain from a single code fragment.An explicit executor reduces nondeterminism and makes tests and traces easier to interpret.
Usage guidelines:
The number of infrastructure decisions increases: pool sizing, backpressure, lifecycle management. Without these, the agent produces async code that looks fine until it hits production load.
If async is not critical, keep the code synchronous. Agent-friendly synchronous code is almost always preferable to pseudo-async without an explicit contract.
// Bad
CompletableFuture<User> load(String id) {
return CompletableFuture.supplyAsync(() -> repo.find(id))
.thenApplyAsync(this::enrich)
.thenApplyAsync(this::score);
}
// Good
class UserLoader {
private final Executor ioPool;
private final Executor cpuPool;
CompletableFuture<User> load(String id) {
return CompletableFuture.supplyAsync(() -> repo.find(id), ioPool)
.thenApplyAsync(this::enrich, cpuPool)
.thenComposeAsync(this::scoreAsync, cpuPool)
.exceptionallyComposeAsync(this::fallback, cpuPool);
}
}
@Async traps: @EnableAsync, self-invocation, and unbounded queue
Problem: @Async appears to declare asynchrony, but without several prerequisites it either silently does nothing or overloads the system.
Solution: @EnableAsync on a configuration class + method in a separate bean + named executor with explicit bounds.
When to apply: any @Async method in production code.
@Async is another member of the AOP proxy trap family described in the @Transactional section. Self-invocation through this. works exactly the same way: the call bypasses the proxy and the method executes synchronously without any warning.
Three independent traps:
Missing @EnableAsync.
Without @EnableAsync on a configuration class, the annotation is silently ignored: the method executes synchronously and no exception is thrown. The happy-path test passes. The method «works», just not asynchronously.
Self-invocation through this..
The same trap as in @Transactional: the agent «extracts a method» within the same class and adds @Async. The call remains direct and executes synchronously without any warning.
Unbounded queue of the default executor.
Spring Boot’s TaskExecutionAutoConfiguration (since version 2.1) auto-configures a ThreadPoolTaskExecutor with 8 core threads and a queue and max-size both defaulting to Integer.MAX_VALUE (effectively unbounded). This does not manifest in tests but threatens OOM under production load spikes. With spring.threads.virtual.enabled=true (Spring Boot 3.2+), the applicationTaskExecutor bean is created as a SimpleAsyncTaskExecutor backed by virtual threads. In both cases, an explicit executor with a bounded queue and a RejectedExecutionHandler produces predictable backpressure behavior.
Usage guidelines:
Record in
CLAUDE.md: «@Asyncrequires@EnableAsyncon a configuration class; the method must bepublic; invoke only through the Spring bean, never throughthis.; always specify the executor name».ArchUnit rule: methods annotated with
@Asyncmust not beprivate,protected, orfinal.
// Bad — three errors at once: no @EnableAsync, self-invocation, no executor
@Service
public class NotificationService {
public void processOrder(Order order) {
this.sendEmail(order); // self-invocation — proxy bypassed
}
@Async // silently ignored without @EnableAsync
private void sendEmail(Order order) { // private — not intercepted by proxy
emailClient.send(order.customerEmail());
}
}
// Good
@Configuration
@EnableAsync // required
public class AsyncConfig {
@Bean("notificationExecutor")
public Executor notificationExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(16);
ex.setQueueCapacity(200);
ex.setRejectedExecutionHandler(new CallerRunsPolicy()); // backpressure
ex.setThreadNamePrefix("notification-");
ex.initialize();
return ex;
}
}
@Service
@RequiredArgsConstructor
public class NotificationService {
private final EmailSender emailSender; // extracted to a separate bean
@Async("notificationExecutor") // explicit executor, public method
public void sendEmail(Order order) {
emailSender.send(order.customerEmail());
}
}
// Test: verify the method actually executes in a different thread
@SpringBootTest
class NotificationServiceAsyncTest {
@Autowired NotificationService notificationService;
@Test
void sendsEmailInSeparateThread() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<String> threadName = new AtomicReference<>();
// override emailSender via @MockitoBean or @TestBean
// to capture the thread name
notificationService.sendEmail(new Order(UUID.randomUUID(), "test@example.com"));
assertThat(latch.await(2, SECONDS)).isTrue();
assertThat(threadName.get()).startsWith("notification-");
}
}
@EventListener vs @TransactionalEventListener
Problem: @EventListener appears to be the standard way to react to domain events, but it executes within the same transaction as the publisher.
Solution: @TransactionalEventListener(phase = AFTER_COMMIT) for side effects; @Transactional(propagation = REQUIRES_NEW) for database writes inside the listener.
When to apply: any listener that sends notifications, calls external systems, or writes to an audit table after the transaction.
These traps build on the @Transactional section above. Those were about the transaction boundary; these are about what happens after it ends. The agent does not see transactional context when reading code statically in either case.
Four independent traps:
@EventListener without transactional awareness.
The listener executes in the same transaction as the publisher. An error during email dispatch rolls back the order save, a data integrity violation that does not manifest in unit tests and is discovered in production only after the user has already lost their data.
Lost database write in AFTER_COMMIT.
This is the classic «test is green, production is broken» case: in a test with a rollback, the audit table insert appears correct. In production, the record silently disappears because the AFTER_COMMIT listener does not automatically open a new transaction: an explicit propagation = REQUIRES_NEW is required.
Silent degradation without @EnableTransactionManagement.
Without @EnableTransactionManagement, @TransactionalEventListener silently degrades to a plain @EventListener: the phase semantics are lost with no warning. Spring Framework issue #32319 documents this behavior: «without @EnableTransactionManagement, the DefaultEventListenerFactory will see @EventListener present as meta-annotation and thus create a standard ApplicationListenerMethodAdapter for the method».
fallbackExecution=false by default.
If the event is published outside a transaction (e.g., from a @Scheduled method), a listener with the default fallbackExecution=false will not execute at all: no error, no warning, the event is simply lost.
Usage guidelines:
Record in
CLAUDE.md: «@EventListener: use when the same transaction is required;@TransactionalEventListener(phase = AFTER_COMMIT, fallbackExecution = true): use when firing after commit is required; database writes inside an AFTER_COMMIT listener: only through@Transactional(propagation = REQUIRES_NEW)».For side effects (email, push, webhook), consider the outbox pattern: the listener writes a record to an outbox table within the same transaction, and a separate process handles delivery. This removes the «lost event» problem at its root.
// Bad — listener in the same transaction:
// an email send error rolls back the entire order
@Service
@RequiredArgsConstructor
public class AuditListener {
private final AuditRepository auditRepo;
private final EmailClient emailClient;
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
auditRepo.save(new AuditEntry(event.orderId(), "PLACED")); // same tx
emailClient.sendConfirmation(event.customerEmail()); // error → order rollback
}
}
// Good — listener fires after commit, DB write in a new transaction
@Service
@RequiredArgsConstructor
public class AuditListener {
private final AuditRepository auditRepo;
private final EmailClient emailClient;
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true // fires even without an active transaction
)
@Transactional(propagation = Propagation.REQUIRES_NEW) // new tx for DB write
public void onOrderPlaced(OrderPlacedEvent event) {
auditRepo.save(new AuditEntry(event.orderId(), "PLACED")); // its own transaction
emailClient.sendConfirmation(event.customerEmail()); // error does not affect the order
}
}
try-with-resources as the mandatory form for I/O and JDBC
Problem: a manual finally does not guarantee closure of the second resource when the first throws an exception; the original exception may be suppressed.
Solution: try-with-resources for everything that implements AutoCloseable.
When to apply: JDBC, file streams, zip/file readers — any resources the agent edits frequently.
Why this helps the LLM agent:
Code becomes shorter, control flow more linear, and failure paths more visible.
Oracle notes that
try-with-resourcesguarantees resource closure and eliminates the leaks characteristic of manualfinally.
// Bad — only Statement is explicitly closed; the second resource is not protected
Statement st = con.createStatement();
try {
return st.executeQuery(sql);
} finally {
st.close();
}
// Good — both resources are guaranteed to close, exceptions are not suppressed
try (Statement st = con.createStatement();
ResultSet rs = st.executeQuery(sql)) {
while (rs.next()) {
// read row
}
}
Resilience4j as a declarative fault-tolerance contract
Problem: interaction with an external service is protected by a manual try/catch that returns null — no circuit breaker, no retry limits, no metrics.
Solution: @Retry + @CircuitBreaker + fallbackMethod from Resilience4j — the fault-tolerance policy becomes a declarative contract.
When to apply: any call to an external HTTP client or message queue.
The declarative integration section covered the call contract. This section covers what happens when calls fail.
This is exactly the code the agent generates: a manual try/catch that returns null. According to the CodeRabbit report «State of AI vs Human Code Generation» (December 2025, analysis of 470 open-source PRs: 320 AI-authored and 150 human-only), exception handling and error-path problems appear in AI-generated PRs nearly twice as often. The swallowed exception compiles, tests stay green, and at the first real cascading failure the connection pool is exhausted and the application goes down entirely.
Why this helps the LLM agent:
The agent does not see runtime and does not know that the upstream fails every N requests. A declarative contract makes the wrong version harder to write than the right one: with three annotations in place, skipping protection requires explicitly removing them.
fallbackMethodwith a concrete signature is the single place where degradation is handled.CircuitBreakerRegistryallows an integration test to transition the breaker toOPENstate without an actual failure and verify the fallback explicitly.
Usage guidelines:
Aspect application order per documentation:
Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(Function))))). Retry is the outermost decorator.In Spring Boot 3+, default
aspectOrdervalues are set such that@Retryis outside@CircuitBreakerin the undesirable sense: a single operation with multiple retry attempts is registered as multiple CircuitBreaker failures instead of one. This causes premature circuit breaker opening in production under load. The cause is the default numeric values:retryAspectOrder = Ordered.LOWEST_PRECEDENCE - 4,circuitBreakerAspectOrder = Ordered.LOWEST_PRECEDENCE - 3(seeRetryConfigurationPropertiesandCircuitBreakerConfigurationProperties, issue #2383, opened December 10, 2025, status open at time of publication).Always set aspect order explicitly in
application.yml. This producesCircuitBreaker(Retry(method))behavior: all retry attempts count as one operation from the CircuitBreaker’s perspective.For Spring Boot 4 + Spring Framework 7 where circuit breaker and fallback are not needed, consider the native
@Retryableand@ConcurrencyLimit(@EnableResilientMethodson the configuration class).@Retryablein Spring Framework 7 is a new annotation built into the core; it differs fromspring-retry, which is in maintenance mode: «The Spring Retry project is in maintenance mode for now without any further major/minor version plans. Everyone is encouraged to migrate to the retry API in Spring Framework». Resilience4j remains the preferred choice when a circuit breaker, bulkhead, rate limiter, time limiter, or detailed Micrometer metrics are needed.Record in
CLAUDE.md: «any call to an external HTTP client or queue is annotated at minimum with@Retry+@CircuitBreakerwithfallbackMethod;try/catchreturningnull/Optional.empty()in this layer is forbidden».Write a test using
transitionToOpenState(), otherwise the correctness offallbackMethodis not verified before production.
// Bad — try/catch without a contract:
// no circuit breaker, no retry limits, no metrics
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentClient client;
public PaymentResult charge(Order order) {
try {
return client.charge(order);
} catch (Exception e) {
log.error("Payment failed", e);
return null; // caller has no idea what happened
}
}
}
// Good — declarative contract: retry → circuit breaker → fallback
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentClient client;
// Aspect order set explicitly in application.yml:
// circuitBreakerAspectOrder: 1, retryAspectOrder: 2
// Resulting wrapper: CircuitBreaker(Retry(method))
@Retry(name = "paymentService", fallbackMethod = "fallbackCharge")
@CircuitBreaker(name = "paymentService", fallbackMethod = "fallbackCharge")
public PaymentResult charge(Order order) {
return client.charge(order);
}
// Signature: same parameters + Throwable last, same return type.
// The compiler does NOT check this: a mismatch causes a runtime NoSuchMethodException.
// This method MUST be covered by a test using transitionToOpenState().
private PaymentResult fallbackCharge(Order order, Throwable ex) {
log.warn("Payment service unavailable, degrading gracefully: {}", ex.getMessage());
return PaymentResult.deferred(order.id());
}
}
// Test: verify the fallback without an actual failure
@SpringBootTest
class PaymentServiceResilienceTest {
@Autowired PaymentService paymentService;
@Autowired CircuitBreakerRegistry circuitBreakerRegistry;
@Test
void fallbackCalledWhenCircuitOpen() {
circuitBreakerRegistry
.circuitBreaker("paymentService")
.transitionToOpenState();
PaymentResult result = paymentService.charge(
new Order(UUID.randomUUID(), BigDecimal.TEN));
assertThat(result.status()).isEqualTo(PaymentStatus.DEFERRED);
}
@AfterEach
void resetCircuit() {
circuitBreakerRegistry
.circuitBreaker("paymentService")
.transitionToClosedState();
}
}
Maven build organization for agent-driven development
Build system errors are the last to surface: not in a test or on startup, but in CI or already in production.
The agent treats pom.xml as a text file. It cannot run the build, has no view of the transitive dependency graph, and has no way to know which profile is active or which versions a BOM already manages. This is where most problems start, and it mirrors exactly how a new developer approaches an unfamiliar build.
Pinned dependency versions
When adding a dependency, the agent almost always specifies an explicit <version>, because that is what it has seen in training data examples. If the project uses a BOM (Spring Boot, Spring Cloud, Jackson, AWS SDK), an explicit version in a child module overrides the managed one, producing a discrepancy that Maven silently accepts. The problem does not manifest at compilation. Incompatible versions surface as NoSuchMethodError at runtime.
Rule: if a BOM is imported, <version> in <dependency> is unnecessary and harmful. List the active BOM coordinates in CLAUDE.md.
<!-- Bad: explicit version overrides the Spring Boot BOM -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.0</version> <!-- BOM already manages this version -->
</dependency>
<!-- Good: version managed through the spring-boot-dependencies BOM -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
Blindness to transitive dependencies
The agent adds a dependency without knowing it is already present transitively, possibly at a different version. Maven selects the «nearest» version in the dependency tree. This divergence does not produce a compilation error. It surfaces as NoSuchMethodError at runtime, often after deployment.
maven-enforcer-plugin with the dependencyConvergence rule converts silent version divergence into a build error.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<id>enforce</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<dependencyConvergence/>
<requireUpperBoundDeps/>
<banDuplicatePomDependencyVersions/>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Incorrect dependency scope
The agent defaults to compile, Maven’s implicit scope, which appears in most examples. The result: test frameworks end up in the production JAR, increasing its size and attack surface.
Servlet API (
jakarta.servlet-api) —provided; the container supplies it, and duplication breaks class loading.JDBC driver —
runtime; not imported in code but must be available at runtime.JUnit, Mockito, AssertJ —
test; test frameworks in the production JAR expand the vulnerability surface.
Record the scope policy in CLAUDE.md as an explicit list. mvn dependency:analyze identifies existing violations.
Adding a dependency to a child module instead of the parent
In a multi-module project, the agent opens the first pom.xml it finds and adds the dependency there. If the same dependency is needed by another module, it will appear again, possibly at a different version or without one. After a few iterations, versions diverge across modules without the compiler noticing.
Rule: versions belong only in <dependencyManagement> of the parent POM. Child modules declare <dependency> without <version>. The same applies to plugins: <pluginManagement> in the parent, <plugin> without <version> in the module.
Add to CLAUDE.md: «when adding a new dependency, first check whether its version is declared in <dependencyManagement> of the parent POM; if not, add it there».
Blindness to Maven profiles
The agent sees profiles in pom.xml but does not know which one is currently active. The typical error is adding a dependency to the prod profile while the developer works with dev, and the problem is discovered only when deploying to the target environment.
If the project has non-trivial profiles, document their semantics in CLAUDE.md: which are active by default, which require explicit activation, and which dependencies each contains. A separate trap: a new profile with <activeByDefault>true</activeByDefault> silently deactivates an existing one: Maven deactivates it as soon as any other profile is activated.
Ignoring Maven wrapper
The agent may invoke the system mvn instead of ./mvnw, picking up a different Maven version, possibly without the required extensions and without settings.xml from .mvn/. Build reproducibility is silently broken: everything works locally, and CI may fail for non-obvious reasons.
One line in CLAUDE.md: «always use ./mvnw, never mvn».
Hallucinated dependency coordinates
This is a distinct class of problems, not directly related to build structure, but manifesting through pom.xml. The agent may propose a non-existent version, a non-existent artifact with a plausible name (commons-utils instead of commons-lang3), or an actually existing malicious package («slopsquatting»). The last scenario is particularly dangerous: the build succeeds, the code compiles, and the dependency carries malicious code.
The Sonatype State of the Software Supply Chain 2026 report (first published December 9, 2025; re-syndicated by GlobeNewswire on January 28, 2026) analyzed approximately 37,000 component version update recommendations generated by GPT-5: in 27.8% of cases, non-existent component versions were proposed, and when working without real-time grounding, actually existing malicious packages were also recommended.
An independent academic study (Spracklen et al., USENIX Security 2025, «We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs», arXiv:2406.10279) generated 576,000 samples using 16 LLMs and found: the average rate of hallucinated package names ranged from 5.2% for commercial models to 21.7% for open-source models, with many hallucinations being stable and potentially exploitable.
Practical protection: proxy Maven Central through a repository manager (Nexus, Artifactory) with a whitelist of permitted groups, or use tools grounded on real Maven Central metadata.
Build contract in CLAUDE.md
All of these problems have a common fix: a written build contract the agent loads at the start of every session. CLAUDE.md is the standard Claude Code mechanism for this: the file loads automatically when the agent starts, from the project root.
## Build contract
- Always use `./mvnw`, never `mvn`
- Run `./mvnw help:effective-pom` before editing any pom.xml
- Run `./mvnw -B dependency:tree` after adding any dependency
- Run `./mvnw -B verify` to confirm the build passes
## Active BOMs (do not add <version> for these coordinates)
- org.springframework.boot:spring-boot-dependencies:3.4.x
- org.springframework.cloud:spring-cloud-dependencies:2024.0.x
- com.fasterxml.jackson:jackson-bom (managed transitively)
## Scope policy
- jakarta.servlet-api, jakarta.ws.rs-api → provided
- *-jdbc-driver, *-driver → runtime
- junit-*, mockito-*, assertj-* → test
## Multi-module rule
- Versions go in parent <dependencyManagement> only
- Child modules declare <dependency> without <version>
## Active profiles
- default: no explicit profile activation
- ci: activated via -Pci in pipeline only
Note |
|
When the agent will still make mistakes
Even a project implementing all the patterns in this document is not immune to certain scenarios. Knowing where the limits are helps you allocate effort correctly.
Summary
The central thesis has not changed since Part 1: agent-friendly code is code where a bug is either immediately visible to the compiler or instantly reproducible in a test. Every pattern here follows from this.
The agent reads code but cannot run it. It cannot see SQL traffic, transaction state, or what the connection pool looks like under load. What the source file says at that moment is all it has. Every pattern in this article does the same thing: moves information from «you’d need to run the application to know this» into «readable from the code».
The cheapest protection is compile-time: @CheckReturnValue, @DoNotCall, @Immutable, AggregateReference turn whole categories of mistakes into build errors. The persistence layer is the hardest to get right. N+1 queries, lazy loading, dirty checking, cascade propagation are all invisible at edit time, and each is a trap the agent will fall into. Declarative HTTP and queue interfaces keep the external system contract in one file, which is exactly where the agent will look. Async is where things look fine until they aren’t: @Async without @EnableAsync, @Retry without a test against transitionToOpenState(): both look protected, neither is. For the build system, the agent only sees pom.xml; CLAUDE.md and maven-enforcer-plugin have to do the rest.
The junior developer analogy still applies: both fall into the same traps for the same reason. They lack context about what happens beyond the current file. A junior developer builds that context over time; the agent starts fresh every session. Document both «how the code is structured» and «why» in CLAUDE.md, in comments, in test names. Write it for the agent; new teammates and your future self will use it too.
In the next article, Agent Development and Logging, we'll discuss how the application execution log and Git log can serve as a valuable source of information for the agent.




