diff --git a/spring-boot/SKILL.md b/spring-boot/SKILL.md new file mode 100644 index 0000000..be4663f --- /dev/null +++ b/spring-boot/SKILL.md @@ -0,0 +1,825 @@ +--- +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 { 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` 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 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` (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 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 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//`. 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 variants; + private final PaymentManager paymentManager; + + public PaymentService(List 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` 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 { + boolean existsByEmail(String email); + List 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 findById(Long id); + + @Select(""" + SELECT id, email, display_name AS displayName, status, created_at AS createdAt + FROM users + + AND email LIKE CONCAT('%', #{email}, '%') + AND status = #{status} + + ORDER BY created_at DESC + """) + List 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 + + + +``` + +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 from(List 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 handleNotFound(NotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse("NOT_FOUND", ex.getMessage(), currentTraceId())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity 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 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 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 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` or `R` 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` / `Result` 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) +``` + +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 命名来源)