Files
claude-code-skills/spring-boot/SKILL.md
T
siujamo 446c5c930f feat: add skill spring-boot
Adds a Spring Boot architecture skill for Gradle + Java 21 + Spring Boot 3.5 with strict layer-based packaging and a four-layer call graph (Controller → Service → Manager/Client → Mapper/Repository), with an optional Variant layer for multi-implementation strategies.

Covers: layer responsibilities and call direction rules; project layout (no per-feature packages, no URL prefix — reverse proxy handles versioning); MyBatis + JPA coexistence (MyBatis for complex JOINs / dynamic SQL / reports, JPA for single-table CRUD, shared entity); transfer objects as Request / Response / Entity records; annotation-first MyBatis with XML fallback in resources/mapper; Spring Validation via @Validated at class and parameter level; global exception handling with TraceId; HTTP status code as the response wrapper (no R<T> envelope); TraceId MDC filter; @Transactional on Service with readOnly for queries; SpringDoc OpenAPI; 11 anti-patterns. Forbids Lombok; encourages Java 21 records, var, text blocks, pattern matching, and sealed types.
2026-06-16 15:08:31 +08:00

826 lines
34 KiB
Markdown

---
name: spring-boot
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`).
```
com.example.app
├── App.java # @SpringBootApplication
├── controller/
├── service/
├── manager/
├── variant/ # strategy / multi-implementation slots
│ └── impl/ # named after the strategy they implement
├── client/
├── mapper/ # MyBatis
├── repository/ # JPA
├── entity/ # JPA @Entity, also used as MyBatis PO
├── domain
│ ├── request/
│ └── response/
├── enums/
├── common/ # cross-cutting: exceptions, config, utils
└── config/
```
Keep the layer directories flat. If a sub-package becomes necessary inside a layer (typically only `variant/impl/`), keep it shallow.
## Gradle Build
`build.gradle.kts`:
```kotlin
plugins {
java
id("org.springframework.boot") version "3.5.0"
id("io.spring.dependency-management") version "1.1.6"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
repositories { mavenCentral() }
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3")
runtimeOnly("com.mysql:mysql-connector-j")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<Test> { useJUnitPlatform() }
```
`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
- Sequenced collections (`List.reversed()`, `Deque`, `LinkedHashMap`)
- `Optional` as a return type for absent values; never as a field on an entity or DTO
- `String.formatted` for inline templating
Example: a sealed event hierarchy with pattern-matching dispatch.
```java
public sealed interface UserEvent permits UserCreated, UserDeactivated { }
public record UserCreated(Long userId, Instant occurredAt) implements UserEvent { }
public record UserDeactivated(Long userId, String reason, Instant occurredAt) implements UserEvent { }
String describe(UserEvent event) {
return switch (event) {
case UserCreated(var id, var at) -> "created %d at %s".formatted(id, at);
case UserDeactivated(var id, var r, var at) -> "deactivated %d (%s) at %s".formatted(id, r, at);
};
}
```
## Controller Layer
Responsibilities:
- HTTP concerns only — request decoding, header handling, response assembly
- Input validation via Spring Validation (`@Validated` + constraint annotations on the `Request` record)
- Calls **exactly one** Service method
- Returns the response body directly, or `ResponseEntity<T>` when adding headers / choosing status
```java
@RestController
@RequestMapping("/users")
@Validated
@Tag(name = "Users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
@Operation(summary = "Get a user by id")
public UserResponse get(@PathVariable Long id) {
return userService.get(id);
}
@PostMapping
public ResponseEntity<UserResponse> create(@Validated @RequestBody CreateUserRequest req) {
var created = userService.create(req);
return ResponseEntity
.created(URI.create("/users/" + created.id()))
.body(created);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
}
```
Rules:
- 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.
```java
@Service
public class UserService {
private final UserManager userManager;
private final AuditClient auditClient;
public UserService(UserManager userManager, AuditClient auditClient) {
this.userManager = userManager;
this.auditClient = auditClient;
}
@Transactional(readOnly = true)
public UserResponse get(Long id) {
var user = userManager.findById(id)
.orElseThrow(() -> new NotFoundException("User %d not found".formatted(id)));
return UserResponse.from(user);
}
@Transactional
public UserResponse create(CreateUserRequest req) {
userManager.assertEmailAvailable(req.email());
var entity = userManager.insert(UserEntity.fromRequest(req));
auditClient.recordUserCreated(entity.getId());
return UserResponse.from(entity);
}
@Transactional
public void delete(Long id) {
userManager.deleteById(id);
}
}
```
Rules:
- 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`
```java
@Component
public class UserManager {
private final UserRepository userRepository; // JPA
private final UserMapper userMapper; // MyBatis
public UserManager(UserRepository userRepository, UserMapper userMapper) {
this.userRepository = userRepository;
this.userMapper = userMapper;
}
public Optional<UserEntity> findById(Long id) {
return userRepository.findById(id);
}
public void assertEmailAvailable(String email) {
if (userRepository.existsByEmail(email)) {
throw new ConflictException("Email %s already in use".formatted(email));
}
}
public UserEntity insert(UserEntity entity) {
return userRepository.save(entity);
}
public void deleteById(Long id) {
userRepository.deleteById(id);
}
/** MyBatis: dynamic search returning entity list. */
public List<UserEntity> search(UserSearchCriteria criteria) {
return userMapper.search(criteria);
}
}
```
Rules:
- 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.
```java
public sealed interface PaymentVariant permits AlipayVariant, StripeVariant, WeChatPayVariant {
PaymentMethod supports();
PaymentResult handle(PaymentRequest request);
}
public enum PaymentMethod { ALIPAY, STRIPE, WECHAT_PAY }
```
```java
@Component
public class AlipayVariant implements PaymentVariant {
private final AlipayClient alipayClient;
private final PaymentManager paymentManager;
public AlipayVariant(AlipayClient alipayClient, PaymentManager paymentManager) {
this.alipayClient = alipayClient;
this.paymentManager = paymentManager;
}
@Override public PaymentMethod supports() { return PaymentMethod.ALIPAY; }
@Override
public PaymentResult handle(PaymentRequest request) {
paymentManager.assertRequestIdUnique(request.requestId());
var response = alipayClient.charge(request.toAlipayCharge());
paymentManager.recordCharge(request, response);
return PaymentResult.from(response);
}
}
```
The Service does the lookup and the exception translation — it never branches on `instanceof`.
```java
@Service
public class PaymentService {
private final Map<PaymentMethod, PaymentVariant> variants;
private final PaymentManager paymentManager;
public PaymentService(List<PaymentVariant> variantList, PaymentManager paymentManager) {
this.variants = variantList.stream()
.collect(Collectors.toUnmodifiableMap(PaymentVariant::supports, v -> v));
this.paymentManager = paymentManager;
}
@Transactional
public PaymentResult pay(PaymentRequest request) {
var variant = variants.get(request.method());
if (variant == null) {
throw new BusinessRuleException("Unsupported payment method: " + request.method());
}
return variant.handle(request);
}
}
```
Rules:
- 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
## Client Layer
Responsibilities:
- Wrap external HTTP/RPC calls and internal infrastructure (JWT signing, S3, message queues, Redis, distributed locks)
- Translate transport exceptions into the project's domain exception types
- Configured via `application.yml` (URL, credentials, timeouts); no scattered `@Value` lookups
```java
@Component
public class S3Client {
private final S3ClientConfig config;
private final S3Presigner presigner;
public S3Client(S3ClientConfig config, S3Presigner presigner) {
this.config = config;
this.presigner = presigner;
}
public String presignUploadUrl(String key, Duration ttl) {
var req = PutObjectRequest.builder()
.bucket(config.bucket())
.key(key)
.build();
var presigned = presigner.presignPutObject(p -> p
.signatureDuration(ttl)
.putObjectRequest(req));
return presigned.url().toString();
}
}
```
A second common shape is a typed remote API client.
```java
@Component
public class PaymentClient {
private final PaymentClientConfig config;
private final RestClient http;
public PaymentClient(PaymentClientConfig config, RestClient.Builder builder) {
this.config = config;
this.http = builder.baseUrl(config.baseUrl()).build();
}
public PaymentResult charge(ChargeRequest req) {
return http.post()
.uri("/v1/charges")
.body(req)
.retrieve()
.body(PaymentResult.class);
}
}
```
Rules:
- One Client per external dependency; never share Clients across concerns
- Build HTTP clients from `RestClient` (synchronous) or `WebClient` (reactive); avoid the legacy `RestTemplate` for new code
- Define the wire-format record (`ChargeRequest`, `PaymentResult`) in the same package as the Client
- On 4xx/5xx responses, throw a domain exception (`PaymentDeclinedException`, `UpstreamUnavailableException`); never let the raw HTTP exception leak
## Persistence: JPA and MyBatis
Both are present in the project. Choose by the kind of work:
| Use JPA when | Use MyBatis when |
|-----------------------------------------------------------|---------------------------------------------------------------|
| Single-table CRUD on a clear domain model | Multi-table JOINs |
| Repository methods can be derived from method names | Dynamic SQL whose shape depends on input |
| Lifecycle callbacks are useful (`@PrePersist`, etc.) | Reports and read-only projections into non-Entity records |
| You want transactional entity state management | The query is a one-off and a stored procedure is preferable |
Both interfaces live in the same module and may be called from the same Manager.
### JPA Repository
```java
public interface UserRepository extends JpaRepository<UserEntity, Long> {
boolean existsByEmail(String email);
List<UserEntity> findByStatus(UserStatus status);
}
```
The entity is a plain JPA `@Entity` with explicit getters and setters. **No Lombok.**
```java
@Entity
@Table(name = "users")
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 255)
private String email;
@Column(nullable = false, length = 100)
private String displayName;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private UserStatus status;
@Column(nullable = false)
private Instant createdAt;
protected UserEntity() { } // JPA
public static UserEntity fromRequest(CreateUserRequest req) {
var e = new UserEntity();
e.email = req.email();
e.displayName = req.displayName();
e.status = UserStatus.ACTIVE;
e.createdAt = Instant.now();
return e;
}
public Long getId() { return id; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public UserStatus getStatus() { return status; }
public void setStatus(UserStatus status) { this.status = status; }
public Instant getCreatedAt() { return createdAt; }
}
```
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.
```java
@Mapper
public interface UserMapper {
@Select("""
SELECT id, email, display_name AS displayName, status, created_at AS createdAt
FROM users
WHERE id = #{id}
""")
Optional<UserEntity> findById(Long id);
@Select("""
SELECT id, email, display_name AS displayName, status, created_at AS createdAt
FROM users
<where>
<if test="email != null">AND email LIKE CONCAT('%', #{email}, '%')</if>
<if test="status != null">AND status = #{status}</if>
</where>
ORDER BY created_at DESC
""")
List<UserEntity> search(UserSearchCriteria criteria);
}
```
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.
```xml
<mapper namespace="com.example.app.mapper.UserMapper">
<select id="findTopSpenders" resultType="com.example.app.entity.UserEntity">
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 &gt; #{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.
```java
public record CreateUserRequest(
@Email @NotBlank @Size(max = 255) String email,
@NotBlank @Size(max = 100) String displayName
) { }
public record UserResponse(
Long id,
String email,
String displayName,
String status,
Instant createdAt
) {
public static UserResponse from(UserEntity e) {
return new UserResponse(
e.getId(), e.getEmail(), e.getDisplayName(),
e.getStatus().name(), e.getCreatedAt());
}
public static List<UserResponse> from(List<UserEntity> list) {
return list.stream().map(UserResponse::from).toList();
}
}
```
## Validation
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
public record UpdateUserRequest(
@Size(min = 1, max = 100) String displayName,
@Pattern(regexp = "ACTIVE|DEACTIVATED") String status
) { }
```
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`:
- `@RequestBody` failure → `MethodArgumentNotValidException`
- `@PathVariable` / `@RequestParam` / method-argument failure (with class-level `@Validated`) → `HandlerMethodValidationException` (Spring 6.1+) or `ConstraintViolationException`
For cross-field rules, declare the validation on the record class with a custom annotation; do not duplicate the rule in the Service.
## Global Exception Handling
One `@RestControllerAdvice` per service, in `common/exception/`. Each domain exception maps to a status code and a short error code:
| Exception | HTTP status | Error code |
|--------------------------------------------------------|-------------|-----------------|
| `NotFoundException` | 404 | `NOT_FOUND` |
| `ConflictException` | 409 | `CONFLICT` |
| `BusinessRuleException` | 422 | `BUSINESS_RULE` |
| `UpstreamUnavailableException` | 503 | `UPSTREAM` |
| `MethodArgumentNotValidException` | 400 | `VALIDATION` |
| `HandlerMethodValidationException` | 400 | `VALIDATION` |
| `ConstraintViolationException` | 400 | `VALIDATION` |
| `Exception` (fallback) | 500 | `INTERNAL` |
```java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage(), currentTraceId()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleBodyValidation(MethodArgumentNotValidException ex) {
var message = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + " " + e.getDefaultMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION", message, currentTraceId()));
}
@ExceptionHandler(HandlerMethodValidationException.class)
public ResponseEntity<ErrorResponse> handleParamValidation(HandlerMethodValidationException ex) {
var message = ex.getAllValidationResults().stream()
.flatMap(r -> r.getResolvableErrors().stream()
.map(e -> r.getMethodParameter().getParameterName() + " " + e.getDefaultMessage()))
.collect(Collectors.joining("; "));
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION", message, currentTraceId()));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraint(ConstraintViolationException ex) {
var message = ex.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + " " + v.getMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION", message, currentTraceId()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.internalServerError()
.body(new ErrorResponse("INTERNAL", "Internal error", currentTraceId()));
}
}
public record ErrorResponse(String code, String message, String traceId) { }
```
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.
```java
@Component
public class TraceIdFilter extends OncePerRequestFilter {
private static final String HEADER = "X-Trace-Id";
private static final String MDC_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
var traceId = Optional.ofNullable(req.getHeader(HEADER))
.orElseGet(() -> UUID.randomUUID().toString());
MDC.put(MDC_KEY, traceId);
res.setHeader(HEADER, traceId);
try {
chain.doFilter(req, res);
} finally {
MDC.remove(MDC_KEY);
}
}
}
```
The error response includes the trace ID, so a client report can be cross-referenced with the server log.
## Transactions
- `@Transactional` on the Service implementation method (not the interface, not the Controller)
- Default propagation: `REQUIRED` (Spring's default — omit the annotation parameter)
- Read methods: `@Transactional(readOnly = true)`
- 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.
```java
@Operation(summary = "Get a user by id")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Found"),
@ApiResponse(responseCode = "404", description = "Not found",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
@GetMapping("/{id}")
public UserResponse get(@PathVariable Long id) { ... }
```
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:
```
com.example.app/
├── controller/
│ └── UserController.java
├── service/
│ └── UserService.java
├── manager/
│ └── UserManager.java
├── repository/
│ └── UserRepository.java (JPA)
├── mapper/
│ └── UserMapper.java (MyBatis, when needed)
├── entity/
│ └── UserEntity.java
├── request/
│ ├── CreateUserRequest.java
│ ├── UpdateUserRequest.java
│ └── UserSearchCriteria.java
├── response/
│ └── UserResponse.java
└── enums/
└── UserStatus.java
```
For a multi-strategy use case (for example, a payment service), add a `variant/` block:
```
com.example.app/
├── variant/
│ ├── PaymentVariant.java (sealed interface)
│ └── impl/
│ ├── AlipayVariant.java
│ ├── StripeVariant.java
│ └── WeChatPayVariant.java
└── service/
└── PaymentService.java (concrete, dispatches via Map<PaymentMethod, PaymentVariant>)
```
Wiring is by package scan, no extra `@Bean` definitions needed. The `App` class is the only place where `@SpringBootApplication` appears.
```java
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
```
## Authoritative References
- Spring Boot 3.5 Reference (Spring docs)
- MyBatis-Spring-Boot-Starter 3.x (mybatis.org/spring-boot-starter)
- Spring Data JPA Reference
- Jakarta Bean Validation 3.0
- springdoc-openapi 2.x
- 阿里巴巴《Java 开发手册》(层结构、Manager/Client 命名来源)