dotmd
// Config Record

>Java + Spring Boot 3

GitHub Copilot instructions for Java 21 and Spring Boot 3 projects with modern testing, security, and architecture defaults.

author:
dotmd Team
license:CC0
published:Feb 23, 2026
// Installation

>Add this file to your project repository:

  • GitHub Copilot
    .github/copilot-instructions.md
// File Content
copilot-instructions.md
1# Project: Java 21 + Spring Boot 3
2
3## Language & Runtime
4
5- Java 21 — use records, sealed interfaces, pattern matching, text blocks, virtual threads
6- Spring Boot 3.x (Jakarta EE 10 — all imports `jakarta.*`, never `javax.*`)
7- Build: Maven with `spring-boot-starter-parent` or Gradle with Spring Dependency Management plugin
8
9## Java 21 Patterns
10
11**Records for all DTOs and value objects:**
12
13```java
14public record CreateOrderRequest(
15 @NotBlank String customerId,
16 @NotEmpty List<OrderLineRequest> lines) {}
17
18public record OrderResponse(UUID id, String status, Instant createdAt) {}
19```
20
21**Sealed interfaces for domain type hierarchies:**
22
23```java
24public sealed interface PaymentResult {
25 record Success(String transactionId, Instant processedAt) implements PaymentResult {}
26 record Declined(String reason) implements PaymentResult {}
27 record Error(String code, String message) implements PaymentResult {}
28}
29```
30
31**Pattern matching in switch — exhaustive, no default needed with sealed types:**
32
33```java
34return switch (result) {
35 case PaymentResult.Success s -> ResponseEntity.ok(toResponse(s));
36 case PaymentResult.Declined d -> ResponseEntity.unprocessableEntity().body(problem(d.reason()));
37 case PaymentResult.Error e -> ResponseEntity.internalServerError().body(problem(e.message()));
38};
39```
40
41**Text blocks for queries and multi-line strings:**
42
43```java
44@Query("""
45 SELECT o FROM Order o JOIN FETCH o.lines
46 WHERE o.customer.id = :customerId AND o.status IN :statuses
47 """)
48List<Order> findByCustomerAndStatuses(UUID customerId, Set<OrderStatus> statuses);
49```
50
51**Virtual threads — enable in config, no code changes needed:**
52
53```yaml
54spring.threads.virtual.enabled: true
55```
56
57## Package Structure
58
59```
60com.example.ordersvc/
61├── OrderServiceApplication.java
62├── config/ # @Configuration classes
63├── order/ # Feature package
64│ ├── Order.java # @Entity
65│ ├── OrderStatus.java # enum
66│ ├── OrderRepository.java # Spring Data interface
67│ ├── OrderService.java # @Service
68│ ├── OrderController.java # @RestController
69│ └── dto/ # Request/response records
70├── customer/ # Another feature package
71└── common/
72 ├── exception/ # GlobalExceptionHandler, domain exceptions
73 └── web/ # Shared web utilities
74```
75
76Organize by **feature**, not by layer. Each feature owns its controller, service, repository, entities, and DTOs.
77
78## Dependency Injection
79
80**Constructor injection only. No `@Autowired`. No field injection.**
81
82```java
83@Service
84public class OrderService {
85 private final OrderRepository orderRepository;
86 private final PaymentClient paymentClient;
87 private final ApplicationEventPublisher events;
88
89 public OrderService(OrderRepository orderRepository,
90 PaymentClient paymentClient,
91 ApplicationEventPublisher events) {
92 this.orderRepository = orderRepository;
93 this.paymentClient = paymentClient;
94 this.events = events;
95 }
96}
97```
98
99Single constructor — `@Autowired` is not needed.
100
101## REST Controllers
102
103```java
104@RestController
105@RequestMapping("/api/v1/orders")
106public class OrderController {
107 private final OrderService orderService;
108
109 public OrderController(OrderService orderService) {
110 this.orderService = orderService;
111 }
112
113 @PostMapping
114 @ResponseStatus(HttpStatus.CREATED)
115 public OrderResponse create(@Valid @RequestBody CreateOrderRequest request) {
116 return orderService.create(request);
117 }
118
119 @GetMapping("/{id}")
120 public OrderResponse findById(@PathVariable UUID id) {
121 return orderService.findById(id);
122 }
123
124 @GetMapping
125 public PageResponse<OrderResponse> list(
126 @RequestParam(defaultValue = "0") int page,
127 @RequestParam(defaultValue = "20") int size) {
128 return orderService.list(PageRequest.of(page, size, Sort.by("createdAt").descending()));
129 }
130}
131```
132
133- Return records directly — Jackson handles them
134- `@Valid` on `@RequestBody` for bean validation
135- `UUID` for path IDs, not `Long`
136
137## Service Layer
138
139```java
140@Service
141@Transactional(readOnly = true)
142public class OrderService {
143
144 @Transactional
145 public OrderResponse create(CreateOrderRequest request) { /* ... */ }
146
147 public OrderResponse findById(UUID id) {
148 return orderRepository.findById(id)
149 .map(this::toResponse)
150 .orElseThrow(() -> new ResourceNotFoundException("Order", id));
151 }
152}
153```
154
155- Class-level `@Transactional(readOnly = true)`, override with `@Transactional` on writes
156- Never return entities — map to response records
157- Never call `Optional.get()` — use `orElseThrow()`, `map()`, or `ifPresent()`
158
159## Entity Design
160
161```java
162@Entity
163@Table(name = "orders")
164public class Order {
165 @Id
166 @GeneratedValue(strategy = GenerationType.UUID)
167 private UUID id;
168
169 @Column(nullable = false)
170 private String customerId;
171
172 @Enumerated(EnumType.STRING)
173 @Column(nullable = false)
174 private OrderStatus status;
175
176 @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
177 private List<OrderLine> lines = new ArrayList<>();
178
179 @Column(nullable = false, updatable = false)
180 private Instant createdAt;
181
182 @PrePersist
183 void onCreate() { this.createdAt = Instant.now(); }
184
185 protected Order() {} // JPA requires no-arg constructor
186
187 public Order(String customerId) {
188 this.customerId = customerId;
189 this.status = OrderStatus.DRAFT;
190 }
191
192 public void addLine(OrderLine line) {
193 lines.add(line);
194 line.setOrder(this);
195 }
196}
197```
198
199- `GenerationType.UUID` — not AUTO or IDENTITY
200- `EnumType.STRING` — never ORDINAL
201- `Instant` for timestamps — never `LocalDateTime` or `java.util.Date`
202- Entities are mutable classes; records are for DTOs only
203- Encapsulate collections (`addLine()`, not `getLines().add()`)
204
205## Database Migrations — Flyway
206
207```
208src/main/resources/db/migration/
209├── V1__create_orders.sql
210├── V2__create_order_lines.sql
211└── V3__add_order_status_index.sql
212```
213
214```yaml
215spring:
216 flyway:
217 enabled: true
218 locations: classpath:db/migration
219 jpa:
220 hibernate:
221 ddl-auto: validate # Never 'update' or 'create' in production
222```
223
224## Error Handling — RFC 9457 Problem Details
225
226```java
227@RestControllerAdvice
228public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
229
230 @ExceptionHandler(ResourceNotFoundException.class)
231 public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
232 var problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
233 problem.setTitle("Resource Not Found");
234 problem.setProperty("resourceType", ex.getResourceType());
235 return problem;
236 }
237
238 @ExceptionHandler(BusinessRuleException.class)
239 public ProblemDetail handleBusinessRule(BusinessRuleException ex) {
240 var problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
241 problem.setTitle("Business Rule Violation");
242 problem.setProperty("code", ex.getCode());
243 return problem;
244 }
245}
246```
247
248Enable built-in problem details: `spring.mvc.problemdetails.enabled: true`
249
250## Spring Security 6
251
252```java
253@Configuration
254@EnableWebSecurity
255public class SecurityConfig {
256
257 @Bean
258 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
259 return http
260 .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
261 .authorizeHttpRequests(auth -> auth
262 .requestMatchers("/api/v1/public/**").permitAll()
263 .requestMatchers("/actuator/health").permitAll()
264 .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
265 .anyRequest().authenticated())
266 .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
267 .sessionManagement(session -> session
268 .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
269 .build();
270 }
271}
272```
273
274- Lambda DSL only — `.and()` chain is removed in Security 6
275- `@EnableMethodSecurity` for `@PreAuthorize` on methods
276- Never `.permitAll()` on `anyRequest()`
277
278## Testing
279
280### Controller Tests — `@WebMvcTest` (fast, no server)
281
282```java
283@WebMvcTest(OrderController.class)
284class OrderControllerTest {
285 @Autowired private MockMvc mockMvc;
286 @Autowired private ObjectMapper objectMapper;
287 @MockitoBean private OrderService orderService;
288
289 @Test
290 void shouldCreateOrder() throws Exception {
291 var response = new OrderResponse(UUID.randomUUID(), "DRAFT", Instant.now());
292 when(orderService.create(any())).thenReturn(response);
293
294 mockMvc.perform(post("/api/v1/orders")
295 .contentType(APPLICATION_JSON)
296 .content(objectMapper.writeValueAsString(
297 new CreateOrderRequest("cust-1", List.of(new OrderLineRequest("SKU-001", 2))))))
298 .andExpect(status().isCreated())
299 .andExpect(jsonPath("$.status").value("DRAFT"));
300 }
301}
302```
303
304- `@MockitoBean` replaces `@MockBean` (Spring Boot 3.4+)
305- AssertJ for assertions, not Hamcrest
306
307### Integration Tests — `@SpringBootTest` + Testcontainers
308
309```java
310@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
311@Testcontainers
312class OrderIntegrationTest {
313 @Container @ServiceConnection
314 static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
315
316 @Autowired private TestRestTemplate restTemplate;
317
318 @Test
319 void shouldCreateAndRetrieveOrder() {
320 var request = new CreateOrderRequest("cust-1", List.of(new OrderLineRequest("SKU-001", 2)));
321 var created = restTemplate.postForEntity("/api/v1/orders", request, OrderResponse.class);
322 assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
323 }
324}
325```
326
327- `@ServiceConnection` auto-configures datasource — no manual properties needed
328- `@DataJpaTest` + `@AutoConfigureTestDatabase(replace = NONE)` for repository-only tests
329
330## Configuration
331
332Bind config to records with `@ConfigurationProperties`:
333
334```java
335@ConfigurationProperties(prefix = "app.orders")
336public record OrderProperties(
337 int maxLinesPerOrder,
338 Duration processingTimeout,
339 RetryProperties retry
340) {
341 public record RetryProperties(int maxAttempts, Duration backoff) {}
342}
343```
344
345Profile-specific files: `application.yml`, `application-local.yml`, `application-prod.yml`.
346
347## HTTP Clients
348
349Use `RestClient` (Spring 6.1+), not `RestTemplate`. For declarative clients, use `@HttpExchange`:
350
351```java
352@HttpExchange("/api/v1/payments")
353public interface PaymentClient {
354 @PostExchange
355 PaymentResult process(@RequestBody PaymentRequest request);
356
357 @GetExchange("/{id}")
358 PaymentStatus status(@PathVariable String id);
359}
360```
361
362## Observability
363
364```yaml
365management:
366 endpoints.web.exposure.include: health,info,prometheus,metrics
367 endpoint.health.show-details: when_authorized
368```
369
370Use Micrometer `@Observed` for custom metrics. Structured logging:
371
372```java
373log.info("Order created [orderId={}, customerId={}]", order.getId(), order.getCustomerId());
374```
375
376## Commands
377
378```bash
379./mvnw spring-boot:run -Dspring-boot.run.profiles=local # Run
380./mvnw test # Unit tests
381./mvnw verify # Unit + integration
382./mvnw clean package -DskipTests # Build JAR
383./mvnw spring-boot:build-image # OCI image
384./mvnw flyway:migrate # Run migrations
385```
386
387## Style Rules
388
389- `var` for local variables when type is obvious from right-hand side
390- `List.of()`, `Map.of()`, `Set.of()` — immutable by default
391- `java.time` exclusively — no `java.util.Date` or `java.sql.Timestamp`
392- Return `Optional<T>` from lookups — never return null
393- Unchecked domain exceptions only — no checked exceptions in service interfaces
394- `final` on all injected fields
395