springboot-patterns
Spring Boot and Java best practices for SpecFlux backend. Use when developing REST APIs, services, repositories, or any Java code. Applies DDD architecture, OpenAPI-first workflow, transaction management, and test-driven development.
$ Installieren
git clone https://github.com/cliangdev/specflux-backend /tmp/specflux-backend && cp -r /tmp/specflux-backend/.claude/skills/springboot-patterns ~/.claude/skills/specflux-backend// tip: Run this command in your terminal to install the skill
SKILL.md
name: springboot-patterns description: Spring Boot and Java best practices for SpecFlux backend. Use when developing REST APIs, services, repositories, or any Java code. Applies DDD architecture, OpenAPI-first workflow, transaction management, and test-driven development.
Spring Boot Development Patterns
Spring Boot and Java best practices for SpecFlux backend. Use when developing REST APIs, services, repositories, or any Java code.
Development Workflow
ALWAYS follow this order when implementing API features:
1. OpenAPI First
- Update
src/main/resources/openapi/api.yamlwith endpoint definition - Define request/response DTOs in the spec
- Run
./mvnw compileto generate interfaces and DTOs
2. Write Controller Test First
- Create test in
src/test/java/.../interfaces/rest/{Domain}ControllerTest.java - Extend
AbstractControllerIntegrationTest - Test happy path + error cases + auth scenarios
- Run test to see it fail (red)
3. Implement Controller
- Create controller implementing generated
{Domain}Apiinterface - Use
@Overrideon all interface methods - Delegate all logic to application service
4. Implement Application Service
- Create service in
{domain}/application/{Domain}ApplicationService.java - Use
TransactionTemplatefor explicit transactions - Handle validation, business logic, and DTO conversion
5. Run Tests
./mvnw test- all tests must pass./mvnw spotless:check- code formatting
6. Commit
- One commit per logical change
- Use conventional commit format
Project Structure (DDD)
src/main/java/com/specflux/
âââ {domain}/ # One package per domain
â âââ application/ # Application services
â â âââ {Domain}ApplicationService.java
â âââ domain/ # Domain models & repository interfaces
â â âââ {Entity}.java
â â âââ {Entity}Repository.java
â âââ infrastructure/ # JPA repository implementations
â â âââ Jpa{Entity}Repository.java
â âââ interfaces/rest/ # Controllers & mappers
â âââ {Domain}Controller.java
â âââ {Domain}Mapper.java
âââ shared/ # Cross-cutting concerns
âââ domain/ # Base classes (Entity, AggregateRoot)
âââ application/ # Shared services (CurrentUserService, RefResolver)
âââ infrastructure/ # Security, config, web setup
OpenAPI Patterns
File Structure
src/main/resources/openapi/
âââ api.yaml # Main spec (imports others)
âââ projects.yaml # Project endpoints
âââ epics.yaml # Epic endpoints
âââ tasks.yaml # Task endpoints
âââ components/
âââ schemas.yaml # Shared DTOs
âââ responses.yaml # Common error responses
Endpoint Definition
/projects/{projectRef}/tasks:
get:
operationId: listTasks
tags: [tasks]
parameters:
- name: projectRef
in: path
required: true
schema:
type: string
- name: cursor
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 20
responses:
'200':
description: Task list
content:
application/json:
schema:
$ref: '#/components/schemas/TaskListResponse'
DTO Naming
- Request:
Create{Entity}Request,Update{Entity}Request - Response:
{Entity}(singular),{Entity}ListResponse(list) - Generated with
Dtosuffix:CreateTaskRequestDto,TaskDto
Controller Patterns
Standard Controller
@RestController
@RequiredArgsConstructor
public class TaskController implements TasksApi {
private final TaskApplicationService taskApplicationService;
@Override
public ResponseEntity<TaskDto> createTask(
String projectRef,
CreateTaskRequestDto request) {
TaskDto created = taskApplicationService.createTask(projectRef, request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@Override
public ResponseEntity<TaskDto> getTask(String projectRef, String taskRef) {
TaskDto task = taskApplicationService.getTask(projectRef, taskRef);
return ResponseEntity.ok(task);
}
@Override
public ResponseEntity<Void> deleteTask(String projectRef, String taskRef) {
taskApplicationService.deleteTask(projectRef, taskRef);
return ResponseEntity.noContent().build();
}
}
Key Points
- Always
implements {Domain}Api(generated interface) - Use
@Overrideon all methods - Use
@RequiredArgsConstructorfor injection - Return appropriate HTTP status codes
- No business logic in controller
Application Service Patterns
Standard Service
@Service
@RequiredArgsConstructor
public class TaskApplicationService {
private final TaskRepository taskRepository;
private final RefResolver refResolver;
private final CurrentUserService currentUserService;
private final TransactionTemplate transactionTemplate;
public TaskDto createTask(String projectRef, CreateTaskRequestDto request) {
return transactionTemplate.execute(status -> {
// 1. Resolve references
Project project = refResolver.resolveProject(projectRef);
User currentUser = currentUserService.getCurrentUser();
// 2. Validate
if (taskRepository.existsByProjectIdAndTitle(project.getId(), request.getTitle())) {
throw new ResourceConflictException("Task with title already exists");
}
// 3. Create domain object
String publicId = PublicId.generate(EntityType.TASK).getValue();
String displayKey = project.getProjectKey() + "-" + project.nextTaskSequence();
Task task = new Task(publicId, displayKey, project, request.getTitle());
task.setDescription(request.getDescription());
task.setPriority(TaskPriority.fromValue(request.getPriority().getValue()));
// 4. Persist
Task saved = taskRepository.save(task);
// 5. Return DTO
return TaskMapper.toDto(saved);
});
}
public void deleteTask(String projectRef, String taskRef) {
transactionTemplate.executeWithoutResult(status -> {
Project project = refResolver.resolveProject(projectRef);
Task task = refResolver.resolveTask(project, taskRef);
taskRepository.delete(task);
});
}
}
Transaction Patterns
// Return value
return transactionTemplate.execute(status -> {
// ... operations
return result;
});
// No return value
transactionTemplate.executeWithoutResult(status -> {
// ... operations
});
// Manual rollback
transactionTemplate.execute(status -> {
try {
// ... operations
} catch (Exception e) {
status.setRollbackOnly();
throw e;
}
return result;
});
Repository Patterns
Interface (Domain Layer)
public interface TaskRepository extends JpaRepository<Task, Long> {
Optional<Task> findByPublicId(String publicId);
Optional<Task> findByProjectIdAndDisplayKey(Long projectId, String displayKey);
List<Task> findByProjectId(Long projectId);
List<Task> findByEpicId(Long epicId);
boolean existsByProjectIdAndTitle(Long projectId, String title);
@Query("SELECT t FROM Task t WHERE t.project.id = :projectId AND t.status = :status")
List<Task> findByProjectIdAndStatus(
@Param("projectId") Long projectId,
@Param("status") TaskStatus status);
}
Naming Conventions
findBy{Field}- ReturnsOptional<T>for single resultfindBy{Field}- ReturnsList<T>for multiple resultsexistsBy{Field}- ReturnsbooleancountBy{Field}- Returnslong- Use
@Queryfor complex queries
Mapper Patterns
Utility Class Mapper
@UtilityClass
public class TaskMapper {
public TaskDto toDto(Task domain) {
TaskDto dto = new TaskDto();
dto.setId(domain.getPublicId());
dto.setDisplayKey(domain.getDisplayKey());
dto.setProjectId(domain.getProject().getPublicId());
dto.setTitle(domain.getTitle());
dto.setDescription(domain.getDescription());
dto.setStatus(TaskStatusDto.fromValue(domain.getStatus().getValue()));
dto.setPriority(TaskPriorityDto.fromValue(domain.getPriority().getValue()));
dto.setCreatedAt(toOffsetDateTime(domain.getCreatedAt()));
dto.setUpdatedAt(toOffsetDateTime(domain.getUpdatedAt()));
// Nested object (only if loaded)
if (domain.getEpic() != null) {
dto.setEpicId(domain.getEpic().getPublicId());
}
return dto;
}
public TaskDto toDtoSimple(Task domain) {
// Simplified version without nested objects
TaskDto dto = new TaskDto();
dto.setId(domain.getPublicId());
dto.setDisplayKey(domain.getDisplayKey());
dto.setTitle(domain.getTitle());
dto.setStatus(TaskStatusDto.fromValue(domain.getStatus().getValue()));
return dto;
}
private OffsetDateTime toOffsetDateTime(Instant instant) {
return instant != null ? instant.atOffset(ZoneOffset.UTC) : null;
}
}
Key Points
- Use
@UtilityClass(Lombok) for static methods - Always use
publicIdfor external IDs, never internalid - Handle null values gracefully
- Convert
InstanttoOffsetDateTimefor API responses - Create simplified versions for list responses
Entity Patterns
Standard Entity
@Entity
@Table(name = "tasks")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Task extends AggregateRoot<Long> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "public_id", nullable = false, unique = true, length = 24)
private String publicId;
@Column(name = "display_key", nullable = false, length = 20)
private String displayKey;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "project_id", nullable = false)
private Project project;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "epic_id")
private Epic epic;
@Setter
@Column(nullable = false)
private String title;
@Setter
@Column(columnDefinition = "TEXT")
private String description;
@Setter
@Column(nullable = false)
private TaskStatus status = TaskStatus.BACKLOG;
@Setter
@Column(nullable = false)
private TaskPriority priority = TaskPriority.MEDIUM;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
// Constructor for required fields
public Task(String publicId, String displayKey, Project project, String title) {
this.publicId = publicId;
this.displayKey = displayKey;
this.project = project;
this.title = title;
this.createdAt = Instant.now();
this.updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = Instant.now();
}
// Domain methods
public void assignToEpic(Epic epic) {
this.epic = epic;
}
public void markCompleted() {
this.status = TaskStatus.COMPLETED;
}
}
Key Points
@NoArgsConstructor(access = AccessLevel.PROTECTED)for JPA- Use
FetchType.LAZYfor all relationships @Setteronly on mutable fields@PreUpdatefor automatic timestamp updates- Domain methods encapsulate state changes
Test Patterns
Controller Test
class TaskControllerTest extends AbstractControllerIntegrationTest {
@DynamicPropertySource
static void configureSchema(DynamicPropertyRegistry registry) {
configureSchemaForClass(registry, TaskControllerTest.class);
}
private Project testProject;
@BeforeEach
void setUp() {
testProject = projectRepository.save(createTestProject());
}
@Test
void createTask_shouldReturnCreatedTask() throws Exception {
CreateTaskRequestDto request = new CreateTaskRequestDto();
request.setTitle("Implement feature");
request.setPriority(TaskPriorityDto.HIGH);
mockMvc.perform(
post("/api/projects/{ref}/tasks", testProject.getPublicId())
.with(user("testuser"))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.displayKey").exists())
.andExpect(jsonPath("$.title").value("Implement feature"))
.andExpect(jsonPath("$.priority").value("HIGH"))
.andExpect(jsonPath("$.status").value("BACKLOG"));
}
@Test
void createTask_withoutAuth_shouldReturn403() throws Exception {
CreateTaskRequestDto request = new CreateTaskRequestDto();
request.setTitle("Test task");
mockMvc.perform(
post("/api/projects/{ref}/tasks", testProject.getPublicId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isForbidden());
}
@Test
void createTask_withInvalidProject_shouldReturn404() throws Exception {
CreateTaskRequestDto request = new CreateTaskRequestDto();
request.setTitle("Test task");
mockMvc.perform(
post("/api/projects/{ref}/tasks", "invalid_ref")
.with(user("testuser"))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("NOT_FOUND"));
}
@Test
void getTask_shouldReturnTask() throws Exception {
Task task = taskRepository.save(createTestTask(testProject));
mockMvc.perform(
get("/api/projects/{projectRef}/tasks/{taskRef}",
testProject.getPublicId(), task.getPublicId())
.with(user("testuser")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(task.getPublicId()))
.andExpect(jsonPath("$.title").value(task.getTitle()));
}
@Test
void deleteTask_shouldReturn204() throws Exception {
Task task = taskRepository.save(createTestTask(testProject));
mockMvc.perform(
delete("/api/projects/{projectRef}/tasks/{taskRef}",
testProject.getPublicId(), task.getPublicId())
.with(user("testuser")))
.andExpect(status().isNoContent());
assertThat(taskRepository.findByPublicId(task.getPublicId())).isEmpty();
}
// Helper methods
private Project createTestProject() {
return new Project(
PublicId.generate(EntityType.PROJECT).getValue(),
"TEST",
"Test Project",
testUser);
}
private Task createTestTask(Project project) {
return new Task(
PublicId.generate(EntityType.TASK).getValue(),
project.getProjectKey() + "-1",
project,
"Test Task");
}
}
Test Checklist
- Happy path (success case)
- Authentication required (403 without auth)
- Resource not found (404)
- Validation errors (400)
- Conflict errors (409)
- Authorization (403 for wrong user)
Error Handling
Custom Exceptions
// In GlobalExceptionHandler or dedicated exceptions package
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
public class ResourceConflictException extends RuntimeException {
public ResourceConflictException(String message) {
super(message);
}
}
public class AccessDeniedException extends RuntimeException {
public AccessDeniedException(String message) {
super(message);
}
}
Global Handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponseDto> handleNotFound(ResourceNotFoundException ex) {
return buildErrorResponse(ex.getMessage(), "NOT_FOUND", HttpStatus.NOT_FOUND);
}
@ExceptionHandler(ResourceConflictException.class)
public ResponseEntity<ErrorResponseDto> handleConflict(ResourceConflictException ex) {
return buildErrorResponse(ex.getMessage(), "CONFLICT", HttpStatus.CONFLICT);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponseDto> handleValidation(
MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.toList();
ErrorResponseDto error = new ErrorResponseDto();
error.setMessage("Validation failed");
error.setCode("VALIDATION_ERROR");
error.setDetails(errors);
return ResponseEntity.badRequest().body(error);
}
private ResponseEntity<ErrorResponseDto> buildErrorResponse(
String message, String code, HttpStatus status) {
ErrorResponseDto error = new ErrorResponseDto();
error.setMessage(message);
error.setCode(code);
return ResponseEntity.status(status).body(error);
}
}
Database Migration
Creating a Migration
-- V17__add_task_estimated_hours.sql
ALTER TABLE tasks ADD COLUMN estimated_hours INTEGER;
-- Add index if needed for queries
CREATE INDEX idx_tasks_estimated_hours ON tasks(estimated_hours)
WHERE estimated_hours IS NOT NULL;
Migration Checklist
- Use next version number (check existing migrations)
- Double underscore after version:
V{n}__{description}.sql - Test migration locally:
./mvnw flyway:migrate - Ensure migration is reversible (document rollback)
- Add indexes for frequently queried columns
Common Commands
# Build and run
./mvnw clean compile # Compile (generates OpenAPI code)
./mvnw spring-boot:run # Run application
./mvnw test # Run all tests
./mvnw test -Dtest=TaskControllerTest # Run specific test
# Code quality
./mvnw spotless:check # Check formatting
./mvnw spotless:apply # Fix formatting
# Database
./mvnw flyway:migrate # Run migrations
./mvnw flyway:info # Show migration status
Repository

cliangdev
Author
cliangdev/specflux-backend/.claude/skills/springboot-patterns
1
Stars
0
Forks
Updated54m ago
Added6d ago