description: Build Spring Boot services in a strict layered architecture (Controller → Service → Manager/Client → Mapper/Repository, with an optional Variant layer for multi-implementation strategies at the same level as Manager) on Gradle + Java 21 + Spring Boot 3.5. Use when creating, scaffolding or refactoring Spring Boot projects, designing REST endpoints, organising service code, or whenever a project follows this layered pattern. Covers MyBatis + JPA persistence (MyBatis for complex JOINs / dynamic SQL / reports; JPA for single-table CRUD on a clear domain model), transfer objects (Request / Response / Entity), Spring Validation, global exception handling, HTTP-status response style, TraceId logging, transaction boundaries, and SpringDoc OpenAPI. Encourages Java 21 syntax sugar (records, var, text blocks, pattern matching, sealed types) and forbids Lombok.
---
# Spring Boot
Conventions for building Spring Boot services on Gradle + Java 21 + Spring Boot 3.5, organised as a strict four-layer architecture.
## Layer Architecture
The call graph is one-directional. No layer may skip its parent.
```
Controller → Service → Manager → Mapper / Repository
↘
Client
↘
Variant (same level as Manager)
```
- **Controller** — HTTP boundary. Parses requests, validates input, dispatches to a single Service method. Returns the response body directly, or `ResponseEntity` when headers or status code choice matter.
- **Service** — business orchestration. Holds `@Transactional` boundaries. Composes Managers, Clients, and Variants into use cases.
- **Manager** — atomic persistence operations and shared business helpers. The only layer permitted to depend on `Mapper` and `Repository`. No HTTP types, no Controller DTOs.
- **Client** — wrappers around external services and internal infrastructure (JWT signing, S3, message queues, Redis, distributed locks, third-party APIs).
- **Variant** — a slot for one implementation of a strategy or extension point. Lives at the same level as Manager and is wired into Service when a use case has multiple variants (payment methods, file processors, notification channels).
- **Mapper / Repository** — MyBatis `Mapper` and JPA `Repository` interfaces. Pure persistence, no business logic.
Allowed call directions:
- Controller → Service → Manager → Mapper/Repository
- Controller → Service → Client (Client never calls Mapper)
- Controller → Service → Variant (Variant may call Manager, Client, or another Variant)
- Service never calls Controller; Manager never calls Service; Mapper/Repository never calls Manager
- Client may call another Client for protocol adaptation
- Manager may call a Client when a data operation needs external context (for example, a uniqueness check against an external system)
- Cross-Manager calls are forbidden — go through Service
## Project Layout
Layer-based packages under a fixed root. There are no per-feature sub-packages — all Controllers live under `controller/`, all Services under `service/`, and so on. Naming a class carries the resource (for example, `UserController`, `OrderService`, `PaymentVariant`).
`application.yml` lives at `src/main/resources/application.yml`. Use kebab-case keys, two-space indentation, one section per concern (`server`, `spring`, `mybatis`, `springdoc`).
## Java 21 Syntax Sugar
Use the language features that arrive for free with the toolchain. They are preferred over verbose alternatives. **Lombok is forbidden** — the toolchain makes it unnecessary.
-`record` for `Request`, `Response`, and any other immutable DTO
-`var` for local variables when the right-hand side makes the type obvious
- Text blocks for multi-line strings (SQL, JSON literals, log templates)
- Pattern matching for `instanceof` and `switch`
- Sealed interfaces for fixed hierarchies of events and error categories
- No URL prefix on the controller's `@RequestMapping`. Path versioning is added by the reverse proxy (Caddy, Nginx, etc.) in front of the service, not by Spring
- One Controller per resource aggregate
- Never return a generic `Map` or `JsonNode` — define a `Response` record
- Never catch exceptions in the Controller; let the global handler do it
-`UserService` is injected through the single public constructor — no `@Autowired`
- Put `@Validated` on the Controller class to enable method-level validation for `@PathVariable` / `@RequestParam` constraints, and on each `@RequestBody` parameter to validate the bound record
## Service Layer
Responsibilities:
- Business orchestration: transaction boundary, cross-Manager composition, Client coordination
- Holds `@Transactional`
- No HTTP types (`HttpServletRequest`, `ResponseEntity`); no persistence types (`EntityManager`, `SqlSession`)
Service is a **concrete class** annotated with `@Service`. There is no `interface UserService` paired with a `UserServiceImpl`. If a use case has multiple variants, extract them into a **Variant** (see below) and let the Service pick one at runtime.
- Service never reads from a `Mapper` or `Repository` directly — always through a Manager
- Read methods use `@Transactional(readOnly = true)`
- Write methods use plain `@Transactional` (default propagation `REQUIRED`)
- Use constructor injection via the single public constructor — Spring 4.3+ resolves it without `@Autowired`
- For multi-variant logic, do **not** introduce an interface on the Service; extract the variants as Variants and inject them by `Map<Key, Variant>` (see the Variant section)
## Manager Layer
Responsibilities:
- Atomic persistence operations on one or more Mapper/Repository interfaces
- Shared business helpers reused across multiple Services (`assertEmailAvailable`, `findActiveByTenant`, etc.)
- Combines JPA and MyBatis access for the same module when both are useful
- Returns **Entity** objects, never `Request` or `Response`
- Manager is the **only** layer allowed to call Mapper/Repository
- Manager never calls another Manager — call through the other Service
- Manager never returns a Controller-layer DTO; map Entity to Response in the Service
- Manager methods are atomic: one transaction, one concern, no remote calls
## Variant Layer
A Variant is a slot for one implementation of a strategy or extension point that a Service delegates to at runtime. It is **not** a top-level layer — it lives at the same level as Manager and exists only to keep the Service free of branching logic.
Use a Variant when a use case has multiple variants that share the same input/output shape but differ in implementation: payment methods, file processors, notification channels, discount rules, AI model backends. Do not use a Variant to implement a second copy of a Service; that is what the Variant is itself a way to avoid.
The interface lives in `variant/`; each implementation lives in `variant/impl/<strategy>/`. The Service injects all implementations as a `Map` keyed by the discriminator and looks one up at call time.
- A Variant may call Manager, Client, or another Variant; it never calls Service or Controller
- All Variants in a group share the same input record and return the same output record; the discriminator is a field on the input (the `supports()` return value) or a sealed interface
- Use a `sealed` interface for the Variant hierarchy so the compiler can verify that every variant is implemented
- Spring collects `List<PaymentVariant>` and the Service builds the dispatch map; no manual `@Bean` is needed
- The Service handles "no variant found" — never let a `NullPointerException` from a missing map entry reach the Controller
A protected no-arg constructor is required by JPA. Static factories (`fromRequest`, `reconstitute`) are the only places outside the persistence framework that construct an entity.
### MyBatis Mapper
**Annotation-first.** Reach for XML only when the SQL has dynamic fragments that are unreadable as a method body.
For a mapper method whose SQL must live in XML, declare the method in the interface and put the SQL in `src/main/resources/mapper/UserMapper.xml`. The XML `namespace` and the interface FQN must match.
SELECT u.id, u.email, u.display_name AS displayName, SUM(o.amount_cents) AS totalSpent
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.id
HAVING totalSpent > #{threshold}
ORDER BY totalSpent DESC
LIMIT #{limit}
</select>
</mapper>
```
Configure XML locations in `application.yml`.
```yaml
mybatis:
mapper-locations:classpath:mapper/**/*.xml
configuration:
map-underscore-to-camel-case:true
```
### Shared Entity, Different Roles
A single class is both the JPA `@Entity` and the MyBatis result target. JPA's `@Entity` is just metadata; MyBatis only cares about the property names matching the result column aliases (camelCase). Do not annotate the entity with MyBatis-specific mapping annotations.
## Transfer Objects
Three kinds only: `Request`, `Response`, `Entity`.
- **Request** — input from a Controller. A `record` carrying Jakarta validation annotations. Never a JPA entity.
- **Response** — output to a Controller. A `record` with explicit static factories (`from`, `fromList`) that map from Entity.
- **Entity** — persistence model. Lives in `entity/`, used by Mapper/Repository and Manager. Never crosses into a Controller or Service signature.
Use Spring Validation. The trigger is `@org.springframework.validation.annotation.Validated` (Spring), applied at the Controller class level and on each `@RequestBody` parameter. The constraint annotations on `record` components come from the `spring-boot-starter-validation` dependency (technically the Jakarta Bean Validation API, but consumed as part of Spring's validation chain).
```java
publicrecordUpdateUserRequest(
@Size(min=1,max=100)StringdisplayName,
@Pattern(regexp="ACTIVE|DEACTIVATED")Stringstatus
){}
```
On the Controller side:
-`@Validated` on the class — enables AOP method-level validation for `@PathVariable` / `@RequestParam` constraints
-`@Validated` on each `@RequestBody` parameter — Spring's annotation is preferred over the Jakarta `@Valid` because it supports validation groups
Failures surface as different exceptions depending on what was being validated; the global handler maps them all to 400 `VALIDATION`:
Domain exceptions live in `common/exception/` as plain `RuntimeException` subclasses; the message is safe to return to the client.
## Response Style
Return the response body directly. **No envelope wrapper** (no `code / message / data` triplet) — the HTTP status code carries the success/failure signal, the body carries the data, the headers carry metadata.
- 2xx → response body, or `ResponseEntity` when adding headers
- 4xx / 5xx → handled centrally by `GlobalExceptionHandler`
- For "created" use `ResponseEntity.created(URI).body(response)` so the client gets the resource location
- For "no content" use `@ResponseStatus(HttpStatus.NO_CONTENT)` on a void method
Do not introduce a `Result<T>` or `R<T>` wrapper. The HTTP status code is the wrapper.
## Logging and TraceId
SLF4J + Logback. Declare a `private static final Logger log` per class. Use parameterised logging: `log.info("user created id={}", id)`.
A `OncePerRequestFilter` writes a trace ID into MDC for every request and echoes it on the response.
- Methods that must run in a new transaction (audit log post-commit, async retry, outbox flush): `Propagation.REQUIRES_NEW`
- Never catch exceptions inside a `@Transactional` method and silently swallow them — the transaction will commit, and the operation appears to succeed
- Class-level `@Transactional` is acceptable when **all** methods are transactional; mixed classes should annotate per method
## OpenAPI Documentation
Add `springdoc-openapi-starter-webmvc-ui` and document endpoints with the standard annotations.
Configure grouping and security in an `OpenApiConfig` under `config/`. The UI is at `/swagger-ui.html` by default; lock it down in production via the standard Spring Security rules.
## Anti-patterns
- **Skipping layers** — Controller calling a Mapper directly, Service calling a Client and skipping Manager
- **Returning entities from the Controller** — the wire format must be a `Response` record
- **Putting `@Transactional` on a Controller** — Controller methods are not transactional in this architecture
- **Using Lombok** — Java 21 records and constructor injection replace it; `@Data` on entities is forbidden
- **Defining a `UserService` interface paired with a `UserServiceImpl`** — Service is a concrete class; multi-variant logic is extracted as Variants
- **Defining a generic `R<T>` / `Result<T>` wrapper** — the HTTP status code is the wrapper
- **Sharing a single `Util` class across layers** — it becomes a junk drawer; promote it to a Client or Manager if it has a clear role
- **Cross-Manager calls** — go through the other Service, not directly through the other Manager
- **`@Autowired` field injection** — use constructor injection (implicit on a single public constructor)
- **`RestTemplate` for new code** — use `RestClient` (synchronous) or `WebClient` (reactive)
- **Mutating a `record`** — they are immutable; build a new instance instead
- **Catching `Exception` in a Controller** — the global handler is the single place to map exceptions to responses
- **Adding a URL prefix inside Spring** — versioning is the reverse proxy's job, not the application's
## End-to-End Example
A complete user-resource scaffold under the layer-based layout: