Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in ai

Claude Code template cho Spring Boot: mổ xẻ repo của Piotr Mińkowski

Tung Bui Thanh (TungDaDev)15 mins read

Architectural blueprints with drafting tools

Bảo Claude Code "tạo cho mình một REST API Spring Boot có PostgreSQL", nhiều khả năng bạn sẽ nhận về một project chạy được. Có điều nhìn kỹ thì hay gặp mấy thứ quen quen: @Autowired ngay trên field, @Data của Lombok, RestTemplate, test thì dùng @MockBean. Toàn là cách viết phổ biến trên internet vài năm trước. Model học từ chỗ đó nên viết theo số đông, chứ không theo phiên bản bạn đang dùng.

Muốn nó viết đúng chuẩn team thì phải dặn trước. claude-ai-spring-boot của Piotr Mińkowski (tác giả blog piotrminkowski.com, viết về Spring khá đều tay) là một bộ "dặn trước" như vậy, đóng gói thành template: clone về, mở Claude Code, mô tả app cần làm.

Bài này mình làm hai việc. Một là mở từng phần của template ra xem nó vận hành ra sao trong Claude Code. Hai là đóng vai tech lead review: chép nguyên code mẫu trong skill ra chạy trên Spring Boot 4.1.1, kéo image Paketo về thử, đọc kỹ file quyền đi kèm. Kết quả có vài chỗ đáng sửa trước khi đem về dùng.

Bài dựa trên bản v1.0 của repo (commit a5d33af, tháng 10/2026). Template thay đổi khá nhanh, bài gốc của tác giả hồi tháng 3 còn ghi 8 agent và 9 skill, giờ đã gọn lại thành 6 agent và 7 skill.

# template gồm những gì

Cấu trúc repo rất mỏng: không có dòng Java nào, chỉ có chỉ dẫn cho Claude.

.
├── .claude
│   ├── agents
│   │   ├── code-reviewer.md
│   │   ├── devops-engineer.md
│   │   ├── docker-expert.md
│   │   ├── kubernetes-specialist.md
│   │   ├── security-engineer.md
│   │   └── spring-boot-engineer.md
│   ├── settings.local.json
│   └── skills
│       ├── code-quality/SKILL.md
│       ├── design-patterns/SKILL.md
│       ├── java-modern/SKILL.md
│       ├── jpa-patterns/SKILL.md
│       ├── logging-patterns/SKILL.md
│       ├── spring-boot
│       │   ├── SKILL.md
│       │   └── references
│       │       ├── cloud.md
│       │       ├── data.md
│       │       ├── security.md
│       │       ├── testing.md
│       │       └── web.md
│       └── testing-patterns/SKILL.md
├── .claude-plugin/plugin.json
├── CLAUDE.md
└── pom.xml

pom.xml gần như trống, chỉ có groupId pl.piomin.services và artifactId. Nó giống tờ giấy kẻ ô sẵn, chờ Claude điền vào. Cây thư mục trên còn thiếu README của skills và .gitignore. Mình cố ý vẽ thêm settings.local.json vì file này có chuyện riêng, nói ở phần sau.

Sáu agent chia việc theo vai trò như một team nhỏ:

AgentViệc chínhModel
spring-boot-engineerViết app theo kiến trúc controller, service, repositorysonnet
code-reviewerReview code: null safety, transaction, N+1, API cũopus
security-engineerSpring Security, OWASP, SSL Bundlesopus
kubernetes-specialistDeployment, probe, ConfigMap/Secret, Skaffoldsonnet
devops-engineerPipeline CircleCI, Paketo Buildpackssonnet
docker-expertDockerfile multi-stage, Docker Composesonnet

Cách chia model khá hợp lý: hai việc cần soi kỹ (review code, bảo mật) chạy opus, việc viết và cấu hình chạy sonnet cho rẻ và nhanh. Agent nào cũng có đủ Read, Write, Edit, Bash, Glob, Grep, kể cả hai agent review. Mình sẽ quay lại chỗ này.

# ba tầng nạp context

Muốn dùng template cho đúng thì phải biết cái gì nạp vào context lúc nào. Mình có viết kỹ hơn ở bài 12 tính năng Claude Code. Tóm lại với repo này thì như sau.

CLAUDE.md luôn được nạp ở đầu mỗi phiên. File này chỉ khoảng 2.100 ký tự nên rất rẻ.

