feat: add skill spring-boot
Adds a Spring Boot architecture skill for Gradle + Java 21 + Spring Boot 3.5 with strict layer-based packaging and a four-layer call graph (Controller → Service → Manager/Client → Mapper/Repository), with an optional Variant layer for multi-implementation strategies. Covers: layer responsibilities and call direction rules; project layout (no per-feature packages, no URL prefix — reverse proxy handles versioning); MyBatis + JPA coexistence (MyBatis for complex JOINs / dynamic SQL / reports, JPA for single-table CRUD, shared entity); transfer objects as Request / Response / Entity records; annotation-first MyBatis with XML fallback in resources/mapper; Spring Validation via @Validated at class and parameter level; global exception handling with TraceId; HTTP status code as the response wrapper (no R<T> envelope); TraceId MDC filter; @Transactional on Service with readOnly for queries; SpringDoc OpenAPI; 11 anti-patterns. Forbids Lombok; encourages Java 21 records, var, text blocks, pattern matching, and sealed types.
This commit is contained in:
@@ -0,0 +1,825 @@
|
||||
---
|
||||
name: spring-boot
|
||||
description: Build Spring Boot services in a strict layered architecture (Controller → Service → Manager/Client → Mapper/Repository, with an optional Variant layer for multi-implementation strategies at the same level as Manager) on Gradle + Java 21 + Spring Boot 3.5. Use when creating, scaffolding or refactoring Spring Boot projects, designing REST endpoints, organising service code, or whenever a project follows this layered pattern. Covers MyBatis + JPA persistence (MyBatis for complex JOINs / dynamic SQL / reports; JPA for single-table CRUD on a clear domain model), transfer objects (Request / Response / Entity), Spring Validation, global exception handling, HTTP-status response style, TraceId logging, transaction boundaries, and SpringDoc OpenAPI. Encourages Java 21 syntax sugar (records, var, text blocks, pattern matching, sealed types) and forbids Lombok.
|
||||
---
|
||||
|
||||
# Spring Boot
|
||||
|
||||
Conventions for building Spring Boot services on Gradle + Java 21 + Spring Boot 3.5, organised as a strict four-layer architecture.
|
||||
|
||||
## Layer Architecture
|
||||
|
||||
The call graph is one-directional. No layer may skip its parent.
|
||||
|
||||
```
|
||||
Controller → Service → Manager → Mapper / Repository
|
||||
↘
|
||||
Client
|
||||
↘
|
||||
Variant (same level as Manager)
|
||||
```
|
||||
|
||||
- **Controller** — HTTP boundary. Parses requests, validates input, dispatches to a single Service method. Returns the response body directly, or `ResponseEntity` when headers or status code choice matter.
|
||||
- **Service** — business orchestration. Holds `@Transactional` boundaries. Composes Managers, Clients, and Variants into use cases.
|
||||
- **Manager** — atomic persistence operations and shared business helpers. The only layer permitted to depend on `Mapper` and `Repository`. No HTTP types, no Controller DTOs.
|
||||
- **Client** — wrappers around external services and internal infrastructure (JWT signing, S3, message queues, Redis, distributed locks, third-party APIs).
|
||||
- **Variant** — a slot for one implementation of a strategy or extension point. Lives at the same level as Manager and is wired into Service when a use case has multiple variants (payment methods, file processors, notification channels).
|
||||
- **Mapper / Repository** — MyBatis `Mapper` and JPA `Repository` interfaces. Pure persistence, no business logic.
|
||||
|
||||
Allowed call directions:
|
||||
|
||||
- Controller → Service → Manager → Mapper/Repository
|
||||
- Controller → Service → Client (Client never calls Mapper)
|
||||
- Controller → Service → Variant (Variant may call Manager, Client, or another Variant)
|
||||
- Service never calls Controller; Manager never calls Service; Mapper/Repository never calls Manager
|
||||
- Client may call another Client for protocol adaptation
|
||||
- Manager may call a Client when a data operation needs external context (for example, a uniqueness check against an external system)
|
||||
- Cross-Manager calls are forbidden — go through Service
|
||||
|
||||
## Project Layout
|
||||
|
||||
Layer-based packages under a fixed root. There are no per-feature sub-packages — all Controllers live under `controller/`, all Services under `service/`, and so on. Naming a class carries the resource (for example, `UserController`, `OrderService`, `PaymentVariant`).
|
||||
|
||||
```
|
||||
com.example.app
|
||||
├── App.java # @SpringBootApplication
|
||||
├── controller/
|
||||
├── service/
|
||||
├── manager/
|
||||
├── variant/ # strategy / multi-implementation slots
|
||||
│ └── impl/ # named after the strategy they implement
|
||||
├── client/
|
||||
├── mapper/ # MyBatis
|
||||
├── repository/ # JPA
|
||||
├── entity/ # JPA @Entity, also used as MyBatis PO
|
||||
├── domain
|
||||
│ ├── request/
|
||||
│ └── response/
|
||||
├── enums/
|
||||
├── common/ # cross-cutting: exceptions, config, utils
|
||||
└── config/
|
||||
```
|
||||
|
||||
Keep the layer directories flat. If a sub-package becomes necessary inside a layer (typically only `variant/impl/`), keep it shallow.
|
||||
|
||||
## Gradle Build
|
||||
|
||||
`build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
java
|
||||
id("org.springframework.boot") version "3.5.0"
|
||||
id("io.spring.dependency-management") version "1.1.6"
|
||||
}
|
||||
|
||||
group = "com.example"
|
||||
version = "0.0.1-SNAPSHOT"
|
||||
|
||||
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3")
|
||||
runtimeOnly("com.mysql:mysql-connector-j")
|
||||
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
|
||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
}
|
||||
|
||||
tasks.withType<Test> { useJUnitPlatform() }
|
||||
```
|
||||
|
||||
`application.yml` lives at `src/main/resources/application.yml`. Use kebab-case keys, two-space indentation, one section per concern (`server`, `spring`, `mybatis`, `springdoc`).
|
||||
|
||||
## Java 21 Syntax Sugar
|
||||
|
||||
Use the language features that arrive for free with the toolchain. They are preferred over verbose alternatives. **Lombok is forbidden** — the toolchain makes it unnecessary.
|
||||
|
||||
- `record` for `Request`, `Response`, and any other immutable DTO
|
||||
- `var` for local variables when the right-hand side makes the type obvious
|
||||
- Text blocks for multi-line strings (SQL, JSON literals, log templates)
|
||||
- Pattern matching for `instanceof` and `switch`
|
||||
- Sealed interfaces for fixed hierarchies of events and error categories
|
||||
- Sequenced collections (`List.reversed()`, `Deque`, `LinkedHashMap`)
|
||||
- `Optional` as a return type for absent values; never as a field on an entity or DTO
|
||||
- `String.formatted` for inline templating
|
||||
|
||||
Example: a sealed event hierarchy with pattern-matching dispatch.
|
||||
|
||||
```java
|
||||
public sealed interface UserEvent permits UserCreated, UserDeactivated { }
|
||||
|
||||
public record UserCreated(Long userId, Instant occurredAt) implements UserEvent { }
|
||||
public record UserDeactivated(Long userId, String reason, Instant occurredAt) implements UserEvent { }
|
||||
|
||||
String describe(UserEvent event) {
|
||||
return switch (event) {
|
||||
case UserCreated(var id, var at) -> "created %d at %s".formatted(id, at);
|
||||
case UserDeactivated(var id, var r, var at) -> "deactivated %d (%s) at %s".formatted(id, r, at);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Controller Layer
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- HTTP concerns only — request decoding, header handling, response assembly
|
||||
- Input validation via Spring Validation (`@Validated` + constraint annotations on the `Request` record)
|
||||
- Calls **exactly one** Service method
|
||||
- Returns the response body directly, or `ResponseEntity<T>` when adding headers / choosing status
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/users")
|
||||
@Validated
|
||||
@Tag(name = "Users")
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
public UserController(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get a user by id")
|
||||
public UserResponse get(@PathVariable Long id) {
|
||||
return userService.get(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> create(@Validated @RequestBody CreateUserRequest req) {
|
||||
var created = userService.create(req);
|
||||
return ResponseEntity
|
||||
.created(URI.create("/users/" + created.id()))
|
||||
.body(created);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable Long id) {
|
||||
userService.delete(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- No URL prefix on the controller's `@RequestMapping`. Path versioning is added by the reverse proxy (Caddy, Nginx, etc.) in front of the service, not by Spring
|
||||
- One Controller per resource aggregate
|
||||
- Never return a generic `Map` or `JsonNode` — define a `Response` record
|
||||
- Never catch exceptions in the Controller; let the global handler do it
|
||||
- `UserService` is injected through the single public constructor — no `@Autowired`
|
||||
- Put `@Validated` on the Controller class to enable method-level validation for `@PathVariable` / `@RequestParam` constraints, and on each `@RequestBody` parameter to validate the bound record
|
||||
|
||||
## Service Layer
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Business orchestration: transaction boundary, cross-Manager composition, Client coordination
|
||||
- Holds `@Transactional`
|
||||
- No HTTP types (`HttpServletRequest`, `ResponseEntity`); no persistence types (`EntityManager`, `SqlSession`)
|
||||
|
||||
Service is a **concrete class** annotated with `@Service`. There is no `interface UserService` paired with a `UserServiceImpl`. If a use case has multiple variants, extract them into a **Variant** (see below) and let the Service pick one at runtime.
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
private final UserManager userManager;
|
||||
private final AuditClient auditClient;
|
||||
|
||||
public UserService(UserManager userManager, AuditClient auditClient) {
|
||||
this.userManager = userManager;
|
||||
this.auditClient = auditClient;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserResponse get(Long id) {
|
||||
var user = userManager.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("User %d not found".formatted(id)));
|
||||
return UserResponse.from(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse create(CreateUserRequest req) {
|
||||
userManager.assertEmailAvailable(req.email());
|
||||
var entity = userManager.insert(UserEntity.fromRequest(req));
|
||||
auditClient.recordUserCreated(entity.getId());
|
||||
return UserResponse.from(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
userManager.deleteById(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Service never reads from a `Mapper` or `Repository` directly — always through a Manager
|
||||
- Read methods use `@Transactional(readOnly = true)`
|
||||
- Write methods use plain `@Transactional` (default propagation `REQUIRED`)
|
||||
- Use constructor injection via the single public constructor — Spring 4.3+ resolves it without `@Autowired`
|
||||
- For multi-variant logic, do **not** introduce an interface on the Service; extract the variants as Variants and inject them by `Map<Key, Variant>` (see the Variant section)
|
||||
|
||||
## Manager Layer
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Atomic persistence operations on one or more Mapper/Repository interfaces
|
||||
- Shared business helpers reused across multiple Services (`assertEmailAvailable`, `findActiveByTenant`, etc.)
|
||||
- Combines JPA and MyBatis access for the same module when both are useful
|
||||
- Returns **Entity** objects, never `Request` or `Response`
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class UserManager {
|
||||
|
||||
private final UserRepository userRepository; // JPA
|
||||
private final UserMapper userMapper; // MyBatis
|
||||
|
||||
public UserManager(UserRepository userRepository, UserMapper userMapper) {
|
||||
this.userRepository = userRepository;
|
||||
this.userMapper = userMapper;
|
||||
}
|
||||
|
||||
public Optional<UserEntity> findById(Long id) {
|
||||
return userRepository.findById(id);
|
||||
}
|
||||
|
||||
public void assertEmailAvailable(String email) {
|
||||
if (userRepository.existsByEmail(email)) {
|
||||
throw new ConflictException("Email %s already in use".formatted(email));
|
||||
}
|
||||
}
|
||||
|
||||
public UserEntity insert(UserEntity entity) {
|
||||
return userRepository.save(entity);
|
||||
}
|
||||
|
||||
public void deleteById(Long id) {
|
||||
userRepository.deleteById(id);
|
||||
}
|
||||
|
||||
/** MyBatis: dynamic search returning entity list. */
|
||||
public List<UserEntity> search(UserSearchCriteria criteria) {
|
||||
return userMapper.search(criteria);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Manager is the **only** layer allowed to call Mapper/Repository
|
||||
- Manager never calls another Manager — call through the other Service
|
||||
- Manager never returns a Controller-layer DTO; map Entity to Response in the Service
|
||||
- Manager methods are atomic: one transaction, one concern, no remote calls
|
||||
|
||||
## Variant Layer
|
||||
|
||||
A Variant is a slot for one implementation of a strategy or extension point that a Service delegates to at runtime. It is **not** a top-level layer — it lives at the same level as Manager and exists only to keep the Service free of branching logic.
|
||||
|
||||
Use a Variant when a use case has multiple variants that share the same input/output shape but differ in implementation: payment methods, file processors, notification channels, discount rules, AI model backends. Do not use a Variant to implement a second copy of a Service; that is what the Variant is itself a way to avoid.
|
||||
|
||||
The interface lives in `variant/`; each implementation lives in `variant/impl/<strategy>/`. The Service injects all implementations as a `Map` keyed by the discriminator and looks one up at call time.
|
||||
|
||||
```java
|
||||
public sealed interface PaymentVariant permits AlipayVariant, StripeVariant, WeChatPayVariant {
|
||||
|
||||
PaymentMethod supports();
|
||||
|
||||
PaymentResult handle(PaymentRequest request);
|
||||
}
|
||||
|
||||
public enum PaymentMethod { ALIPAY, STRIPE, WECHAT_PAY }
|
||||
```
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class AlipayVariant implements PaymentVariant {
|
||||
|
||||
private final AlipayClient alipayClient;
|
||||
private final PaymentManager paymentManager;
|
||||
|
||||
public AlipayVariant(AlipayClient alipayClient, PaymentManager paymentManager) {
|
||||
this.alipayClient = alipayClient;
|
||||
this.paymentManager = paymentManager;
|
||||
}
|
||||
|
||||
@Override public PaymentMethod supports() { return PaymentMethod.ALIPAY; }
|
||||
|
||||
@Override
|
||||
public PaymentResult handle(PaymentRequest request) {
|
||||
paymentManager.assertRequestIdUnique(request.requestId());
|
||||
var response = alipayClient.charge(request.toAlipayCharge());
|
||||
paymentManager.recordCharge(request, response);
|
||||
return PaymentResult.from(response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The Service does the lookup and the exception translation — it never branches on `instanceof`.
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class PaymentService {
|
||||
|
||||
private final Map<PaymentMethod, PaymentVariant> variants;
|
||||
private final PaymentManager paymentManager;
|
||||
|
||||
public PaymentService(List<PaymentVariant> variantList, PaymentManager paymentManager) {
|
||||
this.variants = variantList.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(PaymentVariant::supports, v -> v));
|
||||
this.paymentManager = paymentManager;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PaymentResult pay(PaymentRequest request) {
|
||||
var variant = variants.get(request.method());
|
||||
if (variant == null) {
|
||||
throw new BusinessRuleException("Unsupported payment method: " + request.method());
|
||||
}
|
||||
return variant.handle(request);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- A Variant may call Manager, Client, or another Variant; it never calls Service or Controller
|
||||
- All Variants in a group share the same input record and return the same output record; the discriminator is a field on the input (the `supports()` return value) or a sealed interface
|
||||
- Use a `sealed` interface for the Variant hierarchy so the compiler can verify that every variant is implemented
|
||||
- Spring collects `List<PaymentVariant>` and the Service builds the dispatch map; no manual `@Bean` is needed
|
||||
- The Service handles "no variant found" — never let a `NullPointerException` from a missing map entry reach the Controller
|
||||
|
||||
## Client Layer
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Wrap external HTTP/RPC calls and internal infrastructure (JWT signing, S3, message queues, Redis, distributed locks)
|
||||
- Translate transport exceptions into the project's domain exception types
|
||||
- Configured via `application.yml` (URL, credentials, timeouts); no scattered `@Value` lookups
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class S3Client {
|
||||
|
||||
private final S3ClientConfig config;
|
||||
private final S3Presigner presigner;
|
||||
|
||||
public S3Client(S3ClientConfig config, S3Presigner presigner) {
|
||||
this.config = config;
|
||||
this.presigner = presigner;
|
||||
}
|
||||
|
||||
public String presignUploadUrl(String key, Duration ttl) {
|
||||
var req = PutObjectRequest.builder()
|
||||
.bucket(config.bucket())
|
||||
.key(key)
|
||||
.build();
|
||||
var presigned = presigner.presignPutObject(p -> p
|
||||
.signatureDuration(ttl)
|
||||
.putObjectRequest(req));
|
||||
return presigned.url().toString();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A second common shape is a typed remote API client.
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class PaymentClient {
|
||||
|
||||
private final PaymentClientConfig config;
|
||||
private final RestClient http;
|
||||
|
||||
public PaymentClient(PaymentClientConfig config, RestClient.Builder builder) {
|
||||
this.config = config;
|
||||
this.http = builder.baseUrl(config.baseUrl()).build();
|
||||
}
|
||||
|
||||
public PaymentResult charge(ChargeRequest req) {
|
||||
return http.post()
|
||||
.uri("/v1/charges")
|
||||
.body(req)
|
||||
.retrieve()
|
||||
.body(PaymentResult.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- One Client per external dependency; never share Clients across concerns
|
||||
- Build HTTP clients from `RestClient` (synchronous) or `WebClient` (reactive); avoid the legacy `RestTemplate` for new code
|
||||
- Define the wire-format record (`ChargeRequest`, `PaymentResult`) in the same package as the Client
|
||||
- On 4xx/5xx responses, throw a domain exception (`PaymentDeclinedException`, `UpstreamUnavailableException`); never let the raw HTTP exception leak
|
||||
|
||||
## Persistence: JPA and MyBatis
|
||||
|
||||
Both are present in the project. Choose by the kind of work:
|
||||
|
||||
| Use JPA when | Use MyBatis when |
|
||||
|-----------------------------------------------------------|---------------------------------------------------------------|
|
||||
| Single-table CRUD on a clear domain model | Multi-table JOINs |
|
||||
| Repository methods can be derived from method names | Dynamic SQL whose shape depends on input |
|
||||
| Lifecycle callbacks are useful (`@PrePersist`, etc.) | Reports and read-only projections into non-Entity records |
|
||||
| You want transactional entity state management | The query is a one-off and a stored procedure is preferable |
|
||||
|
||||
Both interfaces live in the same module and may be called from the same Manager.
|
||||
|
||||
### JPA Repository
|
||||
|
||||
```java
|
||||
public interface UserRepository extends JpaRepository<UserEntity, Long> {
|
||||
boolean existsByEmail(String email);
|
||||
List<UserEntity> findByStatus(UserStatus status);
|
||||
}
|
||||
```
|
||||
|
||||
The entity is a plain JPA `@Entity` with explicit getters and setters. **No Lombok.**
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class UserEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 255)
|
||||
private String email;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String displayName;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private UserStatus status;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
protected UserEntity() { } // JPA
|
||||
|
||||
public static UserEntity fromRequest(CreateUserRequest req) {
|
||||
var e = new UserEntity();
|
||||
e.email = req.email();
|
||||
e.displayName = req.displayName();
|
||||
e.status = UserStatus.ACTIVE;
|
||||
e.createdAt = Instant.now();
|
||||
return e;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
public UserStatus getStatus() { return status; }
|
||||
public void setStatus(UserStatus status) { this.status = status; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
```
|
||||
|
||||
A protected no-arg constructor is required by JPA. Static factories (`fromRequest`, `reconstitute`) are the only places outside the persistence framework that construct an entity.
|
||||
|
||||
### MyBatis Mapper
|
||||
|
||||
**Annotation-first.** Reach for XML only when the SQL has dynamic fragments that are unreadable as a method body.
|
||||
|
||||
```java
|
||||
@Mapper
|
||||
public interface UserMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT id, email, display_name AS displayName, status, created_at AS createdAt
|
||||
FROM users
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
Optional<UserEntity> findById(Long id);
|
||||
|
||||
@Select("""
|
||||
SELECT id, email, display_name AS displayName, status, created_at AS createdAt
|
||||
FROM users
|
||||
<where>
|
||||
<if test="email != null">AND email LIKE CONCAT('%', #{email}, '%')</if>
|
||||
<if test="status != null">AND status = #{status}</if>
|
||||
</where>
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
List<UserEntity> search(UserSearchCriteria criteria);
|
||||
}
|
||||
```
|
||||
|
||||
For a mapper method whose SQL must live in XML, declare the method in the interface and put the SQL in `src/main/resources/mapper/UserMapper.xml`. The XML `namespace` and the interface FQN must match.
|
||||
|
||||
```xml
|
||||
<mapper namespace="com.example.app.mapper.UserMapper">
|
||||
<select id="findTopSpenders" resultType="com.example.app.entity.UserEntity">
|
||||
SELECT u.id, u.email, u.display_name AS displayName, SUM(o.amount_cents) AS totalSpent
|
||||
FROM users u
|
||||
JOIN orders o ON o.user_id = u.id
|
||||
GROUP BY u.id
|
||||
HAVING totalSpent > #{threshold}
|
||||
ORDER BY totalSpent DESC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
</mapper>
|
||||
```
|
||||
|
||||
Configure XML locations in `application.yml`.
|
||||
|
||||
```yaml
|
||||
mybatis:
|
||||
mapper-locations: classpath:mapper/**/*.xml
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
```
|
||||
|
||||
### Shared Entity, Different Roles
|
||||
|
||||
A single class is both the JPA `@Entity` and the MyBatis result target. JPA's `@Entity` is just metadata; MyBatis only cares about the property names matching the result column aliases (camelCase). Do not annotate the entity with MyBatis-specific mapping annotations.
|
||||
|
||||
## Transfer Objects
|
||||
|
||||
Three kinds only: `Request`, `Response`, `Entity`.
|
||||
|
||||
- **Request** — input from a Controller. A `record` carrying Jakarta validation annotations. Never a JPA entity.
|
||||
- **Response** — output to a Controller. A `record` with explicit static factories (`from`, `fromList`) that map from Entity.
|
||||
- **Entity** — persistence model. Lives in `entity/`, used by Mapper/Repository and Manager. Never crosses into a Controller or Service signature.
|
||||
|
||||
```java
|
||||
public record CreateUserRequest(
|
||||
@Email @NotBlank @Size(max = 255) String email,
|
||||
@NotBlank @Size(max = 100) String displayName
|
||||
) { }
|
||||
|
||||
public record UserResponse(
|
||||
Long id,
|
||||
String email,
|
||||
String displayName,
|
||||
String status,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static UserResponse from(UserEntity e) {
|
||||
return new UserResponse(
|
||||
e.getId(), e.getEmail(), e.getDisplayName(),
|
||||
e.getStatus().name(), e.getCreatedAt());
|
||||
}
|
||||
|
||||
public static List<UserResponse> from(List<UserEntity> list) {
|
||||
return list.stream().map(UserResponse::from).toList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Use Spring Validation. The trigger is `@org.springframework.validation.annotation.Validated` (Spring), applied at the Controller class level and on each `@RequestBody` parameter. The constraint annotations on `record` components come from the `spring-boot-starter-validation` dependency (technically the Jakarta Bean Validation API, but consumed as part of Spring's validation chain).
|
||||
|
||||
```java
|
||||
public record UpdateUserRequest(
|
||||
@Size(min = 1, max = 100) String displayName,
|
||||
@Pattern(regexp = "ACTIVE|DEACTIVATED") String status
|
||||
) { }
|
||||
```
|
||||
|
||||
On the Controller side:
|
||||
|
||||
- `@Validated` on the class — enables AOP method-level validation for `@PathVariable` / `@RequestParam` constraints
|
||||
- `@Validated` on each `@RequestBody` parameter — Spring's annotation is preferred over the Jakarta `@Valid` because it supports validation groups
|
||||
|
||||
Failures surface as different exceptions depending on what was being validated; the global handler maps them all to 400 `VALIDATION`:
|
||||
|
||||
- `@RequestBody` failure → `MethodArgumentNotValidException`
|
||||
- `@PathVariable` / `@RequestParam` / method-argument failure (with class-level `@Validated`) → `HandlerMethodValidationException` (Spring 6.1+) or `ConstraintViolationException`
|
||||
|
||||
For cross-field rules, declare the validation on the record class with a custom annotation; do not duplicate the rule in the Service.
|
||||
|
||||
## Global Exception Handling
|
||||
|
||||
One `@RestControllerAdvice` per service, in `common/exception/`. Each domain exception maps to a status code and a short error code:
|
||||
|
||||
| Exception | HTTP status | Error code |
|
||||
|--------------------------------------------------------|-------------|-----------------|
|
||||
| `NotFoundException` | 404 | `NOT_FOUND` |
|
||||
| `ConflictException` | 409 | `CONFLICT` |
|
||||
| `BusinessRuleException` | 422 | `BUSINESS_RULE` |
|
||||
| `UpstreamUnavailableException` | 503 | `UPSTREAM` |
|
||||
| `MethodArgumentNotValidException` | 400 | `VALIDATION` |
|
||||
| `HandlerMethodValidationException` | 400 | `VALIDATION` |
|
||||
| `ConstraintViolationException` | 400 | `VALIDATION` |
|
||||
| `Exception` (fallback) | 500 | `INTERNAL` |
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(new ErrorResponse("NOT_FOUND", ex.getMessage(), currentTraceId()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleBodyValidation(MethodArgumentNotValidException ex) {
|
||||
var message = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(e -> e.getField() + " " + e.getDefaultMessage())
|
||||
.collect(Collectors.joining("; "));
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse("VALIDATION", message, currentTraceId()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HandlerMethodValidationException.class)
|
||||
public ResponseEntity<ErrorResponse> handleParamValidation(HandlerMethodValidationException ex) {
|
||||
var message = ex.getAllValidationResults().stream()
|
||||
.flatMap(r -> r.getResolvableErrors().stream()
|
||||
.map(e -> r.getMethodParameter().getParameterName() + " " + e.getDefaultMessage()))
|
||||
.collect(Collectors.joining("; "));
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse("VALIDATION", message, currentTraceId()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public ResponseEntity<ErrorResponse> handleConstraint(ConstraintViolationException ex) {
|
||||
var message = ex.getConstraintViolations().stream()
|
||||
.map(v -> v.getPropertyPath() + " " + v.getMessage())
|
||||
.collect(Collectors.joining("; "));
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse("VALIDATION", message, currentTraceId()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
|
||||
log.error("Unhandled exception", ex);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(new ErrorResponse("INTERNAL", "Internal error", currentTraceId()));
|
||||
}
|
||||
}
|
||||
|
||||
public record ErrorResponse(String code, String message, String traceId) { }
|
||||
```
|
||||
|
||||
Domain exceptions live in `common/exception/` as plain `RuntimeException` subclasses; the message is safe to return to the client.
|
||||
|
||||
## Response Style
|
||||
|
||||
Return the response body directly. **No envelope wrapper** (no `code / message / data` triplet) — the HTTP status code carries the success/failure signal, the body carries the data, the headers carry metadata.
|
||||
|
||||
- 2xx → response body, or `ResponseEntity` when adding headers
|
||||
- 4xx / 5xx → handled centrally by `GlobalExceptionHandler`
|
||||
- For "created" use `ResponseEntity.created(URI).body(response)` so the client gets the resource location
|
||||
- For "no content" use `@ResponseStatus(HttpStatus.NO_CONTENT)` on a void method
|
||||
|
||||
Do not introduce a `Result<T>` or `R<T>` wrapper. The HTTP status code is the wrapper.
|
||||
|
||||
## Logging and TraceId
|
||||
|
||||
SLF4J + Logback. Declare a `private static final Logger log` per class. Use parameterised logging: `log.info("user created id={}", id)`.
|
||||
|
||||
A `OncePerRequestFilter` writes a trace ID into MDC for every request and echoes it on the response.
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class TraceIdFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String HEADER = "X-Trace-Id";
|
||||
private static final String MDC_KEY = "traceId";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest req,
|
||||
HttpServletResponse res,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
var traceId = Optional.ofNullable(req.getHeader(HEADER))
|
||||
.orElseGet(() -> UUID.randomUUID().toString());
|
||||
MDC.put(MDC_KEY, traceId);
|
||||
res.setHeader(HEADER, traceId);
|
||||
try {
|
||||
chain.doFilter(req, res);
|
||||
} finally {
|
||||
MDC.remove(MDC_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The error response includes the trace ID, so a client report can be cross-referenced with the server log.
|
||||
|
||||
## Transactions
|
||||
|
||||
- `@Transactional` on the Service implementation method (not the interface, not the Controller)
|
||||
- Default propagation: `REQUIRED` (Spring's default — omit the annotation parameter)
|
||||
- Read methods: `@Transactional(readOnly = true)`
|
||||
- Methods that must run in a new transaction (audit log post-commit, async retry, outbox flush): `Propagation.REQUIRES_NEW`
|
||||
- Never catch exceptions inside a `@Transactional` method and silently swallow them — the transaction will commit, and the operation appears to succeed
|
||||
- Class-level `@Transactional` is acceptable when **all** methods are transactional; mixed classes should annotate per method
|
||||
|
||||
## OpenAPI Documentation
|
||||
|
||||
Add `springdoc-openapi-starter-webmvc-ui` and document endpoints with the standard annotations.
|
||||
|
||||
```java
|
||||
@Operation(summary = "Get a user by id")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Found"),
|
||||
@ApiResponse(responseCode = "404", description = "Not found",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
})
|
||||
@GetMapping("/{id}")
|
||||
public UserResponse get(@PathVariable Long id) { ... }
|
||||
```
|
||||
|
||||
Configure grouping and security in an `OpenApiConfig` under `config/`. The UI is at `/swagger-ui.html` by default; lock it down in production via the standard Spring Security rules.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Skipping layers** — Controller calling a Mapper directly, Service calling a Client and skipping Manager
|
||||
- **Returning entities from the Controller** — the wire format must be a `Response` record
|
||||
- **Putting `@Transactional` on a Controller** — Controller methods are not transactional in this architecture
|
||||
- **Using Lombok** — Java 21 records and constructor injection replace it; `@Data` on entities is forbidden
|
||||
- **Defining a `UserService` interface paired with a `UserServiceImpl`** — Service is a concrete class; multi-variant logic is extracted as Variants
|
||||
- **Defining a generic `R<T>` / `Result<T>` wrapper** — the HTTP status code is the wrapper
|
||||
- **Sharing a single `Util` class across layers** — it becomes a junk drawer; promote it to a Client or Manager if it has a clear role
|
||||
- **Cross-Manager calls** — go through the other Service, not directly through the other Manager
|
||||
- **`@Autowired` field injection** — use constructor injection (implicit on a single public constructor)
|
||||
- **`RestTemplate` for new code** — use `RestClient` (synchronous) or `WebClient` (reactive)
|
||||
- **Mutating a `record`** — they are immutable; build a new instance instead
|
||||
- **Catching `Exception` in a Controller** — the global handler is the single place to map exceptions to responses
|
||||
- **Adding a URL prefix inside Spring** — versioning is the reverse proxy's job, not the application's
|
||||
|
||||
## End-to-End Example
|
||||
|
||||
A complete user-resource scaffold under the layer-based layout:
|
||||
|
||||
```
|
||||
com.example.app/
|
||||
├── controller/
|
||||
│ └── UserController.java
|
||||
├── service/
|
||||
│ └── UserService.java
|
||||
├── manager/
|
||||
│ └── UserManager.java
|
||||
├── repository/
|
||||
│ └── UserRepository.java (JPA)
|
||||
├── mapper/
|
||||
│ └── UserMapper.java (MyBatis, when needed)
|
||||
├── entity/
|
||||
│ └── UserEntity.java
|
||||
├── request/
|
||||
│ ├── CreateUserRequest.java
|
||||
│ ├── UpdateUserRequest.java
|
||||
│ └── UserSearchCriteria.java
|
||||
├── response/
|
||||
│ └── UserResponse.java
|
||||
└── enums/
|
||||
└── UserStatus.java
|
||||
```
|
||||
|
||||
For a multi-strategy use case (for example, a payment service), add a `variant/` block:
|
||||
|
||||
```
|
||||
com.example.app/
|
||||
├── variant/
|
||||
│ ├── PaymentVariant.java (sealed interface)
|
||||
│ └── impl/
|
||||
│ ├── AlipayVariant.java
|
||||
│ ├── StripeVariant.java
|
||||
│ └── WeChatPayVariant.java
|
||||
└── service/
|
||||
└── PaymentService.java (concrete, dispatches via Map<PaymentMethod, PaymentVariant>)
|
||||
```
|
||||
|
||||
Wiring is by package scan, no extra `@Bean` definitions needed. The `App` class is the only place where `@SpringBootApplication` appears.
|
||||
|
||||
```java
|
||||
@SpringBootApplication
|
||||
public class App {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(App.class, args);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authoritative References
|
||||
|
||||
- Spring Boot 3.5 Reference (Spring docs)
|
||||
- MyBatis-Spring-Boot-Starter 3.x (mybatis.org/spring-boot-starter)
|
||||
- Spring Data JPA Reference
|
||||
- Jakarta Bean Validation 3.0
|
||||
- springdoc-openapi 2.x
|
||||
- 阿里巴巴《Java 开发手册》(层结构、Manager/Client 命名来源)
|
||||
Reference in New Issue
Block a user