Java patterns and anti-patterns for agent-driven development, part 4

This is the last article in the «Java patterns and anti-patterns for agent-driven development» series.
It collects what did not fit into the earlier parts, drawn from three areas:
dependency handling (constructor injection,
ObjectProvider<T>, explicit configuration in place of scattered@Value);architectural techniques that keep an agent’s change from spreading beyond one class (module boundaries, sealed types for API contracts, versioning through
@Deprecated);testability practices that improve the quality of the agent’s feedback loop (mutation testing, contract testing, test slices).
I picked patterns here by one criterion: they matter for agent-driven development and did not fit earlier. So this article is less systematic than the other three.
Dependencies
Constructor injection
Anti-pattern: dependencies get injected via @Autowired on fields or obtained through ApplicationContext.getBean() / Service Locator.
Implementation:
Declare all dependencies as
finalfields and pass them through the constructor.One constructor is enough for Spring applications;
@Autowiredis not needed.
Why this matters for the agent: this is an old engineering practice, and explicit dependencies and testability pay off whoever writes the code. The extra win for agents: every dependency of a class sits in one place, so there is no need to start the Spring context or read neighbouring files to figure out what the class needs. You can build the class in a unit test with no Spring context, so the agent can write a quick test without @SpringBootTest.
Usage guidelines:
If the constructor takes more than 4–5 parameters, that is often (though not always) a sign the class does too much. In orchestration or use-case classes, 5–6 dependencies are sometimes fine, especially when they are narrow ports rather than fused responsibilities; do not split the class mechanically just to hit a parameter-count target.
Configuration values belong in
@ConfigurationPropertiesbeans, not mixed in with infrastructural dependencies.
ObjectProvider<T> for optional dependencies
Anti-pattern: the code declares an optional dependency as a field with @Autowired(required = false). Optionality hides in the annotation attribute rather than in the field type, and when an agent accesses the field directly, it cannot see from the signature that the value may be null. The problem is not field injection as such. The same risk applies to any dependency of type T that may in practice be absent but carries no indication of optionality in the type itself. The danger is hidden nullability the type does not express, not the specific injection style.
Implementation:
Declare optional dependencies as
ObjectProvider<T>and access them through.getIfAvailable()or.ifAvailable(consumer).ObjectProvider<T>is a standard Spring interface available since version 4.3; it requires no extra dependencies.
Why this matters for the agent: ObjectProvider<T> moves optionality into the type instead of hiding it in an annotation attribute. The agent then has to handle bean absence. .getIfAvailable() returns null, and .ifAvailable() does not invoke the consumer. With @Autowired(required = false) the optionality is not part of the type. The signature does not show it, so the agent can access the field without checking whether the value is null.
Usage guidelines:
ObjectProvider<T>implementsIterable<T>, so you can iterate over all implementations of an interface without declaring aList<T>in the constructor..getIfAvailable()/.ifAvailable()only address bean absence, not ambiguity between multiple candidates. With two or more matching beans and no@Primary/@Qualifier, resolution still fails withNoUniqueBeanDefinitionException. If the semantics "return the bean only when there is a single unambiguous candidate, otherwisenull" is acceptable, use.getIfUnique(). Unlike.getIfAvailable(), which returnsnullon absence but throws on ambiguity,.getIfUnique()returnsnullin both cases: on absence and on ambiguity. TreatingObjectProvider<T>as a universal "safe optional" for every resolution problem is a mistake the agent should avoid.Distinguish three cases explicitly rather than lumping them into one universal "for optional" solution via
ObjectProvider<T>:Optional dependency, resolved once at consumer creation. The dependency may be absent.
Optional<T>as the constructor parameter type is enough. Spring resolves it once, when the consumer bean is created, and the agent sees the optionality from the signature with no extra abstraction. This is not singleton semantics: if the target bean itself hasprototypescope, Spring still obtains exactly one prototype instance for it at consumer-creation time. The target does not become a singleton; resolution just happens once when the consumer is created rather than on every access.Lazy/repeated lookup. Resolution must be deferred until actual use or must repeat on every access: a conditional bean (
@ConditionalOnProperty,@ConditionalOnBean), a bean from a specific profile, or several candidate implementations. Here you needObjectProvider<T>, but this does not turn the container into a dynamic registry. The set of beans is fixed when the context starts, andObjectProvider<T>only defers the moment of access to a bean definition that is already defined (or absent).Prototype scope. Each call must return a new instance (see the separate pattern below). Also
ObjectProvider<T>:.getObject()creates a new instance on every call. For a conditional singleton bean (see the example in the@Conditionalbeans section below),.getObject()/.getIfAvailable()return the same instance every time; which of the two cases you have depends on the scope of the bean itself, not on whetherObjectProvider<T>is used.
Explicit @Bean registration instead of unbounded component scan
Anti-pattern: @ComponentScan with no limits across the whole root package of the application. Every class marked @Component, @Service, or @Repository becomes a bean. The wiring graph is not gathered in one configuration layer. Registration depends on the component-scan boundary and on annotations scattered across classes, rather than on one place you can read as a whole.
Implementation:
In library and infrastructure modules, register beans explicitly through
@Configuration+@Bean.Constrain
@ComponentScanwithbasePackagesorbasePackageClasses; do not scan from too broad a package (for example, the application root). PreferbasePackageClasses: a string-valuedbasePackagesis not checked by the compiler and continues to resolve silently to nothing after a package is renamed or moved, whereasbasePackageClassesis tied to a real class and breaks compilation if that class is removed.For the application module (entry point), the standard
@SpringBootApplicationscan is acceptable within one bounded context; infrastructure often already lives inside the root package there, and that is fine. Explicit registration matters most in library and infrastructure modules with auto-configuration: they are pulled into different applications and should not depend on how the consumer configured its component scan.
Why this matters for the agent: with explicit registration, the bean-creation graph is written out as code. The agent can see which dependencies go into the constructor and which profile or condition governs bean creation. A broad component scan (from the application root) makes wiring implicit and blocks local reading of the dependency graph. Without starting the context or searching for annotations across the whole project, the agent cannot tell whether a bean is registered, and often hallucinates code when it generates new components.
Usage guidelines:
One
@Configurationclass per logical group of beans (persistence, messaging, security).Name
@Beanmethods after the returned type or role, not the implementation:clockSource(), notsystemClock(). The agent orients on the method name when it searches for a bean.When migrating from component scan to explicit registration, start with infrastructure modules. They change less often and have clearer boundaries.
Feature flags as an explicit dependency
Anti-pattern: a feature flag check goes through System.getenv("FEATURE_X") directly inside business logic, and the set of flags is nowhere pinned explicitly.
Implementation:
Define a
FeatureFlagsinterface with explicit methods for each flag.Inject it through the constructor; substitute a stub implementation in tests.
Why this matters for the agent: the full list of features sits in one file, so the agent does not have to grep for string constants. Adding a new flag changes the interface contract, and the compiler catches that in every implementation.
Usage guidelines:
In the production implementation,
FeatureFlagsreads the source (environment, database, LaunchDarkly); the use case knows nothing about the source.Name methods by business meaning:
isNewInvoicingEnabled(), notisFeature42Enabled().The
FeatureFlagsinterface hides the flag source but should not hide the consistency model. The flag may be a snapshot taken at application start, a per-request decision, or a dynamic remote flag (LaunchDarkly and similar). If the flag in fact depends on tenant, user, or request context, that should be visible either in the method signature (for example,isNewInvoicingEnabled(TenantId tenant)) or explicitly in the Javadoc when the signature stays parameterless. OtherwiseisNewInvoicingEnabled()looks like a globalboolean, though in a real system it may be a per-tenant decision, and an agent calling the method without parameters cannot detect the hidden context dependency.
@Conditional beans: explicit documentation of conditions
Anti-pattern: a bean is marked @ConditionalOnProperty, @ConditionalOnBean, or with a custom @Conditional without a Javadoc comment. The consumer injects it as a required dependency, unaware that the bean may be absent under some configurations.
Implementation:
Every conditional bean has a Javadoc comment that states the condition, the default value, and what happens when the bean is absent.
Consumers of conditional beans inject them through
ObjectProvider<T>(see the section above) rather than directly.For complex conditions, define a named composite annotation instead of a stack of
@ConditionalOn*on the bean itself.
Why this matters for the agent: the agent does not start the Spring context during code generation. When it sees @ConditionalOnProperty("feature.audit.enabled") without a comment, it has no way to know whether auditing is on in the target environment. It injects AuditService as a final field and then hits NoSuchBeanDefinitionException at runtime. Javadoc plus ObjectProvider<T> on the consumer side make the conditionality visible in the source.
Usage guidelines:
Named composite annotations like
@ConditionalOnAuditEnabledbeat bare@ConditionalOnProperty: the agent sees intent, not configuration details.When the bean is absent, provide a no-op default implementation (
AuditService.NOOP), registered unconditionally. This variant works well in auto-configuration or in explicitly ordered configuration, wherever condition-processing order is predictable (see the next point). Then no-op is preferable toObjectProvider<T>: the consumer always receives a valid dependency.- Exception: if the absence of the component is itself a meaningful business or configuration fact (for example, auditing is mandatory in prod and its absence means a configuration error rather than a supported mode), a no-op can silently mask that error. In that case an explicit
ObjectProvider<T>check or fail-fast at startup is a better fit than a no-op.
- Exception: if the absence of the component is itself a meaningful business or configuration fact (for example, auditing is mandatory in prod and its absence means a configuration error rather than a supported mode), a no-op can silently mask that error. In that case an explicit
When using the no-op default, place the
@ConditionalOnMissingBeanbean in a separate@Configurationclass with lower priority (@AutoConfigureAfteror anAutoConfigurationmodule); otherwise the order of condition processing is not guaranteed.@ConditionalOnMissingBeanworks most reliably inside auto-configuration, where Spring Boot manages ordering through@AutoConfigureOrder/@AutoConfigureAfter. In ordinary application code, the processing order of conditional@Configurationclasses is less predictable, and it is usually simpler to write two mutually exclusive conditions (havingValue = "true"/havingValue = "false", matchIfMissing = true) than to rely on@ConditionalOnMissingBean.
Example implementation
// Bad: the condition is documented nowhere, the consumer does not know about optionality;
// havingValue is not set — by default the condition matches on any property value
// other than "false" (so "foo", "yes", "0" also enable the bean, not only "true")
@Bean
@ConditionalOnProperty("feature.audit.enabled")
public AuditService auditService(AuditRepository repository) {
return new DefaultAuditService(repository);
}
@Service
class InvoiceService {
private final AuditService auditService; // NoSuchBeanDefinitionException at context start if the flag is off
...
}
// Good — option A: no-op default, consumer is unaware of conditionality.
// havingValue = "true" is set deliberately: audit is enabled only on explicit "true"
// rather than on any value other than "false", as in the anti-pattern above
/**
* Registered only when {@code feature.audit.enabled=true}. For any other value,
* or when the property is absent, {@link #noOpAuditService()} is used instead.
*/
@Bean
@ConditionalOnProperty(value = "feature.audit.enabled", havingValue = "true")
public AuditService auditService(AuditRepository repository) {
return new DefaultAuditService(repository);
}
@Bean
@ConditionalOnMissingBean(AuditService.class)
public AuditService noOpAuditService() {
// In this compact example, noOpAuditService() sits next to auditService() for clarity.
// In production code, place it in a separate fallback / auto-configuration class
// with guaranteed processing order (@AutoConfigureAfter or an AutoConfiguration module,
// see "Usage guidelines" above) — not arbitrarily next to the main conditional bean:
// the order between two @Configuration classes in regular application code is not guaranteed.
return AuditService.NOOP; // implementation with empty methods
}
// Good — option B: ObjectProvider, when a no-op is semantically incorrect
@Service
class InvoiceService {
private final ObjectProvider<AuditService> auditServiceProvider;
InvoiceService(ObjectProvider<AuditService> auditServiceProvider) {
this.auditServiceProvider = auditServiceProvider;
}
public void createInvoice(InvoiceCommand cmd) {
auditServiceProvider.ifAvailable(a -> a.record(cmd));
}
}
@ConfigurationProperties record for module configuration
Anti-pattern: a spray of @Value("${…}"), manual string parsing, string property keys scattered across services, and a dependency on SpEL in application logic.
Implementation:
Gather all settings of one module into a single
@ConfigurationPropertiesrecord with constructor binding,@Validated, and typed fields (Duration,DataSize,URI,InetAddressinstead of rawStringandlong).The use case takes this record as a constructor dependency.
Do not scatter string property keys across services: it is easier for the agent to search and change one explicitly named configuration type than to reconstruct meaning from dozens of string keys.
Why this matters for the agent: every configuration parameter of a module lives in one file. A typo in the record field name fails compilation immediately. A typo in the external property key (for example, payment.gateway.url written as paymant.gateway.url) is not caught by the compiler. It fails to bind to any field and, without @NotNull, silently yields null or the default value rather than an error. @Validated plus mandatory fields catch that, not the use of a record per se. A change touches fewer places, and part of the errors moves into bind or validation at application startup. @ConfigurationProperties does not support SpEL expressions (#{…}), unlike @Value, where accidental use is hard for the agent to spot in the source.
Usage guidelines:
One record per logical configuration area (payment gateway, cache, retry policy).
Set
@Validatedon the class explicitly: without it, Bean Validation annotations (@NotNull,@Positive, and so on) do not fire.For nested records/objects, a single
@Validatedat the top level is not enough: Spring Boot validates a@ConfigurationPropertiesclass when it is itself marked@Validated, but this does not propagate to nested objects automatically. To have nested configuration validated too, mark the nested field with@Valid(cascade validation).The record must be registered as a bean, through
@EnableConfigurationProperties(PaymentGatewayProperties.class)on a configuration class or through@ConfigurationPropertiesScanat the application level.For very small standalone values,
@Valuemay be faster to inject, but as the project grows this approach almost always loses. If a full migration is heavy, start with external integrations like timeouts, base URLs, and limits.
@PreDestroy for resource-holding classes; AutoCloseable/Closeable when needed
Anti-pattern: a class holds a resource (a connection, an ExecutorService, a file descriptor, an HTTP client with a connection pool) and declares no explicit release method. Spring closes the context; the resource stays open.
Implementation:
For your own resource-holding classes registered through
@Component/@Service(component scan), keep an explicit lifecycle release method inside the class itself and annotate it with@PreDestroy. Spring does nothing automatic for such classes: it does not look for or callclose()/shutdown()on its own.If the object also needs to be used outside Spring via
try-with-resources(in tests, CLI utilities), implementAutoCloseableorCloseable. Otherwise a@PreDestroymethod with a descriptive name (shutdown(),release()) is enough, without implementing any interface.For third-party resources wrapped by a
@Beanmethod in a@Configurationclass, Spring by default finds and calls the publicclose()/shutdown()on the returned object (inferred destroy method), so you can lean on this and skip writing@PreDestroymanually. SpecifydestroyMethodexplicitly only if the method name differs fromclose/shutdown, or if you deliberately want the lifecycle to be visible in the configuration class.
Why this matters for the agent: agents generate resource-initialisation code far more often than resource-release code. A lifecycle release method, whether a @PreDestroy method with a descriptive name or an implementation of AutoCloseable/Closeable, puts the release step into the class contract. That tells the agent the resource needs release and points it at the right lifecycle hook. A missing @PreDestroy causes leaks that only surface under load or when the service is redeployed.
Usage guidelines:
@PreDestroydoes not work on beans withscope = "prototype". Spring does not manage the destruction of prototype beans. For such classes, resource release remains the explicit responsibility of the consumer.ExecutorServicealways requires an explicitshutdown()orshutdownNow()in@PreDestroy. Without it, the JVM will not exit cleanly when non-daemon threads exist.The inferred destroy method applies specifically to
@Beanmethods in@Configurationclasses (see "Implementation" above) and does not extend to classes registered through component scan (@Component,@Service); those need an explicit@PreDestroy, as in the example below.Closeable#close()is declared to throwIOException, which is appropriate when the resource actually wraps I/O (a file, a socket, an HTTP client with a connection pool). For business and infrastructure components that throw nothing (likeReportExporterbelow),AutoCloseable(a wider contract, without a mandatoryIOException) is usually more natural, or simply a@PreDestroy void shutdown()method without implementingCloseable/AutoCloseableat all. ImplementCloseablespecifically when the object needstry-with-resourcesoutside the Spring context.
Example implementation
// Bad
@Service
class ReportExporter {
private final ExecutorService executor = Executors.newFixedThreadPool(4);
// executor is never shut down — thread leak on restart
}
// Good — ReportExporter throws nothing, so AutoCloseable is enough here
// (no mandatory IOException); Closeable is only needed when the object requires
// try-with-resources outside the Spring context — see "Implementation" above
@Service
class ReportExporter implements AutoCloseable {
private final ExecutorService executor = Executors.newFixedThreadPool(4);
// business methods...
@Override
@PreDestroy
public void close() {
executor.shutdown();
try {
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
ObjectProvider<T> for @Scope("prototype") and @Lazy beans
Anti-pattern: a prototype bean is injected into a singleton through an ordinary constructor, so Spring creates it once at singleton initialisation and reuses the same instance. Code uses @Lazy without understanding the consequences. The agent overlooks that Spring defers creation of the target instance (or access to it) until first real use. The bean definition itself already exists in the context; only the object has not been created yet.
Implementation:
Access prototype beans through
ObjectProvider<T>; each.getObject()call creates a new instance.Use
@Lazyon a dependency only deliberately; document the reason in Javadoc.An alternative for prototype is a
@Lookupmethod: Spring overrides it in a subclass and returns a new instance from the context.
Why this matters for the agent: injecting a prototype bean into a singleton is an old mistake agents reproduce often, because syntactically it looks identical to correct injection. ObjectProvider<T> names the "new instance every time" behaviour right in the field type. @Lazy without documentation is hidden state, so the agent has no way to tell why it is there or whether it is safe to remove.
Usage guidelines:
@Scope("prototype")on a value bean (a stateful object with request data) is the typical case forObjectProvider<T>.@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)is another way to solve the singleton-consumer problem, with different semantics. Spring wraps the dependency in a CGLIB proxy, and each external method call through this proxy pulls a new prototype instance from the context rather than reusing the previous one (this holds for external calls through the proxy; self-invocation inside the already-created target object does not switch to a new instance, a detail not critical for basic understanding of the pattern, but worth knowing). The downside: the moment a new instance is created is hidden inside the method call rather than written as a separate operation in the code. If the consumer calls several methods on the proxy in a row as one logical operation, each call may land on a different instance, which is non-obvious and easy to miss during code review.ObjectProvider<T>is more explicit here. The moment of obtaining the instance (.getObject()) is visible in the code, so it is preferable for the agent. Also,TARGET_CLASSrequires a non-finalclass and methods (CGLIB proxy); for interface-based proxies useScopedProxyMode.INTERFACES.If a new instance per request is needed in a web application, consider
@RequestScopeinstead of manual prototype.
Architecture
Patterns in this group keep an agent-driven change from spreading beyond one class. A typical change touches one class and does not spill over onto its neighbours.
Package-private helpers
Anti-pattern: helper classes carry the public modifier "just in case", though they are used only within their package.
Implementation:
- Declare helper classes not intended for external use without a modifier (
package-private).
Why this matters for the agent: the agent reads public as "intended for use" and package-private as "internal detail", so it is less likely to skip past the public entry point and grab an internal helper directly.
Usage guidelines:
In multi-module projects, use
module-info.javawith explicitexportsdirectives (Java Platform Module System) to enforce encapsulation at the module level.Package-private only protects within one package. If a package holds classes from mixed use cases, protection is weak, because the whole package is exposed, not just the intended helper. The pattern works together with package-structure discipline (one package per use case/module), not as a substitute for it.
Interface Segregation as a signal to the agent
Anti-pattern: one widely used interface with 10+ methods (InvoiceRepository with save, findById, findAll, delete, countByStatus, existsByCustomer, and so on) is injected into every use case, even those that in fact need one or two methods.
Implementation:
Split the wide interface into narrow, role-based ones:
InvoiceReader(read-only),InvoiceWriter(write-only), instead of a singleInvoiceRepositorycontaining all operations.The use case depends only on the interface it actually uses.
One infrastructure class can implement both narrow interfaces; the split concerns the contract, not the implementation.
Why this matters for the agent: a wide interface does not tell the agent which of the 10 methods a given use case needs. The whole set is visible, and the agent may call an unrelated method, or miss that the method it needs already exists under a different name. A narrow interface shrinks what the agent has to hold in the task context and cuts the odds of reaching for irrelevant operations.
Usage guidelines:
Splitting is appropriate when the interface has grown large in practice and different use cases consume non-overlapping subsets of methods. For a small repository with 3–4 methods around one entity that expresses one coherent role, splitting is unnecessary; see Ports as interfaces. A repository-as-port is fine on its own; split it when it grows and starts to blend several roles.
Split by use case, not mechanically by the CRUD scheme:
InvoiceReader/InvoiceWriteris an example; the specific roles depend on the domain.Over-fine interfaces hurt the agent as much as over-wide ones. With one port per action, the agent has to search for and keep more types in context. Aim for a role (
InvoiceReader,InvoiceWriter), not "one method per interface".
Ports as interfaces
Anti-pattern: business logic directly calls HTTP clients, JPA repositories, or Kafka producers inside a use case or domain service.
Implementation:
Define a port interface in the domain/application layer:
InvoiceRepository,PaymentGateway,DomainEventPublisher.The infrastructure implementation lives in a separate module and depends on domain, not the other way round.
Why this matters for the agent: this is the Hexagonal/Clean Architecture principle, useful whether a human or an agent is writing the code. Side effects live behind an interface, so the agent can swap the implementation for a mock without changing business logic and without reading infrastructure code. An ArchUnit test keeps the rule in force.
Usage guidelines:
Name the port by its role in the domain, not by technology:
InvoiceRepository, notJpaInvoiceDao.One port per responsibility.
Explicit mapper
Anti-pattern: conversions between layers happen through reflection or BeanUtils.copyProperties.
Implementation:
For each pair of layers, introduce an explicit mapper class with named methods:
toDto,toDomain,toCommand.Use MapStruct for code generation (Java records supported since 1.4.0) or write the mapper by hand.
Why this matters for the agent: the mapping is written out in code, so the agent reads the mapper and sees which fields go where, with no reflection magic. The compiler usually catches structural errors like type mismatches and method-name typos. A semantic error, such as a forgotten field in a hand-written mapper when the DTO allows null or has a default, is not something the compiler catches. It shows up in review or in a test (a golden-master or snapshot serialization test, say) rather than as a compile-time error on its own. For MapStruct this is closer to a guarantee, but only under strict configuration like unmappedTargetPolicy = ReportingPolicy.ERROR.
Usage guidelines:
The mapper must be stateless.
Do not use the mapper for validation or for enrichment with data from the database.
Cross-module calls via ApplicationEventPublisher
Anti-pattern: an orchestration service pulls in every neighbouring module and creates functional gravity. Module A calls ServiceB.doSomething() from module B directly, then ServiceC.doSomethingElse(), and over time turns into a god object.
The Spring Modulith documentation illustrates the effect of a direct cross-module call through the complete(…) method from a tutorial scenario (section "Working with Application Events"): "The complete(…) method creates functional gravity in the sense that it attracts related functionality and thus interaction with Spring beans defined in other application modules". That is a specific example, not a blanket "use only events" statement, but the underlying effect holds. Direct cross-module calls pull logic that should be spread across modules into one service. Spring Modulith recommends events as the default way to decouple module interactions.
Implementation:
For cross-module reactions, publish domain/application events and handle them with listeners; do not drag dependencies on other modules' application services into every use case.
Do not conflate the two delivery semantics. The pattern works with both, but the choice directly affects idempotency and delivery guarantees:
Ordinary Spring event /
@EventListener. Synchronous local decoupling inside a process, usually on the same thread and in the same transaction.ApplicationEventPublisher.publishEvent(…)is synchronous by default (unless an asynchronousApplicationEventMulticaster/executor is configured), so the listener runs on the current thread and inside the same transaction as the caller. "Event-ness" here is about who should not know about whom (decoupling), not about asynchrony; it is simply a local decoupling boundary within one process.Spring Modulith
@ApplicationModuleListener. This is not an ordinary synchronous listener but a composite annotation equivalent to@Async+@Transactional(propagation = Propagation.REQUIRES_NEW)+@TransactionalEventListener(phase = AFTER_COMMIT). The handler runs asynchronously, after the publishing code’s transaction commits, in a new separate transaction (REQUIRES_NEW), not "in the same" one and not without a transaction at all. So this is eventual consistency, and idempotency is required. Its failure does not roll back the publishing transaction, which has already committed. Re-invocation of the handler is not an automatic consequence of the annotation itself. It happens when Spring Modulith’s event publication registry / republication mechanism is used (a publication log plus redelivery of undelivered or failed events), a separate part of the delivery model, not something@ApplicationModuleListenergives you by itself. But because such retries are possible in the overall architecture, the handler must be idempotent from the start.
The receiving module, when it integrates across a Spring Modulith boundary, subscribes through
@ApplicationModuleListenerand must account for its async-after-commit semantics. For plain synchronous decoupling inside a single module or process, an ordinary@EventListeneris enough.Running an ordinary
@EventListenerasynchronously is a separate architectural decision (@Asyncon the listener, extraction into a message broker), with its own delivery guarantees.@ApplicationModuleListenergives this mode out of the box, which is why it requires the handler’s idempotency to be considered from the start.Verify the module structure with a test:
ApplicationModules.of(Application.class).verify().
Why this matters for the agent: an event is a more readable boundary of intent. "When an order is completed, OrderCompleted is published" is easier to find by grep and easier to cover with a scenario test, and changes stay local. When the agent adds logic to one module, it cannot accidentally create a dependency on another, because verify() breaks the build on violation. But the agent must know which delivery semantics it picked. If it drops @ApplicationModuleListener into a spot that expected synchronous processing inside the same transaction, or the reverse, uses a synchronous @EventListener where cross-module decoupling after commit is needed, the code compiles and even passes a naive test, but rollback, retry, and ordering will behave differently from what was assumed. Handler idempotency under async delivery is part of the cost of the pattern, and you have to plan for it up front.
Usage guidelines:
Name domain events in the past tense:
OrderCompleted,InvoiceCreated,PaymentProcessed.Direct calls are acceptable inside a single module; the ban applies only to cross-module ones.
If moving to
@ApplicationModuleListener(async, after-commit) feels risky, start with a synchronous@EventListenerinside one transaction plus event-level tests. Later, migrate to the async model with idempotent handlers, without changing the business logic that publishes the event.
Example implementation
// Bad — an orchestration service with direct dependencies on neighbouring modules
@Service
class OrderService {
private final OrderRepository repo;
private final InventoryService inventory; // dependency on another module
OrderService(OrderRepository repo, InventoryService inventory) {
this.repo = repo;
this.inventory = inventory;
}
@Transactional
void complete(Order order) {
repo.save(order.complete());
inventory.reserve(order.id()); // functional gravity
}
}
// Good — the module publishes an event, the rest react on their own
record OrderCompleted(OrderId orderId) {}
@Service
class OrderService {
private final OrderRepository repo;
private final ApplicationEventPublisher events;
OrderService(OrderRepository repo, ApplicationEventPublisher events) {
this.repo = repo;
this.events = events;
}
@Transactional
void complete(Order order) {
repo.save(order.complete());
events.publishEvent(new OrderCompleted(order.id()));
}
}
// In the Inventory module — a reaction without coupling to OrderService.
// @ApplicationModuleListener = @Async + @Transactional(propagation = REQUIRES_NEW)
// + @TransactionalEventListener(phase = AFTER_COMMIT): invoked AFTER the transaction
// of complete(...) commits, on a separate thread and in a NEW separate transaction —
// so reserve(...) must be idempotent: when Spring Modulith's event publication
// registry / republication mechanism is in use, the handler may be invoked again.
@Component
class InventoryEventListener {
private final InventoryPort inventoryPort;
InventoryEventListener(InventoryPort inventoryPort) {
this.inventoryPort = inventoryPort;
}
@ApplicationModuleListener
void on(OrderCompleted event) {
inventoryPort.reserve(event.orderId()); // idempotent operation
}
}
Explicit state transition method
Anti-pattern: a service calls invoice.setStatus(CANCELLED) directly through a public setter and skips the transition’s business rules.
Implementation:
Declare state transition methods on the entity itself:
invoice.approve(),invoice.cancel(reason).The method checks whether the transition is allowed and throws a domain exception on violation.
Why this matters for the agent: the object’s lifecycle lives in one class and can be read locally. Through the entity’s public API you cannot move it into an invalid state. This holds for ordinary code invocation only, and does not cover reflection, direct ORM field access, or a package-private setter.
Narrow public methods
Anti-pattern: public methods take many nullable parameters and behave differently depending on their combination.
Implementation:
Each public method solves one task with the minimally required set of parameters.
Instead of many parameters, pass a command record.
Usage guidelines:
If the method takes more than 2–3 parameters, replace them with a command record.
nullas a parameter is a cue for refactoring.One method name (
handle) shared by several overloads is compact but less grep-friendly for the agent: a search by method name returns both use cases at once. If that is a problem, name methods by action (create(…),cancel(…)) or split them into separate use-case classes (CreateInvoiceUseCase.handle(…),CancelInvoiceUseCase.handle(…)). A command record as a parameter type matters more than the overloading.
Sealed request/response with @JsonTypeInfo / @JsonSubTypes for polymorphic APIs
Anti-pattern: the code models a polymorphic request or response body as Map<String, Object>, a "fat" DTO with all possible fields of all variants (with null in unused ones), or a string discriminator field disconnected from the type. Forgetting to register a new subtype surfaces as a Jackson runtime error (Could not resolve subtype) after deployment.
Implementation:
Declare the base type as a
sealed interface, with explicitpermitslisting all allowed variants, or with inferredpermits(when all implementing classes are declared as nested types of the same interface, as in the example below, the compiler infers the list itself).Each variant is a separate
recordimplementing the base interface.On the base type set
@JsonTypeInfo(use = Id.NAME, property = "type")and@JsonSubTypeswith stable discriminator names.The controller accepts the base type; a
switchwith pattern matching handles all variants exhaustively, withoutdefault.
Why this matters for the agent: sealed interface fixes the variant space at the Java-code level. An agent adding a new variant must declare a record implementing the interface, and a forgotten branch in a switch on this type fails compilation through exhaustive pattern matching. The JSON contract is another matter. @JsonSubTypes is a separate registration the compiler does not check, and a forgotten entry there still yields a Jackson runtime error (Could not resolve subtype) on deserialization. So the code-level variant space and the JSON-level variant space are two lists that have to be kept in sync by hand. Serialization and deserialization tests (golden master or contract tests) catch divergence between them, not the compiler. The pattern extends "Sealed interface for state variants" from part 1 to API serialization.
Usage guidelines:
Fix discriminator values in an SDK-compatible form: kebab- or snake-case, stable, documented in OpenAPI.
For an extensible API on the consumer side,
defaultImpl = UnknownVariant.classis acceptable. On the provider side an unknown variant should return 400 Bad Request rather than be silently accepted.Serialization tests via golden master (see the section above) pin the contract at the JSON level.
Exhaustive
switchwithoutdefaulton a sealed type is pattern matching for switch, finalised in Java 21 (JEP 441). On earlier source levels the compiler or IDE may not recognise the switch as exhaustive and may insist on adefaultbranch. The pattern itself does not change conceptually, but state the minimum supported source level alongside the example.
Example implementation
// Bad — a fat DTO containing all fields of all operations;
// the compiler does not prevent passing Cancel without reason
public class InvoiceRequest {
private String operation; // "create" | "cancel" | "approve"
private CustomerId customerId; // needed only for create
private Money amount; // needed only for create
private String cancellationReason;// needed only for cancel
private UserId approvedBy; // needed only for approve
// ...
}
// Good — sealed + @JsonSubTypes, each variant is a record
@JsonTypeInfo(use = Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = InvoiceRequest.Create.class, name = "create"),
@JsonSubTypes.Type(value = InvoiceRequest.Cancel.class, name = "cancel"),
@JsonSubTypes.Type(value = InvoiceRequest.Approve.class, name = "approve")
})
public sealed interface InvoiceRequest {
record Create (CustomerId customerId, Money amount) implements InvoiceRequest {}
record Cancel (InvoiceId invoiceId, String reason) implements InvoiceRequest {}
record Approve(InvoiceId invoiceId, UserId approver) implements InvoiceRequest {}
}
@PostMapping("/invoices")
public InvoiceResult handle(@RequestBody InvoiceRequest request) {
// exhaustive: a new variant breaks compilation before release
return switch (request) {
case InvoiceRequest.Create c -> createUseCase.handle(c);
case InvoiceRequest.Cancel c -> cancelUseCase.handle(c);
case InvoiceRequest.Approve a -> approveUseCase.handle(a);
};
}
@Deprecated(since, forRemoval) as an API removal contract
Anti-pattern: deprecated methods, fields, and classes carry @Deprecated without attributes. No one knows when they were deprecated or whether they will be removed. When the agent runs into such an API, it cannot tell whether it is still safe to use or time to migrate.
Implementation:
Every
@Deprecatedfills both attributes:since = "3.2"for the version when deprecation began, andforRemoval = trueorfalse. The attributes are available starting from Java 9.The Javadoc contains an
@deprecatedblock naming the replacement: a specific new method or class.CHANGELOG.md(generated by semantic-release, see part 3) for each major version lists allforRemoval = trueelements. Removal happens in the next major.The compiler with
-Xlint:removalisolates use offorRemoval = trueinto a separate warning channel.-Werrorturns all compiler warnings into build errors, not only the removal channel. Enable it only when the warning baseline is clean (no unrelated warnings in the project) or in a dedicated CI/profile gate focused specifically on the removal check. Otherwise an unrelated warning elsewhere in the code will turn the build red without warning.Project-internal code must be free of
forRemoval = truewarnings. If your own service has not migrated, the agent will not see a "correct" example and will reproduce the deprecated variant.
Why this matters for the agent: agents reproduce the patterns they see in the code. A bare @Deprecated reads to the agent as "still fine to use". @Deprecated(since, forRemoval = true) together with -Xlint:removal turns further use of the deprecated API into a warning that carries a date and version, and under -Werror on a clean baseline (or in a dedicated gate) into a compilation error for that case. The @deprecated block with a named replacement gives the agent a migration recipe, so it does not have to read commit history.
Usage guidelines:
When a replacement exists, name it. The formula: "`@deprecated` since 3.2, use {@link NewApi#method} instead", not "will be removed later". When there is no replacement (the behaviour is deemed erroneous and is removed rather than moved to a new API), do not invent one. Write direct migration guidance instead: "drop the call", "switch to new workflow X", or "use external adapter Y".
Between
sinceand removal, at least one major version passes: a migration window for consumers.An ArchUnit rule: "code in
..production..must not depend onforRemoval = trueelements" turns "we forgot to migrate" into a failing test.
Example implementation
// Bad — the agent knows neither the version, nor the fate, nor the replacement
@Deprecated
public Invoice findById(Long id) { ... }
// Good — since, forRemoval, replacement in Javadoc
/**
* @deprecated since 3.2, use {@link #findById(InvoiceId)} with typed ID.
* Untyped Long-based overload will be removed in 4.0
* (see CHANGELOG.md and PAY-2317).
*/
@Deprecated(since = "3.2", forRemoval = true)
public Invoice findById(Long id) {
return findById(new InvoiceId(id));
}
public Invoice findById(InvoiceId id) { ... }
pom.xml: -Xlint:removal enables the removal-warnings channel
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-Xlint:removal</arg>
<!-- -Werror promotes ALL compiler warnings to errors, not only removal —
enable it on a clean warning baseline or move it to a dedicated CI/profile gate -->
<arg>-Werror</arg>
</compilerArgs>
</configuration>
</plugin>
Refaster templates as codified style conventions
Anti-pattern: style conventions like Optional.orElseThrow() in place of .get(), assertThat().isEqualTo() in place of assertEquals(), or List.of() in place of Arrays.asList() live in a code-style document or get debated in review. The agent does not read them, and every review spends time on the same fixes. These are conventions of a specific project or team, not a universal engineering truth. assertThat().isEqualTo() is not "objectively better" than assertEquals(). It is a style choice. What helps the agent is that the choice is codified and enforceable, whether or not one variant is intrinsically better.
Implementation:
Codify rules as Refaster templates: pairs of
@BeforeTemplate/@AfterTemplatedescribing the current and target form of the code. Refaster does not claim that the target form is objectively better. The project picks a form and pins it as a convention. Refaster is part of Error Prone.The Picnic error-prone-support project publishes template packs for JDK, AssertJ, Mockito, Guava, and Reactor: hundreds of rules ready to plug in.
Compiled Refaster templates run under a single Error Prone check named
Refaster, not as a separateBugCheckerper rule. Its severity is set with the-Xep:Refaster:ERRORflag (orWARN/OFF), like any other check. To select specific templates from the Picnic pack, use-XepOpt:Refaster:NamePattern=<regex>.Turn on automatic in-place fixing with
-XepPatchChecks:Refasterand-XepPatchLocation:IN_PLACE. The build then diagnoses and also rewrites files into@AfterTemplateform.
Why this matters for the agent: the agent does not read the style guide. It reproduces the form it sees in tests and neighbouring classes. A Refaster template running as a compiler rule turns a convention from a declarative "this is how we do it" into an imperative "the compiler requires this". Unlike checkstyle-style linters, Refaster knows both the current and target form and can rewrite code on its own. The signal works like @CheckReturnValue and NullAway from part 1: the error shows up at compile time, so the agent does not have to run the code to discover it. Picnic’s template packs cover common LLM regressions, so you do not need to write your own DSL from scratch.
Usage guidelines:
Start with the Picnic set: add it as an Error Prone dependency, watch the warnings in CI, and gradually promote them to
ERROR.Write your own templates only for rules specific to the project: replacements of internal API calls, encoding of domain invariants.
Refaster does not replace review; it strips out mechanical edits so the reviewer can focus on substantive questions.
Bulk application to legacy: set up a Maven profile (for example,
error-prone-patch) that turns on the auto-patch flags above; commit the result as onerefactor(codebase): apply refaster templatescommit (see Conventional Commits in part 3).
Example: a hand-written Refaster template
// Replaces .get() with .orElseThrow() on Optional.
// This is a style/cleanup rule, not a substitute for meaningful handling of an absent value:
// the no-arg orElseThrow() preserves runtime semantics close to .get() (the same
// NoSuchElementException) — the template only unifies the call form, without changing behaviour
// and without replacing business validation where the absence of a value deserves meaningful handling.
public final class OptionalGetTemplate {
@BeforeTemplate
<T> T before(Optional<T> optional) {
return optional.get();
}
@AfterTemplate
<T> T after(Optional<T> optional) {
return optional.orElseThrow();
}
}
// Was in the code:
Invoice invoice = invoiceRepository.findById(id).get();
// After compiler-driven rewrite:
Invoice invoice = invoiceRepository.findById(id).orElseThrow();
pom.xml: prebuilt rules from Picnic
<!-- This is only the template pack. Error Prone itself (annotationProcessorPaths in
maven-compiler-plugin) and the -Xep:Refaster:ERROR / -XepPatchChecks flags
from "Implementation" above are configured separately — this dependency alone
is not enough to actually enable the check. -->
<dependency>
<groupId>tech.picnic.error-prone-support</groupId>
<artifactId>error-prone-contrib</artifactId>
<version>${error-prone-support.version}</version>
<scope>provided</scope>
</dependency>
Testability
Patterns in this group help an agent write a fast, deterministic test for a specific scenario without booting the whole Spring context and without calling external systems directly, wherever that is avoidable.
A mental model of agent-driven development
A working mental model for agentic development: the human formulates intent and constraints, tests formalise the contract, and the agent searches for a change that makes the contract hold. That matches the workflow recommended in the public Claude Code documentation [^1]. The Claude Code guidance boils down to a few concrete habits: explore before you plan, plan before you code, use hooks for deterministic mandatory actions, treat the context window as a scarce resource. The agent does better work when it can verify itself.
The test carries most of the communication between human and agent. Yang et al. show that the quality of the agent-computer interface shapes how well the agent can navigate the repository, edit files, and run tests. [^2] The agent recovers hidden invariants from code and comments unreliably, but it responds well to short executable feedback.
Four kinds of uncertainty minimised by a good agent-facing test
From this model follows one rule: each test should minimise four kinds of uncertainty.
Context uncertainty
The test should be local and require minimal setup, so the agent does not have to model half the system to check one business rule. The fewer dependencies you load to reproduce a failure, the fewer tokens diagnosis costs.
Outcome uncertainty
The test should be deterministic. A flaky outcome destroys trust in the feedback channel. Research on flakiness shows that developers care most about losing trust in tests, and the agent has the same problem. [^3]
Diagnostic uncertainty
The failure message should point to the violated invariant, not to a stack trace. An agent that gets AssertionError: expected <true> but was <false> has to read the implementation; an agent that gets Invoice amount must be positive, got: -1.00 USD knows exactly what to fix.
Intent uncertainty
The test should express behaviour, not internal implementation. A test bound to private methods or a specific class structure overfits the agent to the current implementation and breaks under a refactor that did not change behaviour.
Clock as an explicit dependency
Anti-pattern: LocalDateTime.now(), Instant.now(), or Clock.systemDefaultZone() is called directly inside domain logic.
Implementation:
Inject
java.time.Clockthrough the constructor alongside the other dependencies.At the wiring layer, register
Clock.systemUTC()as a bean: explicit UTC, not the system default zone.The Oracle Javadoc warns explicitly that
Clock.systemDefaultZone()"hard codes a dependency to the default time-zone into your application" [^4], meaning it hard-wires a dependency on the system time zone, which differs between the developer machine, CI, and production.Error Prone contains a family of checks for time-handling errors (
JavaTimeDefaultTimeZone,DateChecker,MisusedDayOfYear,TimeUnitConversionChecker, and others); the density of rules shows how error-prone this area is in practice.
Why this matters for the agent: injecting Clock is a known practice for testability of time-dependent logic, useful independently of agents. For the agent it matters more. Temporal bugs are hard for the agent to localise. They depend on the environment, the hour, the TZ, and DST, and often do not reproduce in a test without special setup. That cuts both context uncertainty and outcome uncertainty. Clock turns time into an explicit dependency and makes the code deterministic. Scenarios like "invoice is overdue", "discount has expired", or "retry in 5 minutes" become checkable without Thread.sleep.
Usage guidelines:
Clockis the sole source of time in the domain. Do not mixInstant.now(clock)withLocalDate.now()without an explicit clock in the same class. Hold the distinction betweenInstant,LocalDateTime, andZonedDateTime.If a full migration is expensive, first extract "the current time" into one
TimeProviderbean and gradually replace directnow()calls.Clock.systemUTC()is a reliable technical default but not a universal source of "correct time" for business rules. The business date often depends on the customer’s time zone, the tenant, or a legal zone. A rule like "the invoice is overdue at the end of the local business day" needs aClocktied to the appropriate zone (or a separate domain notion of business date), rather than one global UTCClockfor the whole application.
@TestConfiguration instead of @MockBean
Here the focus is a reusable set of test doubles and a Spring context friendly to caching. Where the test’s configuration class should physically live so that it does not "leak" through component-scan is a separate question; see "`@TestConfiguration` as an explicit override mechanism" below.
Anti-pattern: each test class uses @MockBean to replace dependencies, so Spring recreates the ApplicationContext for each new set of @MockBean, which multiplies build time.
Implementation:
Extract test double configuration into a separate
@TestConfigurationclass.Reuse one Spring context across all tests with the same set of stubs.
Why this matters for the agent: when the agent adds a @MockBean, it may not know the cost. Each unique set creates a new context. A shared @TestConfiguration caps that cost. Tests then reuse one context rather than silently multiplying it.
Usage guidelines:
These are two tools for different situations. A shared fake/stub via
@TestConfigurationfits when several test classes use the same set of stubs and it pays to reuse the context.@MockitoBean(see the "`@TestConfiguration` as an explicit override mechanism" section below) fits when a single bean needs a point substitution in a specific slice or integration test without a dedicated configuration class. Newer Spring Boot versions deprecate@MockBeanin favour of@MockitoBean. This section still uses@MockBeanbecause that is the name most readers will already recognise. On a current Spring Boot version, replace it with@MockitoBean.@MockBeanis acceptable in isolated integration tests where context recreation is unavoidable.For unit tests use constructor injection and pass stubs directly. The Spring context is not needed at all.
Watch for state. If
@TestConfigurationregistersMockito.mock(…)as an ordinary@Beanrather than through@MockitoBean, Spring does not reset stubbing/verification between tests automatically. With a shared context, a mock configuration in one test can "leak" into the next. Either reset the mock in@BeforeEach, or (usually more reliable for agent-friendly tests) use a deterministic fake/stub instead of a shared mock.
Test data builders
Anti-pattern: tests build objects through a constructor that takes every field, so adding a new required field breaks every test.
Implementation:
Create a builder class for each test fixture with reasonable defaults.
In the test override only the fields relevant to that scenario.
Instantiate a new builder in each test (or in
@BeforeEach). Do not store it in astaticfield that mutates between tests. A shared mutable builder creates a state leak between tests, symmetric to the one avoided by resetting a shared mock in the@TestConfigurationsection above.
Why this matters for the agent: builders for test fixtures are a common answer to constructor bloat, and useful on their own. For the agent, they mean building a valid object in a single line without recalling every required field. When a new field is added to the record, only the builder changes, not every test, and the agent avoids hand-editing dozens of files for one schema change.
AssertJ soft assertions for composite objects
Anti-pattern: the test uses ordinary single assertions; the first failed check stops execution and hides the rest of the errors.
Implementation:
- For tests that verify a composite object, use
SoftAssertionsfrom AssertJ.
Why this matters for the agent: if the test report shows a single failed check, the agent applies a partial fix and then hits another, unrelated failure. Soft assertions show the full picture in one run.
Usage guidelines:
AssertJ is part of
spring-boot-starter-test, so no additional dependencies are needed.Use
assertSoftlyfor tests of several fields of one object. OrdinaryassertThatis enough for simple scenarios.Soft assertions are useful for checking one result across several fields or aspects, but not for merging several independent scenarios into one test. The failure report widens and the diagnostic value of each scenario drops. Put different scenarios into different tests, even if each of them uses
assertSoftlyinternally.
Parameterized tests
Anti-pattern: several almost identical test methods differ only in input data.
Implementation:
Use
@ParameterizedTestwith@MethodSourceor@CsvSource.@CsvSourceis convenient for simple scalar values but ill-suited fornulland complex objects. For those,@MethodSourceis more reliable.
Why this matters for the agent: the agent grows the scenario matrix by adding a row to the data source rather than copying a test method. All boundary cases live in one place, and mutation testing (see below) points at the rows that are still missing.
Golden master / snapshot for serialization
Anti-pattern: JSON/XML is emitted "as it comes out", with no pinned contract, and accidental changes go unnoticed.
Implementation:
The developer creates or updates the baseline through an explicit approve action, not automatically on the first test run, and commits it to the repository.
Subsequent runs compare against the baseline.
The ApprovalTests library (Maven:
com.approvaltests:approvaltests) supports JUnit 3/4/5 and TestNG; an alternative is Selfie (1.0 release in early 2024) with inline snapshots and automatic garbage collection for disk files.
Usage guidelines:
- Commit baseline files to the repository, because a contract change should be a deliberate action. In CI, the absence of a baseline or a mismatch against it should fail the test, not create or update the file automatically. Otherwise the agent can silently "legalise" accidentally-changed JSON as the new contract.
Eventually-asserted tests via Awaitility / Scenario
Anti-pattern: Thread.sleep, flaky delays guessed by feel, assertTimeoutPreemptively in code where ThreadLocal/transaction context matters.
Implementation:
For asynchronous and event-driven scenarios, express the expected state as a predicate and wait for it via Awaitility.
In modular Spring applications,
Scenariofrom Spring Modulith is also useful.Do not wait for a fixed time and do not use preemptive timeout in code that may depend on thread-local state.
JUnit warns that
assertTimeoutPreemptively()executes code in another thread, which may have undesirable side effects whenThreadLocalis in use. Spring is mentioned in JUnit’s Javadoc as an example. More precisely, the Spring TestContext binds a test-managed transaction to the current test thread. When preemptive timeout moves the callback into another thread, operations inside it may execute outside that transaction and not roll back with the test. The issue is not that@Transactional"does not work". The rollback covers something different from what actually changed in the database.A declarative alternative for limiting the execution time of the whole test is the
@Timeoutannotation on a method or class. In the default configuration,@Timeoutdoes not move test execution to a separate thread and does not disturbThreadLocalcontext. Formally, JUnit uses inferred thread mode, and this behaviour can be changed, explicitly viathreadModeon the annotation or globally via a configuration parameter. SettingthreadMode = Timeout.ThreadMode.SEPARATE_THREADexplicitly makes theThreadLocal/Spring test-managed-transaction risks equivalent to those ofassertTimeoutPreemptively, and theThreadLocalcontext will be disturbed.
Why this matters for the agent:
- The agent can maintain a test of the form "wait until the counter becomes 1" more easily than one that guesses an arbitrary 700 ms delay. Such a test supplies its own termination criterion.
Usage guidelines:
A poorly written eventual test can hide real latency/regression. Reasonable upper bounds and fail-fast predicates are needed.
The minimal step is to replace
sleepwith Awaitility; the next is to move toScenariofor cross-module event flows.
// Anti-pattern: assertTimeoutPreemptively runs the callback on a separate thread —
// Spring's test-managed transaction is bound to the original thread, so changes
// inside the callback may not join it and may not roll back with the test
@Test
void brokenWithSpring() {
assertTimeoutPreemptively(Duration.ofSeconds(3), () -> {
service.complete(orderId); // runs on another thread — outside the test-managed transaction
});
}
Test slices @WebMvcTest / @DataJpaTest instead of @SpringBootTest for everything
Anti-pattern: every test boots the full application context through @SpringBootTest. Tests are slow, the context cache grows out of control, tests time out in CI or produce false positives from unexpected beans in the context.
Implementation:
For testing the web layer use
@WebMvcTest(TargetController.class). It loads only the specified controller,MockMvc, and web infrastructure. When Spring Security is on the classpath, its filters are typically loaded too, but this depends on auto-configuration and specific setup, so turn them off or configure them explicitly as needed. Services and repositories are not booted. They are absent from the context, and for each such controller dependency you must declare@MockitoBeanyourself (Spring does not substitute them automatically).For testing the persistence layer use
@DataJpaTest. It boots JPA, an embedded database (or Testcontainers via@ServiceConnection), and wraps each test in@Transactionalwith automatic rollback. No services or controllers.Spring Boot supports a context cache. Two tests with the same bean set reuse a single context. Narrow slices maximise cache reuse.
Why this matters for the agent:
Lower context uncertainty: the agent does not have to boot half the application to explain why one endpoint’s test failed.
An agent unaware of slices writes
@SpringBootTeston every test. The full context starts in 20–60 seconds. An agent in headless mode like Claude Code waits a long time for the result, and feedback density falls.@WebMvcTestdoes not boot service or persistence beans at all. If the agent leaves a real dependency on such a bean in the controller without a mock, the test refuses to start with a clear context error. That catches a stray dependency of the web layer on real infrastructure or application context. The lack of an interface does not matter here, because@MockitoBeancan mock a concrete class as well.The slice declares the testing scope explicitly. On the next task, the annotation name alone tells the agent what is being tested here.
Usage guidelines:
If a slice needs customisation, use
@TestConfigurationinside a nested class of the test (not@Configurationnext to the test, which would leak the bean into other tests through component-scan).Codify in
AGENTS.md: "before writing a test, pick the closest slice:@WebMvcTestfor controllers,@DataJpaTestfor repositories,@SpringBootTestonly for full scenarios".
@TestConfiguration as an explicit override mechanism
The previous section covered reusing the Spring context and shared test doubles across tests. The question here is where the test’s configuration class should physically live so it does not "leak" into other tests through component-scan.
Anti-pattern: a test configuration class carries @Configuration and sits next to the test. Spring Boot component-scan picks it up in every test, including @SpringBootTest in other modules, which leads to failures that are hard to reproduce, such as NoUniqueBeanDefinitionException, or "green in isolation, red in the suite".
Implementation:
Use
@TestConfiguration(not@Configuration) for classes that should apply only in a specific test.@TestConfigurationin a nestedstaticclass of the test is tied strictly to the test in which it is declared and does not leak through component-scan. A top-level@TestConfigurationclass is also safe: unlike@Configuration, it is not picked up by component-scan on its own. But because of that, it must be@Import-ed into every test that needs it, otherwise it does not apply to any test at all.@MockitoBeanand@MockitoSpyBeanreplace the old@MockBean/@SpyBeanfrom Spring Boot Test (originally these were Spring Boot’s own annotations, not Mockito’s), for substituting a specific bean without writing a configuration class.
Why this matters for the agent:
The agent copies configuration patterns from the documentation and reaches for
@Configuration, since that is how production code does it. Spring Boot warns about this trap: if the application uses component scanning, "you may find top-level configuration classes that you created only for specific tests accidentally get picked up everywhere". The agent does not catch this subtlety on its own.@TestConfigurationas an explicit annotation tells the next agent (or reviewer): "this class is for the test next to it only". The scope lives in the annotation, not in a comment.
Usage guidelines:
Codify in
AGENTS.md: "test configurations use@TestConfigurationonly;@Configurationinsrc/test/javais an error".ArchUnit rule:
noClasses().that().resideInAPackage("..test..").should().beAnnotatedWith(Configuration.class). A direct ban on bare@Configurationin test packages surfaces the violation inmvn test. This example applies specifically to projects where test classes live in a..test..package.resideInAPackage("..test..")only fires when the package name contains such a segment, and the standard Maven layoutsrc/test/javadoes not guarantee this on its own (the test class’s package usually matches that of the class under test, withouttestin the name). Otherwise, tie the rule to the project’s package-naming convention or to the test-classes analysis configuration.
Testcontainers with @ServiceConnection instead of @DynamicPropertySource
Anti-pattern: a @DynamicPropertySource method manually maps container properties onto Spring configuration. When you add a new container, missing one line is easy to overlook, and the test turns green against an in-memory stub that quietly activated in place of real Postgres.
Implementation:
Use
@ServiceConnectionon a@Containerfield. Spring Boot detects the container type and registers the neededConnectionDetailsbean on its own (JdbcConnectionDetailsfor PostgreSQL,RedisConnectionDetailsfor Redis, and so on), which the corresponding auto-configuration uses in place of manual property configuration. This works for a supported service through its auto-configuration. If you declared aDataSource,RedisConnectionFactory, or another client bean directly yourself,@ServiceConnectiondoes not override it.No manual
registry.add("spring.datasource.url", postgres::getJdbcUrl). The annotation replaces the whole block.A
@Containerfield under@Testcontainerslives within a lifecycle managed by the JUnit Testcontainers extension. It starts the container before the tests and stops it afterwards, so the container does not outlive JVM termination..withReuse(true)does not change this on its own. The JUnit extension can still stop the container regardless of the flag. Testcontainers' official documentation states directly that for genuine reuse a container must be created and started manually, outside the@Testcontainers/@Containerintegration, and calls reusable containers an experimental feature not recommended for CI. That is why the example below does not use.withReuse(true). The shown pattern is about@ServiceConnection, not reuse. Reuse itself is a separate topic in "Usage guidelines".
Why this matters for the agent:
With
@DynamicPropertySourcethe agent often misses one of the mapping lines or gets a property name wrong (spring.datasource.urlvs.spring.r2dbc.url). The test does not necessarily fail. It may pass unexpectedly against an embedded/in-memory configuration or another fallback (say, H2) if such a fallback is on the classpath and the slice/auto-configuration allows the substitution. That is a common scenario, not a guaranteed behaviour for every project.@ServiceConnectioncuts the chance of "forgetting" configuration. One annotation covers what previously required several lines of manual mapping, and separate properties that could be missed line by line simply do not exist here.A real cycle speedup from a container that survives multiple runs is a separate manually-configured reuse mode (see "Usage guidelines" below), not something the agent gets "for free" with
@ServiceConnection. If the agent copies.withReuse(true)onto a@Containerfield expecting such a speedup, no speedup follows. The container still stops at the end of the test under the JUnit extension’s control.
Usage guidelines:
@ServiceConnectionsupports out of the box: PostgreSQL, MySQL, MariaDB, RabbitMQ, Kafka, MongoDB, Elasticsearch, Cassandra, Neo4j, through typed Testcontainers modules.- Redis and Zipkin plug in the same way but through
GenericContainer, and here behaviour depends on the declaration form. Astaticfield (as in the example below) can be recognised by Spring Boot from the Docker image name; a@Beanmethod returningGenericContainer<?>cannot, and needs an explicit@ServiceConnection(name = "redis"). For other custom containers, implementContainerConnectionDetailsFactory.
- Redis and Zipkin plug in the same way but through
If a container that survives several runs is nonetheless needed for local development, that is a separate mode not shown in the example above. The container is created and started manually (not as a
@Containerfield under@Testcontainers),testcontainers.reuse.enable=trueis set in the developer’s~/.testcontainers.properties, and only then does the container leave the standard JUnit model and outlive both test classes within one run and the JVM process itself, so it survives several consecutivemvn testinvocations. This mode is not used in CI. There you need a fresh container per run or an explicitly managed shared service.- Separately from tests: if the same container is used for local development with Spring Boot DevTools, the
@RestartScopeannotation on the@Beanmethod prevents DevTools from recreating the container on application hot-restart. That is a third, separate mechanism for live-reload in development, unrelated to either the@Testcontainerslifecycle or the manual reuse mode above.
- Separately from tests: if the same container is used for local development with Spring Boot DevTools, the
// @ServiceConnection — one annotation instead of manual property mapping,
// nothing to forget or mix up between spring.datasource.* and spring.r2dbc.*.
// The container is managed by the JUnit Testcontainers extension (start/stop per run) —
// without withReuse(true): genuine reuse requires a separate mode with manual
// container startup, see "Usage guidelines" in the section text.
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine");
@Autowired OrderRepository repo;
@Test
void savesAndFindsOrder() {
Order saved = repo.save(new Order(/* ... */));
assertThat(repo.findById(saved.id())).isPresent();
}
}
Contract testing (Spring Cloud Contract + WireMock)
Anti-pattern: an integration test mocks the HTTP client by hand and invents responses from an "approximate" JSON found somewhere in the codebase. When the provider’s contract changes, the consumer’s test keeps passing, because it tests a fantasy, not real behaviour.
Implementation:
The source of truth for the contract is whoever formulates the expectations. In the classical consumer-driven model (Pact, for example), the consumer writes the expectations, and a provider-verification test checks that the provider responds as the consumer expects. Spring Cloud Contract officially supports both modes, consumer-driven and producer-driven; its own documentation states this directly: "It lets you perform consumer-driven and producer-driven contract testing". The flow shown below is its typical provider-driven variant. The provider side describes the contract in a DSL (Groovy or YAML), and Spring Cloud Contract generates from it both the provider’s autotest and the
WireMockstub for consumers.On the provider side,
Spring Cloud Contractgenerates from the contract a provider autotest and aWireMockstub inside a JAR with thestubsclassifier.On the consumer side, plug in the stub JAR through
@AutoConfigureStubRunner. The stubs come up before the test and stay in sync with the provider.@AutoConfigureStubRunneritself starts the stub server on the specified port (8080in the example below), but does not redirect an arbitrary HTTP client to it.OrderClientin the example must receive the base URL of that stub server through configuration, service discovery,@DynamicPropertySource, or another explicit wiring mechanism. Without such wiring, the client will keep hitting the real (or entirely unreachable) address rather than the running stub.A contract change on the provider side breaks the consumer’s test when the stub artifact is updated, or in a pipeline that verifies the consumer against fresh provider stubs. When the consumer is pinned to an old stubs version, it learns about a breaking change only on the next dependency update, not "immediately". The scheme itself does not guarantee instant detection. It guarantees detection before production, provided the pipeline pulls current stubs.
Why this matters for the agent:
The agent writes mocks "by example". It grabs the nearest JSON in the code and drops it into
mockServer.when(…). That tests an internal fantasy, not the external contract. When the API changes, the test stays green until someone runs a real integration.The stub JAR from the provider is the sole source of truth for the consumer. The agent cannot invent a response other than the one encoded in the contract, because the framework brings the stub up, not the test code.
Named contracts (
shouldReturnOrderById,shouldReturn404WhenOrderNotFound) are the specification. On the next change, the agent can see what breaks and why.
Usage guidelines:
Spring Cloud Contract requires a Maven plugin (
spring-cloud-contract-maven-plugin) and usually a separate artifact for publishing stubs. For a small team, consider WireMock with hand-written JSON stubs as a lighter alternative, at the cost of auto-synchronisation.The flow shown here is provider-driven: the contract lives in the provider’s repository, and consumers depend on it through a stub JAR. Spring Cloud Contract also supports a consumer-driven workflow (the contract is formulated on the consumer side and handed to the provider for verification), but in practice this model more often uses a dedicated tool (Pact Broker), which is a different workflow, not the one shown in the example below.
Describing the HTTP client via
@HttpExchange(or any other client style) does not on its own create or verify a contract: Spring Cloud Contract generates the provider test andWireMockstub from the contract regardless of the consumer’s client style. Wiring the stub in (through@AutoConfigureStubRunner) and pointing the HTTP client at the stub endpoint is something the consumer configures separately; contract configuration does not become "automatically correct" through@HttpExchange.A plus-version in
ids(com.example:order-service:+:stubs:8080) is convenient for an example, since Stub Runner picks the latest available version of the stub artifact, but that same behaviour makes it worse for CI reproducibility. The same consumer commit can pass today and fail tomorrow without a single change of its own, if the provider published a new stubs version. For CI, pin a concrete artifact version or a managed "latest" (for example, through BOM/dependency management with explicit updates); not a bare+.
// Provider-side contract (Groovy DSL)
// src/test/resources/contracts/orders/shouldReturnOrderById.groovy
Contract.make {
description "should return order by id"
request {
method GET()
url "/orders/42"
headers { accept(applicationJson()) } // GET with no body — we expect Accept, not Content-Type
}
response {
status OK()
body([id: 42, status: "CONFIRMED", total: 150.00])
headers { contentType(applicationJson()) } // the response has a body — Content-Type is correct here
}
}
// Consumer test — the stub is brought up from the provider's JAR, not written by hand.
// The "+" in ids below is for illustration; in CI pin the stub artifact version (see "Usage guidelines").
// The example assumes OrderClient is already pointed at the running stub server's base URL
// (port 8080) — through configuration/discovery/@DynamicPropertySource; StubRunner itself
// brings the stub up but does not redirect the client to it automatically.
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.example:order-service:+:stubs:8080",
stubsMode = StubRunnerProperties.StubsMode.LOCAL)
class OrderClientTest {
@Autowired OrderClient client;
@Test
void returnsOrderById() {
Order order = client.getOrder(42L);
assertThat(order.status()).isEqualTo("CONFIRMED");
assertThat(order.total()).isEqualByComparingTo("150.00");
}
}
Architecture tests (ArchUnit)
Anti-pattern: architectural decisions live only in documentation or in the team’s heads, so the agent breaks them while generating code.
Implementation:
Write ArchUnit tests that encode architectural rules as executable specifications.
The tests run as part of ordinary
mvn test/gradle test.
Why this matters for the agent: the agent reads an architecture test as living documentation. A violation breaks the build immediately. That enforces the "usage guidelines" from other patterns like naming conventions, the ban on domain-to-infrastructure dependencies, and the ban on cycles.
Usage guidelines:
Start with three rules: (1) domain does not depend on infrastructure, (2) use-case class names follow the
*UseCasescheme, (3) no cyclic dependencies between packages.The
useCasesAreNamedCorrectlyrule below finds use cases through the stereotype annotation@Service. It will not see a use case registered by an explicit@Beanwithout a stereotype (see the "Explicit@Beanregistration instead of unbounded component scan" section above). If the project uses such registration, check by package (..application..) and class-name suffix, not by annotation.Add a ban on dependencies on
..internal..of other modules: the cheapest rule with the largest diagnostic benefit, because the first run will surface the real list of architectural debts.The analysis scope (
@AnalyzeClasses(packages = …)) determines which classes the rule sees at all. The example below analyses onlycom.example.invoice, so the..internal..rule in practice checks specifically "invoice does not reach into order.internal", not a universal project-wide ban. For a codebase-wide rule, broadenpackages(for example, to"com.example"); otherwise it is easy to overestimate the reach of the rule.Name the file following the
*ArchitectureTestscheme (see the naming convention in part 1).
Example implementation
@AnalyzeClasses(packages = "com.example.invoice")
public class InvoiceModuleArchitectureTest {
@ArchTest
static final ArchRule domainDoesNotDependOnInfrastructure =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..");
@ArchTest
static final ArchRule useCasesAreNamedCorrectly =
classes()
.that().resideInAPackage("..application..")
.and().areAnnotatedWith(Service.class)
.should().haveSimpleNameEndingWith("UseCase");
@ArchTest
static final ArchRule noCycles =
slices()
.matching("com.example.invoice.(*)..")
.should().beFreeOfCycles();
@ArchTest
static final ArchRule noAccessToInternalPackages =
// The rule sees only classes from @AnalyzeClasses(packages = "com.example.invoice") —
// this checks specifically that "invoice does not reach into order.internal",
// not a ban across the whole codebase.
noClasses()
.that().resideOutsideOfPackage("com.example.order..")
.should().accessClassesThat()
.resideInAPackage("com.example.order.internal..");
}
Mutation testing (PIT) as an objective signal of agent-facing test quality
Anti-pattern: the agent writes tests that give high line coverage but do not verify business invariants; assertions are missing or check trivial conditions. Coverage shows 90 %, review passes, and the mutation if (amount > 0) → if (amount >= 0) remains indistinguishable, and a regression sneaks into production.
Implementation:
Add PIT (
pitest-maven-plugin). For JUnit 5,pitest-junit5-pluginis mandatory.The metric to watch is
mutation coverage(not line coverage): the share of mutants "killed" by tests. Treat any numeric threshold as a starting reference to be calibrated to the specific code, not as a universal quality metric. For example, 70–80 % for domain code and 50–60 % for service layers. Such thresholds are worth enforcing in critical zones (domain rules, money/time/status transitions, authorization, serialization contracts), where a surviving mutant costs the most. On peripheral code the same threshold may be too strict.Incremental mode:
withHistory=truecaches results between runs and does not repeat analysis where the code has not changed, so this speeds up both local and CI runs.PIT itself calls incremental analysis an experimental feature; do not rely on it as the sole source of truth about coverage.
To run mutations only on classes changed in a PR, there is a
scmMutationCoveragegoal (via Maven SCM plugin), but its behaviour and availability depend on the PIT version; treat it as a separate piece of CI scaffolding rather than the main reliable path. An alternative is a custom git-diff script in CI that feeds the file list into<targetClasses>.
A full pass over the whole codebase runs nightly or weekly, regardless of the incremental mode used during the day.
<mutationThreshold>in the plugin configuration. The build breaks when the mutation score falls below the threshold.The
target/pit-reports/index.htmlreport contains uncaught mutants:SURVIVED(the code executed, but assertions did not notice the change) andNO_COVERAGE(the line was not executed by tests at all), with line numbers. That is a direct TODO list for the agent.
Why this matters for the agent: line coverage measures execution, not checking. An agent that optimises for coverage generates tests of the form assertNotNull(result). They raise the percentage but miss regressions. Mutation testing gives an objective loss function: a surviving mutant is a regression the test will not catch. In the self-verify loop (a machine-readable summary of the test run, see part 3), the agent reads the list of surviving mutants and adds assertions aimed at the specific mutation it just saw.
Atlassian applies a similar loop in Rovo Dev (2025). The CLI runs PIT, finds uncaught mutants, generates tests, repeats until the threshold, then opens a PR. On the academic side there is arXiv:2501.12862, "Mutation-Guided LLM-based Test Generation at Meta". These industrial and research examples show that an LLM plus mutation feedback works inside specific large organisations. They are not a template to copy verbatim. Thresholds, how much of the run is incremental, and how much of the loop is automated all need tuning to the size and change rate of the codebase you are working on.
Usage guidelines:
Do not run PIT on all code on every PR. It is slow. Incremental or diff-based mode on an average branch usually takes tens of seconds, but the exact time depends on diff size, history-cache state, and CI worker resources, so this is not a guaranteed SLA.
Choose the
mutationThresholdempirically. Start at 50 % and raise it as confidence in the tests grows.Exclude mutants in trivial code like getters and boilerplate through
<excludedClasses>or<excludedMethods>. Otherwise they fill the report with noise.Combination with parameterized tests (see the section above): each new row in
@MethodSourceis a potential new killed mutant.
pom.xml
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<configuration>
<targetClasses>
<param>com.example.invoice.domain.*</param>
</targetClasses>
<targetTests>
<param>com.example.invoice.domain.*Test</param>
</targetTests>
<mutationThreshold>75</mutationThreshold>
<withHistory>true</withHistory>
</configuration>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
</dependency>
</dependencies>
</plugin>
PIT report: a direct TODO list for the agent
> pitest report — unmatched mutants (3)
> Invoice.java:47 `> 0` → `>= 0` (ConditionalsBoundary) SURVIVED — code executed, assertions did not catch it
> Invoice.java:52 `throw ex` → `null` (VoidMethodCall) NO_COVERAGE — line not executed by tests at all
> Invoice.java:58 `+ 1` → `- 1` (Math) SURVIVED — code executed, assertions did not catch it
> Add assertions covering these lines to reach threshold (75%).
[^1]: Anthropic, "Claude Code: Best practices for agentic coding", docs.claude.com.
[^2]: Yang J. et al., "SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering", NeurIPS 2024. arXiv:2405.15793.
[^3]: Luo Q. et al., "An Empirical Analysis of Flaky Tests", FSE 2014. DOI 10.1145/2635868.2635920.
[^4]: Javadoc java.time.Clock.systemDefaultZone(), Java SE 21.