Skill thì nạp kiểu bậc thang. Lúc đầu phiên Claude chỉ thấy name và description trong frontmatter. Mình đếm thử: description của 7 skill cộng lại khoảng 1.500 ký tự, của 6 agent khoảng 1.200 ký tự. Phần thân SKILL.md chỉ được đọc khi Claude thấy việc đang làm khớp với description. Còn mấy file trong references/ thì nằm sâu thêm một tầng: SKILL.md của spring-boot có bảng "Reference Guide" kiểu "đụng tới testing thì đọc references/testing.md", tới lúc cần Claude mới mở ra.

Đây là chỗ thiết kế của template làm tốt. Toàn bộ nội dung skill và reference là khoảng 154 nghìn ký tự, ước chừng 38 nghìn token nếu tính thô 4 ký tự một token. Nhồi hết vào CLAUDE.md thì phiên nào cũng gánh từng ấy, kể cả khi bạn chỉ hỏi sửa một dòng log. Chia tầng thì phần luôn nằm trong context chỉ khoảng 1 nghìn token.

Agent thì khác skill ở chỗ nó chạy trong context riêng. Gọi code-reviewer thì việc đọc diff, grep code diễn ra trong context của agent đó, phiên chính chỉ nhận lại bản báo cáo. Review một PR lớn mà không làm đầy context của phiên đang code là nhờ vậy.

Đổi lại, bậc thang này có một cái giá mà phần chạy thử bên dưới sẽ lộ ra: thông tin nằm càng sâu thì càng dễ bị bỏ qua.

# CLAUDE.md: quy trình làm việc và luật của dự án

CLAUDE.md chia làm hai nửa khá rõ.

Nửa đầu là quy trình làm việc, không dính gì tới Spring. Việc nào từ 3 bước trở lên thì vào plan mode trước. Chưa chứng minh chạy được thì chưa báo xong ("Would a staff engineer approve this?"). Có thêm một vòng tự sửa: mỗi lần bị người dùng chỉnh, Claude ghi bài học vào tasks/lessons.md và đọc lại ở đầu phiên sau. Mình thấy ý này hay: Claude Code không tự nhớ giữa các phiên, nên bài học phải nằm trong một file trong repo, ai trong team cũng đọc và sửa được.

Nửa sau là luật dự án, và đây là chỗ bạn chắc chắn phải sửa trước khi dùng:

- Always use the latest versions of dependencies.
- Always use Maven for dependency management.
- Always create test cases for the generated code both positive and negative.
- Always generate the CircleCI pipeline in the .circleci directory to verify the code.
- Use `pl.piomin.services` as the group ID for the Maven project and base Java package.
- Do not use the Lombok library.
- Generate the Docker Compose file to run all components used by the application.

pl.piomin.services và CircleCI là lựa chọn riêng của tác giả. Team bạn dùng GitHub Actions hay GitLab CI thì sửa ở đây, đồng thời sửa luôn trong spring-boot-engineer.md, vì agent này chép lại nguyên bộ luật ở mục "Mandatory Rules". Hai chỗ cùng ghi một luật thì sửa một chỗ quên chỗ kia là chuyện sớm muộn.

Dòng "Always use the latest versions" cũng đáng nghĩ. Trong settings.local.json có sẵn quyền cho Claude curl file maven-metadata.xml của spring-boot-starter-parent, tức là nó tự tra bản Boot mới nhất trước khi tạo project. Tạo project mới thì tiện. Nhưng với project đang chạy, một AI tự nâng version mỗi lần sinh code là cách nhanh nhất để có một PR vừa thêm tính năng vừa lên major version. Theo mình nên đổi thành "dùng đúng version trong pom.xml, chỉ nâng khi được yêu cầu".

# chạy thử code mẫu trên spring boot 4.1.1

Template tuyên bố dành cho Spring Boot 4.x, nên mình kiểm đúng cái mà Claude sẽ học theo: code mẫu trong skill. Project tạo từ start.spring.io với Boot 4.1.1, Java 21, các dependency web, data-jpa, validation, postgresql, testcontainers. Code main chép nguyên từ skills/spring-boot/SKILL.md (entity Product, service, controller, record DTO, GlobalExceptionHandler). Test lấy từ SKILL.md và testing-patterns/SKILL.md.

