Compare commits
11 Commits
3a9a83835b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
2625fac6d8
|
|||
|
a7fa29e3ec
|
|||
|
e10bcfa734
|
|||
|
3be04dab3a
|
|||
|
6beb72688e
|
|||
|
b14493d3ad
|
|||
|
caae678e26
|
|||
|
7f43b55b61
|
|||
|
abf70542c2
|
|||
|
446c5c930f
|
|||
|
87822d08a4
|
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Local Claude Code configuration (per-machine)
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
*.iws
|
||||||
|
*.ipr
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Editor backups
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Guidelines for adding or modifying a skill in this catalogue.
|
||||||
|
|
||||||
|
## Adding a Skill
|
||||||
|
|
||||||
|
1. Pick a short, kebab-case name (`my-skill`).
|
||||||
|
2. Create a directory and a `SKILL.md` inside it:
|
||||||
|
```
|
||||||
|
mkdir my-skill
|
||||||
|
```
|
||||||
|
3. Start `SKILL.md` with the required frontmatter:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: my-skill
|
||||||
|
description: ...
|
||||||
|
---
|
||||||
|
```
|
||||||
|
4. Open a pull request against `main`.
|
||||||
|
|
||||||
|
### Frontmatter
|
||||||
|
|
||||||
|
- `name` must match the directory name. Claude Code uses it to identify the skill.
|
||||||
|
- `description` controls when the skill is invoked. Be specific: name the file types, project shapes, and user requests that should trigger it. A vague description (for example, "helps with Java") will fire too often and add noise.
|
||||||
|
- The body of `SKILL.md` is loaded into the conversation when the skill fires. Keep it dense and self-contained.
|
||||||
|
|
||||||
|
## Modifying a Skill
|
||||||
|
|
||||||
|
Edit the skill's `SKILL.md` in place. Open a PR. If the change alters the skill's public contract (the `description`, the rules, or the inputs/outputs the skill expects), call it out in the PR description so reviewers can decide whether the change should also trigger a version bump in any catalogue that consumes it.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- **British English** is the default for all prose, code comments, and commit messages. Use the `british-english` skill to self-review before requesting review.
|
||||||
|
- Use sentence case in headings ("Adding a skill", not "Adding A Skill").
|
||||||
|
- Code samples should be runnable, or marked clearly when truncated for brevity.
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
Use conventional commits. For this repository:
|
||||||
|
|
||||||
|
| Type | When |
|
||||||
|
|---|---|
|
||||||
|
| `feat` | A new skill, or a new feature within an existing skill |
|
||||||
|
| `fix` | A behavioural correction |
|
||||||
|
| `docs` | README, CONTRIBUTING, LICENCE, or doc-only edits |
|
||||||
|
| `chore` | Tooling, `.gitignore`, build configuration |
|
||||||
|
|
||||||
|
See the `commit-message` skill for the full format.
|
||||||
|
|
||||||
|
## Review
|
||||||
|
|
||||||
|
Reviewers check for:
|
||||||
|
|
||||||
|
- Frontmatter correctness and specificity
|
||||||
|
- Compliance with any cross-cutting rules (for example, the four-layer architecture in the `spring-boot` skill)
|
||||||
|
- British English conformance
|
||||||
|
- A concrete example for every non-trivial claim
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions are licensed under the MIT Licence (see [`LICENCE`](LICENCE)).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Onixbyte
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -4,4 +4,23 @@ This repository stores commonly used skills for Claude Code internally.
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Clone this repository into `~/.claude/skills` with `git clone git@git.onixbyte.com:onixbyte/claude-code-skills ~/.claude/skills`.
|
Clone this repository into `~/.claude/skills` with `git clone git@git.onixbyte.com:onixbyte/claude-code-skills ~/.claude/skills`.
|
||||||
|
|
||||||
|
## Available Skills
|
||||||
|
|
||||||
|
| Skill | Description |
|
||||||
|
|---|---|
|
||||||
|
| `british-english` | Apply British English spelling and grammar as a global default for files, commits, and documentation when no project-level language override is set. |
|
||||||
|
| `commit-message` | Produce conventional-commit messages from the current staged diff, with optional gitmoji support. |
|
||||||
|
| `spring-boot` | Conventions for Spring Boot services on Gradle + Java 21 + Spring Boot 3.5: a strict four-layer architecture (Controller → Service → Manager/Client → Mapper/Repository), with an optional Variant layer for multi-implementation strategies. Covers MyBatis + JPA, Spring Validation, global exception handling, TraceId logging, and SpringDoc OpenAPI. |
|
||||||
|
|
||||||
|
## Adding a Skill
|
||||||
|
|
||||||
|
1. Create a directory named after the skill: `mkdir my-skill/`.
|
||||||
|
2. Add a `SKILL.md` inside it, beginning with the required frontmatter (`name`, `description`).
|
||||||
|
3. Open a pull request against `main`.
|
||||||
|
|
||||||
|
## Repository Hygiene
|
||||||
|
|
||||||
|
- Local Claude Code configuration (`.claude/`) is ignored — keep per-machine settings out of version control.
|
||||||
|
- Each skill lives in its own top-level directory named exactly as the `name` field in its `SKILL.md`.
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
---
|
||||||
|
name: british-english
|
||||||
|
description: Apply British English spelling and grammar as a global default whenever a project does not explicitly declare its language in CLAUDE.md or AGENT.md. Invoke when creating or editing files, writing commit messages, generating documentation, or producing any user-facing text in a project that has no project-level language override. Covers -ise endings, -our/-re/-ogue spellings, doubled-l verb forms, noun/verb pairs such as practise/license, and other British–American differences.
|
||||||
|
---
|
||||||
|
|
||||||
|
# British English
|
||||||
|
|
||||||
|
Conventions for writing in British English, as distinct from American English.
|
||||||
|
|
||||||
|
## Spelling
|
||||||
|
|
||||||
|
### -ise not -ize
|
||||||
|
Use -ise endings (the dominant British convention outside Oxford University Press):
|
||||||
|
|
||||||
|
organise, realise, recognise, apologise, specialise, summarise, emphasise, authorise, categorise, prioritise, criticise, customise, finalise, minimise, optimise, standardise, sympathise, visualise
|
||||||
|
|
||||||
|
**Exception:** -yse is always British (never -yze): analyse, paralyse, breathalyse, catalyse
|
||||||
|
|
||||||
|
### -our not -or
|
||||||
|
colour, favour, honour, humour, labour, behaviour, neighbour, glamour, odour, rigour, valour, flavour, harbour, vapour, savour, endeavour
|
||||||
|
|
||||||
|
### -re not -er
|
||||||
|
centre, fibre, theatre, metre, litre, lustre, sombre, sabre, calibre, manoeuvre, spectre, reconnoitre
|
||||||
|
|
||||||
|
### -ence/-ence not -ense
|
||||||
|
defence, offence, pretence, licence (noun) / license (verb), practice (noun) / practise (verb)
|
||||||
|
|
||||||
|
**Remember:** noun = c, verb = s. "The doctor's **practice**. She **practises** medicine. A driving **licence**. You are **licensed** to drive."
|
||||||
|
|
||||||
|
### -ogue not -og
|
||||||
|
catalogue, dialogue, monologue, analogue, prologue, epilogue, travelogue
|
||||||
|
|
||||||
|
### Doubled consonants
|
||||||
|
British doubles the final consonant before suffixes regardless of stress:
|
||||||
|
|
||||||
|
travelled, travelling, traveller, cancelled, cancelling, modelled, modelling, labelled, labelling, counselled, counselling, marvellous, signalling, worshipped, jewellery, fulfilled, skilful, wilful, enrol, instalment
|
||||||
|
|
||||||
|
### Programme vs program
|
||||||
|
- **programme** = broadcast, event, plan, schedule
|
||||||
|
- **program** = computer software only
|
||||||
|
|
||||||
|
### Other British spellings
|
||||||
|
aluminium, aeroplane, grey, tyre, mould, plough, sulphur, pyjamas, cheque (bank), draught (beer/air), kerb (road edge), storey (of a building), sceptic, artefact, mediaeval (older British form; medieval is now standard in modern usage), annexe (noun), furore
|
||||||
|
|
||||||
|
## Punctuation
|
||||||
|
|
||||||
|
### Quotation marks
|
||||||
|
- **Single quotes** for primary quotations: 'like this'
|
||||||
|
- **Double quotes** for quotes within quotes: 'He said "hello" and left'
|
||||||
|
|
||||||
|
### Logical punctuation
|
||||||
|
Place commas and full stops **outside** quotation marks unless they are part of the quoted material:
|
||||||
|
|
||||||
|
- The minister called the proposal 'absurd'.
|
||||||
|
- She asked, 'What time is it?' (question mark is part of the quote)
|
||||||
|
- 'I shall return,' he said. (comma is part of the quoted speech)
|
||||||
|
|
||||||
|
### Oxford comma
|
||||||
|
Optional in British English. Generally **omit** unless needed to prevent ambiguity:
|
||||||
|
|
||||||
|
- "red, white and blue" (standard)
|
||||||
|
- "my parents, the Queen, and the Prime Minister" (needed to avoid implying your parents are the Queen and PM)
|
||||||
|
|
||||||
|
### Dashes
|
||||||
|
Use spaced en dashes for parenthetical statements:
|
||||||
|
- "The policy – introduced last year – has been revised." (note the spaces around the en dash)
|
||||||
|
|
||||||
|
## Vocabulary
|
||||||
|
|
||||||
|
| British | American |
|
||||||
|
|---|---|
|
||||||
|
| boot | trunk |
|
||||||
|
| bonnet | hood |
|
||||||
|
| windscreen | windshield |
|
||||||
|
| lorry | truck |
|
||||||
|
| petrol | gas/gasoline |
|
||||||
|
| motorway | freeway/highway |
|
||||||
|
| pavement | sidewalk |
|
||||||
|
| car park | parking lot |
|
||||||
|
| number plate | license plate |
|
||||||
|
| flat | apartment |
|
||||||
|
| ground floor | first floor |
|
||||||
|
| first floor | second floor |
|
||||||
|
| lift | elevator |
|
||||||
|
| torch | flashlight |
|
||||||
|
| wardrobe | closet |
|
||||||
|
| cooker | stove |
|
||||||
|
| bin | trash can |
|
||||||
|
| rubbish | garbage/trash |
|
||||||
|
| garden | yard |
|
||||||
|
| post | mail |
|
||||||
|
| postbox | mailbox |
|
||||||
|
| postcode | zip code |
|
||||||
|
| mobile (phone) | cell phone |
|
||||||
|
| maths | math |
|
||||||
|
| full stop | period |
|
||||||
|
| university | college (informal) |
|
||||||
|
| term | semester |
|
||||||
|
| head teacher | principal |
|
||||||
|
| marks | grades |
|
||||||
|
| revision | review (studying) |
|
||||||
|
| biscuit | cookie |
|
||||||
|
| crisps | potato chips |
|
||||||
|
| chips | French fries |
|
||||||
|
| aubergine | eggplant |
|
||||||
|
| courgette | zucchini |
|
||||||
|
| coriander | cilantro |
|
||||||
|
| sweets | candy |
|
||||||
|
| fizzy drink | soda |
|
||||||
|
| takeaway | takeout |
|
||||||
|
| queue | line |
|
||||||
|
| holiday | vacation |
|
||||||
|
| fortnight | two weeks |
|
||||||
|
| chemist | drugstore |
|
||||||
|
| shop | store |
|
||||||
|
| bill | check (restaurant) |
|
||||||
|
| nappy | diaper |
|
||||||
|
| dummy | pacifier |
|
||||||
|
| autumn | fall |
|
||||||
|
| aluminium | aluminum |
|
||||||
|
| tyre | tire |
|
||||||
|
| grey | gray |
|
||||||
|
|
||||||
|
## Dates and Times
|
||||||
|
|
||||||
|
- **Day-Month-Year:** 15 March 2026 or 15/03/2026
|
||||||
|
- No comma between month and year
|
||||||
|
- 24-hour clock in formal writing: 14:30
|
||||||
|
- 12-hour clock with lowercase: 2.30pm (note: full stop not colon, no space before am/pm)
|
||||||
|
|
||||||
|
## Grammar
|
||||||
|
|
||||||
|
### Collective nouns as plural
|
||||||
|
When emphasising the individuals in a group, use plural verbs:
|
||||||
|
- "The government **are** divided on this issue."
|
||||||
|
- "The team **are** playing well."
|
||||||
|
- "The committee **have** decided."
|
||||||
|
|
||||||
|
Singular is also acceptable when the group acts as a unit: "The company **is** based in London."
|
||||||
|
|
||||||
|
### Got not gotten
|
||||||
|
- "I've **got** a new car." (possession — standard British)
|
||||||
|
- "She's **got** better at it." (become — standard British)
|
||||||
|
- Never use "gotten" — it's American.
|
||||||
|
|
||||||
|
### Shall vs will
|
||||||
|
- **Shall** for first-person offers and suggestions: "Shall I open the window?" "Shall we go?"
|
||||||
|
- **Will** for simple future: "I will be there at three."
|
||||||
|
- In formal/legal writing, "shall" indicates obligation (though "must" is increasingly preferred).
|
||||||
|
|
||||||
|
### Past participles — irregular forms preferred
|
||||||
|
learnt, dreamt, burnt, spoilt, smelt, spelt, knelt, leant (not learned, dreamed, burned, etc. — though both accepted)
|
||||||
|
|
||||||
|
### Prepositions
|
||||||
|
- "at the weekend" (not "on the weekend")
|
||||||
|
- "in hospital" (not "in the hospital")
|
||||||
|
- "at university" (not "in college")
|
||||||
|
- "different from" or "different to" (not "different than")
|
||||||
|
- "fill in a form" (not "fill out")
|
||||||
|
- "write to someone" (not "write someone")
|
||||||
|
|
||||||
|
### Present perfect for recent events
|
||||||
|
- "I've just eaten." (not "I just ate.")
|
||||||
|
- "Have you seen it?" (not "Did you see it?" — for recent events)
|
||||||
|
|
||||||
|
### Needn't
|
||||||
|
"You needn't worry." is standard British alongside "You don't need to worry."
|
||||||
|
|
||||||
|
## Measurements
|
||||||
|
|
||||||
|
Metric is official, but imperial persists in specific contexts:
|
||||||
|
- **Miles** for road distances and speed (mph)
|
||||||
|
- **Feet and inches** for human height
|
||||||
|
- **Stones and pounds** for body weight (1 stone = 14 pounds)
|
||||||
|
- **Pints** for beer, cider, and milk
|
||||||
|
- **Celsius** for temperature
|
||||||
|
- **Kilograms** for other weights
|
||||||
|
- **Litres** for fuel (but miles per gallon for economy)
|
||||||
|
- **A4 paper** (210 x 297mm), not Letter size
|
||||||
|
|
||||||
|
## Currency
|
||||||
|
- Pound sign before the figure: £50, £1,200
|
||||||
|
- Pence with p: 50p
|
||||||
|
- No space between symbol and number
|
||||||
|
- "Per cent" as two words when spelled out; % symbol with no space: 10%
|
||||||
|
|
||||||
|
## Authoritative Sources
|
||||||
|
|
||||||
|
- New Oxford Style Manual (Oxford University Press)
|
||||||
|
- Guardian and Observer Style Guide
|
||||||
|
- Cambridge Guide to English Usage
|
||||||
|
- Fowler's Modern English Usage
|
||||||
|
- GOV.UK Style Guide
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "Claude Code Skills",
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"name": "british-english",
|
||||||
|
"description": "Apply British English spelling and grammar conventions to project documents and code",
|
||||||
|
"repository": "https://onixbyte.dev/onixbyte/claude-code-skills",
|
||||||
|
"branch": "main",
|
||||||
|
"path": "british-english"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "commit-message",
|
||||||
|
"description": "Generate well-formatted git commit messages following conventional commits",
|
||||||
|
"repository": "https://onixbyte.dev/onixbyte/claude-code-skills",
|
||||||
|
"branch": "main",
|
||||||
|
"path": "commit-message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "spring-boot",
|
||||||
|
"description": "Add JPA CRUD resources following Spring Boot layered architecture",
|
||||||
|
"repository": "https://onixbyte.dev/onixbyte/claude-code-skills",
|
||||||
|
"branch": "main",
|
||||||
|
"path": "spring-boot"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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, 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.
|
||||||
|
|
||||||
|
```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
|
||||||
|
├── 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.
|
||||||
|
|
||||||
|
```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
|
||||||
Reference in New Issue
Block a user