If you have ever asked Claude Code, Cursor, or Copilot to "add an endpoint" to a Spring Boot project, you already know the result: a @RestController with field injection, business logic crammed into the controller method, no @Transactional, no validation, a stack trace leaking back to the client, and a test that boots the entire @SpringBootTest context just to assert a 200.
That output is not the AI's fault. The training data is fifteen years of Spring tutorials — most of them written for Spring 3.x or 4.x, when field injection was idiomatic and WebSecurityConfigurerAdapter was the only path. The AI is doing exactly what you would do if you had only read 2014 blog posts.
The fix is a CLAUDE.md file at the root of your repo. AI coding assistants read it before they write a line, and the rules act as a house style that overrides whatever Stack Overflow answer the model is about to regurgitate.
Here are 13 rules that consistently move AI output from "compiles but I would reject this PR" to "I would merge this." They target Spring Boot 3.2+ on Java 17 / 21 LTS.
Rule 1: Package by feature, not by layer — com.acme.app.invoice over com.acme.app.controllers
AI emits com.acme.app.controllers, com.acme.app.services, com.acme.app.repositories, com.acme.app.models. Six months later, every feature is smeared across four packages and a "small" change touches files in all of them.
Why: Package-by-feature keeps related classes together (InvoiceController, InvoiceService, InvoiceRepository, Invoice, InvoiceDto). Cohesion goes up, accidental coupling goes down, and you can make package-private classes truly package-private — the layered structure forces everything to public to cross packages.
Apply: Top-level packages map to bounded contexts (invoice, billing, user). Inside each, sub-packages by role if needed (invoice.api, invoice.domain, invoice.persistence) — never by Spring stereotype. Anything not exported from the feature stays package-private. A new developer should be able to delete a whole feature by deleting one directory.
Rule 2: Constructor injection only — never @Autowired on fields, never setter injection
AI loves @Autowired private UserRepository repository; because every Baeldung article from the last decade shows it. Field injection breaks final, hides dependencies, makes unit testing without Spring impossible, and lets you accidentally instantiate a half-wired bean.
Why: Constructor injection makes dependencies explicit (you can't compile without them), allows final fields (immutable wiring, thread-safe), and lets you test with new UserService(mockRepo) — no Spring context needed. Spring 4.3+ auto-detects single constructors, so @Autowired is redundant noise.
Apply: One constructor per @Component / @Service / @Controller, all dependencies as final fields, no @Autowired annotation. Use Lombok's @RequiredArgsConstructor if you must, but plain constructors are clearer. If a class needs more than 4–5 dependencies, that's a design smell — split the class.
Rule 3: REST controllers are thin — they translate HTTP, they don't contain logic
@RestController classes that build queries, call repositories, transform DTOs, and emit emails are AI's default because every "todo app in Spring" tutorial does it that way. The controller becomes 400 lines and the same logic gets duplicated for the gRPC endpoint, the scheduled job, and the test.
Why: Controllers are an adapter between HTTP and your domain. They parse path/query/body, call exactly one service method, translate the result (or exception) to a response. Logic in controllers can't be reused outside HTTP, can't be transactional cleanly, and pollutes test setup with MockMvc for cases that need no HTTP at all.
Apply: Each controller method does: validate input → call service → map result to response. @RequestBody with a DTO record, @Valid for bean validation, ResponseEntity<T> only when you need to control status/headers (otherwise return T and let Spring set 200). No @Transactional on controllers. No repository injection in controllers — ever.
Rule 4: Service layer owns the use case and the transaction boundary — @Transactional lives here
AI sprinkles @Transactional everywhere: on the controller, the service, the repository method. Or it forgets it entirely and lazy-loaded entities throw LazyInitializationException in production.
Why: A transaction is a use case boundary: "transfer money from A to B" either fully commits or fully rolls back. The service method is the natural place — it knows the business invariant. Spring's proxy-based @Transactional only works on public methods called from outside the bean; self-invocation silently bypasses it.
Apply: @Transactional on public service methods that span multiple repository calls. @Transactional(readOnly = true) for read-only flows (skips dirty checking, hints DB). Never @Transactional on a private method or one called from the same class. Configure timeouts: @Transactional(timeout = 30). Define rollback rules explicitly for checked exceptions: rollbackFor = Exception.class.
Rule 5: Spring Data repositories return what they query — no findAll() for tables with > 1000 rows
userRepository.findAll() in a controller, then .stream().filter(...) in Java. AI does this because it's syntactically simplest. Then the table grows to 50M rows and the heap explodes.
Why: Repositories are a port to the database; the database is the right place to filter, sort, and paginate. JPA fetches entire entity graphs by default — every findAll() materialises every column of every row plus eager associations.
Apply: Derived query methods (findByStatusAndCreatedAtAfter(Status s, Instant t)) for simple cases, @Query for joins and projections. Always paginate list endpoints: Page<UserDto> findAllByStatus(Status s, Pageable pageable). Use projection interfaces or DTOs to fetch only the columns you need. Prefer Slice over Page when you don't need a total count (saves a COUNT(*)).
Rule 6: One global exception handler with @RestControllerAdvice — no try/catch in controllers, no leaked stack traces
AI wraps every controller method in try { ... } catch (Exception e) { return ResponseEntity.status(500).body(e.getMessage()); }. The client sees the full Hibernate stack trace, including table and column names. Production secret leak in the response body.
Why: Exception handling is cross-cutting. Doing it in every controller means inconsistent status codes, inconsistent body shapes, and inevitable leakage of internal detail. @RestControllerAdvice centralises the policy: one mapping from exception type → HTTP status + safe body.
Apply: One @RestControllerAdvice class with @ExceptionHandler per exception type. Map domain exceptions to status codes (UserNotFoundException → 404, DuplicateEmailException → 409). Use RFC 7807 ProblemDetail (Spring 6+) for the response body — never e.getMessage() raw. Log the full exception server-side; return a correlation ID to the client.
Rule 7: Validate at the edge with Jakarta Bean Validation — @Valid, @NotNull, @Size, custom constraints
if (request.getEmail() == null || request.getEmail().isBlank()) throw new IllegalArgumentException("email required"); repeated in every method. AI writes it because it works. Until the same field needs the same check in three endpoints and they drift.
Why: Bean Validation is declarative, composable, and runs before your controller method body. Constraints live on the DTO where the contract is documented. @Valid triggers validation; MethodArgumentNotValidException is auto-handled by your @RestControllerAdvice (Rule 6) into a consistent 400 response.
Apply: Annotate DTO fields: @NotBlank, @Email, @Size(min=8, max=72), @Pattern(regexp="..."), @Past, @Future. Add @Valid on @RequestBody and @PathVariable/@RequestParam (with @Validated on the controller class for the latter). Build custom constraints (@ValidCountryCode) for domain rules. Validate request DTOs, not entities — entities are persistence concerns, not API contracts.
Rule 8: Spring Security with explicit SecurityFilterChain — no WebSecurityConfigurerAdapter, no permitAll() everywhere
AI emits extends WebSecurityConfigurerAdapter (deprecated since Spring Security 5.7, removed in 6) or wide-open http.authorizeHttpRequests().anyRequest().permitAll() because the build passes and the endpoints respond.
Why: Security is allow-list, not deny-list. The default must be "deny", and every public endpoint must be a deliberate exception. The modern API is a SecurityFilterChain @Bean — explicit, lambda-based, composable. CSRF defaults to on; disabling it requires you to acknowledge you're building a stateless API.
Apply: One @Configuration with a SecurityFilterChain bean: http.authorizeHttpRequests(auth -> auth.requestMatchers("/api/public/**").permitAll().anyRequest().authenticated()). JWT? oauth2ResourceServer(oauth2 -> oauth2.jwt(...)). Method-level: @PreAuthorize("hasRole('ADMIN')") over annotation-less authorization checks. Hash passwords with BCryptPasswordEncoder — never plain SHA, never MD5.
Rule 9: Test controllers with @WebMvcTest + MockMvc — never @SpringBootTest for a single endpoint
AI uses @SpringBootTest for everything because it's the annotation it sees most. Each test boots the full application context, every test class takes 8 seconds, and the suite goes from "fast" to "skip in dev" in three sprints.
Why: @WebMvcTest boots only the web layer (controllers, filters, @RestControllerAdvice, Jackson, validation) and mocks everything else. Tests run in milliseconds because no JPA, no embedded server, no autoconfigured infrastructure.
Apply: Controller tests: @WebMvcTest(InvoiceController.class) + @MockitoBean InvoiceService service + MockMvc mockMvc. Service tests: plain JUnit + Mockito, no Spring annotation. Repository tests: @DataJpaTest against Testcontainers PostgreSQL (not H2 — H2's SQL dialect lies about Postgres). Full-stack integration tests: @SpringBootTest(webEnvironment = RANDOM_PORT) — keep these few and tag them @Tag("integration").
Rule 10: One @Transactional boundary per use case — beware self-invocation and LAZY collections outside
service.methodA() calls this.methodB() which is @Transactional. The proxy is bypassed; methodB runs without a transaction. AI doesn't know this trap and writes the bug confidently.
Why: Spring's @Transactional works via JDK or CGLIB proxies. The proxy intercepts external calls; calls through this skip the proxy entirely. Same for private, static, and final methods. Lazy-loaded JPA associations accessed after the transaction closes throw LazyInitializationException — usually surfacing in the controller after the service returned.
Apply: Self-invocation: extract the transactional method to a separate bean. Use @Transactional(propagation = REQUIRES_NEW) only when you genuinely need a separate transaction. Force fetching with explicit @EntityGraph or DTO projections — don't return entities with lazy associations to controllers. Prefer DTOs across the service boundary; entities stay in the persistence package.
Rule 11: Structured logging with SLF4J — never System.out.println, never string concatenation in log calls
System.out.println("user " + userId + " logged in") ships to production, the logs are unsearchable, and the userId field doesn't exist as structured data.
Why: SLF4J's parameterised logging ({} placeholders) defers string construction until the appender confirms the level is enabled — zero-cost when off. Structured logs (JSON via Logback's logstash-logback-encoder) make userId a queryable field in your log aggregator instead of a substring.
Apply: private static final Logger log = LoggerFactory.getLogger(MyClass.class); (or Lombok @Slf4j). Always log.info("user {} did {}", userId, action) — never +. Add MDC.put("requestId", id) in a filter so every log line in the request gets the ID. Log levels: DEBUG for developer detail, INFO for business events, WARN for recoverable anomalies, ERROR for things that page someone. No e.printStackTrace() — log.error("context", e).
Rule 12: Configuration as @ConfigurationProperties records — no @Value("${...}") scattered across beans
@Value("${stripe.api.key}") private String stripeKey; repeated in five beans, with three different default values, and one typo (${stripe.apikey}) that silently resolves to null.
Why: @ConfigurationProperties binds a whole namespace to a typed object — one source of truth, IDE autocompletion, validation via @Validated, and metadata generation for application.yml autocomplete. @Value is string-based, no validation, no relocation safety, silent on typos.
Apply: Define a record per config namespace: @ConfigurationProperties("stripe") record StripeProperties(@NotBlank String apiKey, @NotNull Duration timeout, Webhook webhook) {}. Enable with @EnableConfigurationProperties(StripeProperties.class). Inject the record like any other bean. Use application-{profile}.yml for environment overrides — never commit secrets; use Vault, AWS Secrets Manager, or env vars.
Rule 13: Version your API in the URL or media type — and bake it in from v1, not "we'll add it later"
@GetMapping("/users") ships, gets adopted by three mobile apps, and the next breaking change requires a coordinated client release.
Why: Public APIs are contracts you can't unilaterally change. Versioning lets you ship a v2 shape while v1 clients keep working. URL versioning (/api/v1/users) is operationally simplest (cache-friendly, log-greppable, gateway-routable). The first version is the cheapest one to add.
Apply: All endpoints under /api/v{n}/... from day one. Use @RequestMapping("/api/v1") at the controller class level. When introducing v2, copy the v1 controller, rename the package, and evolve independently — don't add if (version == 2) branches inside one method. Deprecate v1 with a Sunset header (RFC 8594).
Wrapping up
These 13 rules don't replace the Spring reference docs — they encode the failure modes AI repeats most often in real Spring Boot codebases. Package by feature, constructor injection, thin controllers, transactional services, paginated repositories, centralised exception handling, declarative validation, explicit SecurityFilterChain, @WebMvcTest over @SpringBootTest, awareness of proxy self-invocation, structured logging, typed configuration, and versioned APIs from v1 — that's the difference between a Spring app that scales and a Spring app that gets rewritten in 18 months.
Drop the file at the root of your repo. The next AI prompt produces Spring Boot your future self won't have to apologise for at the post-incident review.
Free sample (this article + Gist): CLAUDE-springboot.md on GitHub Gist
Get the full CLAUDE.md Rules Pack (40+ languages and frameworks): oliviacraftlat.gumroad.com/l/skdgt
Top comments (0)