# lần 1: compile không qua

Test slice trong spring-boot/SKILL.md viết thế này:

@WebMvcTest(ProductController.class)
class ProductControllerTest {
    @Autowired MockMvc mockMvc;
    @MockBean ProductService service;

Skill không ghi import, nên Claude sẽ tự điền theo thói quen, là các package của Boot 3. Kết quả:

[ERROR] ProductControllerTest.java:[11,63] package org.springframework.boot.test.autoconfigure.web.servlet does not exist
[ERROR] ProductControllerTest.java:[12,50] package org.springframework.boot.test.mock.mockito does not exist
[ERROR]   symbol: class WebMvcTest
[ERROR]   symbol:   class MockBean

Có hai thay đổi của Boot 4 ở đây. @MockBean bị deprecate từ Boot 3.4 và đã bị gỡ hẳn ở Boot 4, thay bằng @MockitoBean của Spring Framework (org.springframework.test.context.bean.override.mockito). Còn các annotation test slice thì chuyển theo module mới của Boot 4. Mình lục trong jar để lấy package đúng:

org/springframework/boot/webmvc/test/autoconfigure/WebMvcTest.class            <- spring-boot-webmvc-test-4.1.1.jar
org/springframework/boot/data/jpa/test/autoconfigure/DataJpaTest.class         <- spring-boot-data-jpa-test-4.1.1.jar
org/springframework/boot/jdbc/test/autoconfigure/AutoConfigureTestDatabase.class <- spring-boot-jdbc-test-4.1.1.jar
org/springframework/boot/resttestclient/autoconfigure/AutoConfigureRestTestClient.class
org/springframework/test/context/bean/override/mockito/MockitoBean.class       <- spring-test-7.0.9.jar

@MockBean xuất hiện ở cả spring-boot/SKILL.md, testing-patterns/SKILL.md lẫn references/testing.md, ở testing.md còn được giới thiệu trong bảng annotation là "Add mock bean to Spring context". Điều trớ trêu là agent code-reviewer lại có luật bắt lỗi API cũ như TestRestTemplate hay @DynamicPropertySource, nhưng không có dòng nào bắt @MockBean. Tức là agent viết code học theo mẫu cũ, còn agent review thì không được dặn để bắt.

# lần 2: ObjectMapper của Jackson nào

testing-patterns/SKILL.md có test controller @Autowired private ObjectMapper objectMapper;. Viết theo thói quen thì import com.fasterxml.jackson.databind.ObjectMapper:

[ERROR] ObjectMapperSliceTest.java:[3,38] package com.fasterxml.jackson.databind does not exist

Boot 4 chuyển mặc định sang Jackson 3, package đổi thành tools.jackson.databind. Đổi import sang tools.jackson.databind.ObjectMapper là test slice chạy được. Lỗi này không nằm hẳn trong skill vì skill không ghi import. Nhưng như vậy càng cho thấy template nên nói rõ chuyện này ra, vì không dặn thì model sẽ điền import của Jackson 2.

# lần 3: bảng chưa có

Sửa xong import, test repository và test integration (Testcontainers với @ServiceConnection) chạy và cùng chết một kiểu:

WARN  org.hibernate.orm.jdbc.error : ERROR: relation "products" does not exist
[ERROR] Tests run: 4, Failures: 0, Errors: 2, Skipped: 0

Lỗi này không phải bug của Boot. Với database thật (không phải H2 nhúng), mặc định spring.jpa.hibernate.ddl-auto là none, nên không ai tạo bảng cả. Template có lời giải, nhưng nằm ở tầng sâu nhất: một file application-test.yml với ddl-auto: create-drop trong references/testing.md, còn Flyway thì ở references/data.md. Test integration trong skill có @ActiveProfiles("test"), còn test @DataJpaTest thì không. Thêm file đó và profile vào thì:

[INFO] Tests run: 1, Failures: 0, Errors: 0 -- in pl.piomin.services.ProductRepositoryTest
[INFO] Tests run: 1, Failures: 0, Errors: 0 -- in pl.piomin.services.ProductIntegrationTest
[INFO] Tests run: 1, Failures: 0, Errors: 0 -- in pl.piomin.services.ObjectMapperSliceTest
[INFO] Tests run: 1, Failures: 0, Errors: 0 -- in pl.piomin.services.ProductControllerTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Còn một chi tiết nhỏ mà hay: application-test.yml của template khai báo url: jdbc:h2:mem:testdb và driver-class-name: org.h2.Driver, trong khi project không có H2. Test vẫn pass, vì connection details từ @ServiceConnection được ưu tiên hơn property spring.datasource.*, nên Boot nối vào container Postgres. Chạy thì được, nhưng người đọc file cấu hình sẽ tưởng test chạy trên H2.

Ngoài ra, code mẫu giữa các skill cũng không khớp nhau. Trong spring-boot/SKILL.md, ProductService.create() trả về ProductResponse. Trong testing-patterns/SKILL.md, cũng hàm đó lại trả về entity Product. Integration test còn gọi GET /api/v1/products/{id} và DELETE, hai endpoint mà controller mẫu không có. Con người đọc thấy lệch sẽ tự hiểu đó là ví dụ. Model thì có thể học theo cả hai cách rồi trộn vào nhau.

Đây cũng là lý do vòng "Verification Before Done" trong CLAUDE.md rất đáng giá. Claude chạy ./mvnw verify, thấy lỗi compile, rồi tự sửa. Template vẫn ra được app chạy được, như bài gốc của tác giả đã cho thấy. Nhưng mỗi vòng sửa như vậy tốn token và thời gian, trong khi chỉ cần sửa mẫu là tránh được từ đầu.

# preStop hook không chạy trên image paketo

Phần này dễ lọt qua review nhất, vì lỗi không làm hỏng build mà chỉ lộ ra lúc rolling update.

Agent kubernetes-specialist và references/cloud.md cùng khuyên hai thứ. Một là build image bằng Paketo Buildpacks qua ./mvnw spring-boot:build-image, "không cần Dockerfile". Hai là gắn preStop hook để pod chờ một chút trước khi tắt:

