|
|
@@ -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 命名来源)
|