Files
claude-code-skills/spring-boot/SKILL.md
T
siujamo 3be04dab3a docs(spring-boot): align examples with documented conventions
Two consistency cleanups:

- Drop redundant column aliases in the MyBatis Mapper example; map-underscore-to-camel-case is already enabled in application.yml, so the explicit aliases are noise.
- Re-nest the request/response directories under a top-level domain/ package in the end-to-end example to match the project layout shown earlier in the skill. The transfer-objects section already refers to com.example.app.domain.request.* and com.example.app.domain.response.* implicitly via the layout tree.

Also drops the now-orphaned 阿里巴巴《Java 开发手册》 line from Authoritative References, since the rest of the skill no longer leans on it for terminology.
2026-06-16 15:36:12 +08:00

33 KiB

name, description
name description
spring-boot 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:

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.

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
@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.

@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
@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.

public sealed interface PaymentVariant permits AlipayVariant, StripeVariant, WeChatPayVariant {

    PaymentMethod supports();

    PaymentResult handle(PaymentRequest request);
}

public enum PaymentMethod { ALIPAY, STRIPE, WECHAT_PAY }
@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.

@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
@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.

@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

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.

@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.

@Mapper
public interface UserMapper {

    @Select("""
        SELECT id, email, display_name, status, created_at
        FROM users
        WHERE id = #{id}
        """)
    Optional<UserEntity> findById(Long id);

    @Select("""
        SELECT id, email, display_name, status, created_at
        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.

<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.

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.
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).

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
@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.

@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.

@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
├── 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<PaymentMethod, PaymentVariant>)

Wiring is by package scan, no extra @Bean definitions needed. The App class is the only place where @SpringBootApplication appears.

@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