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.
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
ResponseEntitywhen headers or status code choice matter. - Service — business orchestration. Holds
@Transactionalboundaries. Composes Managers, Clients, and Variants into use cases. - Manager — atomic persistence operations and shared business helpers. The only layer permitted to depend on
MapperandRepository. 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
Mapperand JPARepositoryinterfaces. 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.
recordforRequest,Response, and any other immutable DTOvarfor 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
instanceofandswitch - Sealed interfaces for fixed hierarchies of events and error categories
- Sequenced collections (
List.reversed(),Deque,LinkedHashMap) Optionalas a return type for absent values; never as a field on an entity or DTOString.formattedfor 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 theRequestrecord) - 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
MaporJsonNode— define aResponserecord - Never catch exceptions in the Controller; let the global handler do it
UserServiceis injected through the single public constructor — no@Autowired- Put
@Validatedon the Controller class to enable method-level validation for@PathVariable/@RequestParamconstraints, and on each@RequestBodyparameter 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
MapperorRepositorydirectly — always through a Manager - Read methods use
@Transactional(readOnly = true) - Write methods use plain
@Transactional(default propagationREQUIRED) - 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
RequestorResponse
@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
sealedinterface 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@Beanis needed - The Service handles "no variant found" — never let a
NullPointerExceptionfrom 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@Valuelookups
@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) orWebClient(reactive); avoid the legacyRestTemplatefor 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 > #{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
recordcarrying Jakarta validation annotations. Never a JPA entity. - Response — output to a Controller. A
recordwith 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:
@Validatedon the class — enables AOP method-level validation for@PathVariable/@RequestParamconstraints@Validatedon each@RequestBodyparameter — Spring's annotation is preferred over the Jakarta@Validbecause it supports validation groups
Failures surface as different exceptions depending on what was being validated; the global handler maps them all to 400 VALIDATION:
@RequestBodyfailure →MethodArgumentNotValidException@PathVariable/@RequestParam/ method-argument failure (with class-level@Validated) →HandlerMethodValidationException(Spring 6.1+) orConstraintViolationException
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
ResponseEntitywhen 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
@Transactionalon 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
@Transactionalmethod and silently swallow them — the transaction will commit, and the operation appears to succeed - Class-level
@Transactionalis 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
Responserecord - Putting
@Transactionalon a Controller — Controller methods are not transactional in this architecture - Using Lombok — Java 21 records and constructor injection replace it;
@Dataon entities is forbidden - Defining a
UserServiceinterface paired with aUserServiceImpl— 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
Utilclass 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
@Autowiredfield injection — use constructor injection (implicit on a single public constructor)RestTemplatefor new code — useRestClient(synchronous) orWebClient(reactive)- Mutating a
record— they are immutable; build a new instance instead - Catching
Exceptionin 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