--- 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, status, created_at FROM users WHERE id = #{id} """) Optional findById(Long id); @Select(""" SELECT id, email, display_name, status, created_at 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 ├── domain/ │ ├── 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