Compare commits

...

11 Commits

Author SHA1 Message Date
siujamo 2625fac6d8 feat: register british-english, commit-message, and spring-boot skills 2026-06-22 11:46:08 +08:00
siujamo a7fa29e3ec feat: add skills.json marketplace manifest 2026-06-22 11:23:09 +08:00
siujamo e10bcfa734 @
docs(british-english): remove redundant guidance line

The "Follow these rules consistently throughout a document." line
added no real directive beyond what the rule sections already convey,
and the "a document" framing was too narrow for outputs like commit
messages and code comments.
@
2026-06-16 17:21:31 +08:00
siujamo 3be04dab3a docs(spring-boot): align examples with documented conventions
Two consistency cleanups:

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

Also drops the now-orphaned 阿里巴巴《Java 开发手册》 line from Authoritative References, since the rest of the skill no longer leans on it for terminology.
2026-06-16 15:36:12 +08:00
siujamo 6beb72688e docs: add CONTRIBUTING guide
Document the process for adding or modifying a skill, frontmatter requirements, style (British English), commit conventions, and the licence under which contributions are accepted.
2026-06-16 15:24:54 +08:00
siujamo b14493d3ad docs: add LICENCE (MIT)
Add the MIT Licence text, copyright Onixbyte 2026, to make the terms under which the skills catalogue is shared explicit.
2026-06-16 15:24:53 +08:00
siujamo caae678e26 docs: expand README
Add an Available Skills table summarising each skill's purpose, plus contributor notes covering the per-skill directory layout and the rationale for ignoring .claude/.
2026-06-16 15:22:06 +08:00
siujamo 7f43b55b61 chore: expand .gitignore
Add common IDE, OS, and editor-backup exclusions to keep per-machine artefacts out of version control alongside the existing .claude/ entry.
2026-06-16 15:22:03 +08:00
siujamo abf70542c2 chore: add .claude/ to gitignore
Claude Code's local settings.local.json lives under .claude/ and is per-machine; keep it out of version control.
2026-06-16 15:11:51 +08:00
siujamo 446c5c930f feat: add skill spring-boot
Adds a Spring Boot architecture skill for Gradle + Java 21 + Spring Boot 3.5 with strict layer-based packaging and a four-layer call graph (Controller → Service → Manager/Client → Mapper/Repository), with an optional Variant layer for multi-implementation strategies.

Covers: layer responsibilities and call direction rules; project layout (no per-feature packages, no URL prefix — reverse proxy handles versioning); MyBatis + JPA coexistence (MyBatis for complex JOINs / dynamic SQL / reports, JPA for single-table CRUD, shared entity); transfer objects as Request / Response / Entity records; annotation-first MyBatis with XML fallback in resources/mapper; Spring Validation via @Validated at class and parameter level; global exception handling with TraceId; HTTP status code as the response wrapper (no R<T> envelope); TraceId MDC filter; @Transactional on Service with readOnly for queries; SpringDoc OpenAPI; 11 anti-patterns. Forbids Lombok; encourages Java 21 records, var, text blocks, pattern matching, and sealed types.
2026-06-16 15:08:31 +08:00
siujamo 87822d08a4 feat: add skill british-english
Adds a British English writing-conventions skill covering -ise/-our/-re/-ence/-ogue spellings, doubled-l verb forms, programme vs program, punctuation (single quotes, logical punctuation, spaced en dashes, Oxford comma), vocabulary, dates/times, grammar (collective nouns, shall/will, prepositions, present perfect), measurements and currency. Polishes a few entries on second pass: doubled-l wording, mediaeval note, en-dash example, and 'informal' label.
2026-06-16 14:06:35 +08:00
7 changed files with 1168 additions and 1 deletions
+22
View File
@@ -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
*~
+61
View File
@@ -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)).
+21
View File
@@ -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.
+20 -1
View File
@@ -4,4 +4,23 @@ This repository stores commonly used skills for Claude Code internally.
## 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`.
+193
View File
@@ -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 BritishAmerican 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
View File
@@ -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"
}
]
}
+825
View File
@@ -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 &gt; #{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