        lifecycle:
          preStop:
            exec:
              command: ["sh", "-c", "sleep 10"]

Mình lục trong spring-boot-buildpack-platform-4.1.1.jar thì builder mặc định của Boot 4.1.1 là paketobuildpacks/builder-noble-java-tiny. Run image của dòng "tiny" được làm tối giản để giảm bề mặt tấn công, và thử là thấy ngay:

$ docker run --rm --entrypoint sh paketobuildpacks/ubuntu-noble-run-tiny:latest -c "echo has-shell"
exec: "sh": executable file not found in $PATH: unknown.
 
$ docker run --rm --entrypoint sleep paketobuildpacks/ubuntu-noble-run-tiny:latest 1
exec: "sleep": executable file not found in $PATH: unknown.

Không có sh, cũng không có sleep. Hook exec chạy lỗi thì Kubernetes chỉ ghi một event FailedPreStopHook rồi gửi SIGTERM luôn. Mười giây chờ để endpoint kịp gỡ pod khỏi Service biến mất, và đúng lúc deploy sẽ có vài request rơi vào pod đang tắt. Ghép hai lời khuyên đúng lại với nhau thì ra một cấu hình sai, mà sai một cách lặng lẽ.

Từ Kubernetes 1.30 (beta, bật mặc định) và GA ở 1.34 đã có sẵn hành động sleep cho lifecycle hook. Kubelet tự chờ, không cần binary nào trong image:

spec:
  terminationGracePeriodSeconds: 45
  containers:
  - name: app
    lifecycle:
      preStop:
        sleep:
          seconds: 10

Dòng terminationGracePeriodSeconds ở trên cũng là một chỗ thiếu của template. Thời gian chạy preStop được tính bên trong grace period, mà grace period mặc định là 30 giây. Template đặt preStop 10 giây cộng spring.lifecycle.timeout-per-shutdown-phase: 30s. Trường hợp xấu nhất có request chạy lâu, Spring muốn chờ tới 30 giây nhưng pod chỉ còn 20 giây là bị SIGKILL. Cứ để grace period lớn hơn tổng preStop và timeout shutdown là an toàn.

# settings.local.json đi theo repo

Repo có .gitignore liệt kê đúng .claude/settings.local.json, nhưng file này đã được commit từ trước. Gitignore không gỡ được file đã được track, nên clone về là có nó:

{
  "permissions": {
    "allow": [
      "Bash(mvn:*)",
      "Bash(java:*)",
      "Bash(docker exec:*)",
      "Bash(docker-compose up:*)",
      "Bash(git commit:*)",
      ...
    ]
  }
}

Theo tài liệu settings, settings.local.json là cấu hình "project local", độ ưu tiên còn cao hơn .claude/settings.json dùng chung. Clone template tức là bạn nhận luôn một bộ quyền do người khác bật sẵn: Claude được git commit mà không hỏi, được docker exec vào container bất kỳ. Còn mvn:* và java:* thì gần như là cho chạy code tuỳ ý (một plugin Maven trong pom.xml là chạy được bất cứ thứ gì).

File này nhiều khả năng chỉ là quyền tác giả bấm "allow" trong lúc làm việc, rồi lỡ commit lên. Nhưng chính vì vậy mà mình nên xoá nó sau khi clone, rồi tự quyết định quyền nào chia sẻ cho cả team qua .claude/settings.json.

# cài như plugin thì sao

Repo có .claude-plugin/plugin.json, nên dễ tưởng là cài như một plugin được. Mình chạy validator:

$ claude plugin validate .
⚠ Found 1 warning:
  ❯ root: CLAUDE.md at the plugin root is not loaded as project context.
    To ship context with your plugin, use a skill (skills/<name>/SKILL.md) instead.
✔ Validation passed with warnings

Nghĩa là nếu cài dạng plugin thì toàn bộ luật trong CLAUDE.md sẽ không có tác dụng. Thêm nữa, theo layout chuẩn của plugin, agent và skill phải nằm ở agents/ và skills/ ngay gốc plugin, hoặc được khai báo đường dẫn trong plugin.json. Ở repo này chúng nằm dưới .claude/, và plugin.json không khai báo gì. Mô tả trong plugin.json cũng còn ghi "Spring Boot 3.x support". Cách dùng đúng của repo này hiện giờ vẫn là clone làm template, đúng như README hướng dẫn.

# đem về dùng cho team: nên sửa gì

Template này đáng dùng. Cách chia tầng context hợp lý, mỗi agent một vai rõ ràng, có vòng tự kiểm chứng, và phần lớn các luật (constructor injection, RestClient, @ServiceConnection, Problem Details) đúng với Spring Boot hiện đại. Nhưng nên coi nó là bản nháp đầu tiên của chính team mình, chứ không phải một gói cài vào là xong. Mình sẽ làm thế này:

  1. Fork về, xoá .claude/settings.local.json, tạo .claude/settings.json với những quyền team đồng ý chia sẻ.
  2. Sửa luật dự án trong CLAUDE.md cho đúng với team: group ID, CI, chính sách version. Xoá phần chép lại trong spring-boot-engineer.md, chỉ trỏ về CLAUDE.md.
  3. Thêm một mục ngắn ghi các thay đổi của Boot 4 ngay vào CLAUDE.md, vì đây là thứ model hay sai nhất mà lại rẻ nhất để dặn:
## Spring Boot 4 facts
- Mocks in Spring tests: `@MockitoBean` (org.springframework.test.context.bean.override.mockito). `@MockBean` no longer exists.
- Jackson 3: `tools.jackson.databind.ObjectMapper` / `JsonMapper`, not `com.fasterxml.jackson.databind`.
- Test slices moved: `org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest`,
  `org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest`.
- Schema is managed by Flyway; tests never rely on `ddl-auto`.
  1. Sửa code mẫu trong các skill cho thống nhất (cùng một ProductService, đủ endpoint mà test gọi tới), đổi @MockBean thành @MockitoBean, rồi thêm dòng "Flag @MockBean" vào checklist của code-reviewer.
  2. Đổi preStop sang sleep action, thêm terminationGracePeriodSeconds.
  3. Bỏ Write và Edit khỏi code-reviewer và security-engineer. Reviewer chỉ cần đọc và báo cáo, cho quyền sửa thì có ngày nó "tiện tay" sửa luôn trong lúc review.

Còn một việc mình thấy đáng làm nhất: cho chính template một bài kiểm tra. Tạo một project nhỏ dùng đúng code mẫu trong skill, chạy ./mvnw verify trong CI mỗi khi sửa skill. Skill là code mà người đọc là model. Code thì phải có test, không có thì sẽ lặp lại đúng chuyện @MockBean ở trên khi Spring Boot lên bản tiếp theo.

# tham khảo


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.