| name | springboot-patterns |
| description | Spring Boot and Java best practices. Use when developing REST APIs, services, repositories, or any Java code. Applies DDD architecture, transaction management, and code style conventions. |
Spring Boot Best Practices
Project Structure (Domain-Driven Design)
src/main/java/com/example/
├── {domain}/ # One package per domain
│ ├── domain/ # Domain layer - entities and repository interfaces
│ │ ├── {Entity}.java # JPA entity
│ │ └── {Entity}Repository.java # Spring Data JPA repository interface
│ ├── application/ # Application layer - business logic
│ │ └── {Entity}ApplicationService.java
│ └── interfaces/ # Interface layer - REST controllers
│ └── rest/
│ ├── {Entity}Controller.java
│ └── {Entity}Mapper.java
├── shared/ # Cross-cutting concerns
│ ├── application/
│ ├── domain/
│ └── interfaces/rest/
└── api/generated/ # OpenAPI generated code (do not edit)
Transaction Management
Minimize Transaction Scope
Only wrap the actual database operation in a transaction, not the entire method:
// Good - minimal transaction scope
public EntityDto updateEntity(String ref, UpdateEntityRequestDto request) {
Entity entity = refResolver.resolve(ref);
if (request.getTitle() != null) {
entity.setTitle(request.getTitle());
}
// ... other field updates
Entity saved = transactionTemplate.execute(status -> entityRepository.save(entity));
return entityMapper.toDto(saved);
}
// Bad - entire method in transaction
public EntityDto updateEntity(String ref, UpdateEntityRequestDto request) {
return transactionTemplate.execute(status -> {
Entity entity = refResolver.resolve(ref); // Read doesn't need transaction
// ... field updates don't need transaction
Entity saved = entityRepository.save(entity); // Only this needs transaction
return entityMapper.toDto(saved);
});
}
Use TransactionTemplate, Not @Transactional
Prefer TransactionTemplate over @Transactional annotation for explicit control:
@Service
@RequiredArgsConstructor
public class EntityApplicationService {
private final TransactionTemplate transactionTemplate;
private final EntityRepository entityRepository;
public void deleteEntity(String ref) {
Entity entity = refResolver.resolve(ref);
transactionTemplate.executeWithoutResult(status -> entityRepository.delete(entity));
}
}
When Full Transaction Is Needed
Keep full transaction scope when operations must be atomic:
// Create needs full transaction for sequence number atomicity
public EntityDto createEntity(CreateEntityRequestDto request) {
return transactionTemplate.execute(status -> {
int sequenceNumber = getNextSequenceNumber(); // Read
Entity entity = new Entity(..., sequenceNumber, ...); // Must be atomic
return entityMapper.toDto(entityRepository.save(entity)); // Write
});
}
Lombok Usage
Use @RequiredArgsConstructor for Dependency Injection
Never write constructors manually for Spring beans:
// Good
@Service
@RequiredArgsConstructor
public class EntityApplicationService {
private final EntityRepository entityRepository;
private final RefResolver refResolver;
private final EntityMapper entityMapper;
}
// Bad
@Service
public class EntityApplicationService {
private final EntityRepository entityRepository;
public EntityApplicationService(EntityRepository entityRepository) {
this.entityRepository = entityRepository;
}
}
Standard Lombok Annotations for Entities
@Entity
@Table(name = "entities")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA requires no-arg constructor
public class Entity {
// fields...
// Required-args constructor for creating new instances
public Entity(String publicId, int sequenceNumber, ...) {
// initialization
}
}
OpenAPI Generated Code
DTO Suffix Convention
All generated model classes have Dto suffix to distinguish from domain entities:
- Domain:
Entity,Project,Task - DTOs:
EntityDto,ProjectDto,TaskDto,CreateEntityRequestDto
Never Import with Wildcards
Always use explicit imports:
// Good
import com.example.api.generated.model.EntityDto;
import com.example.api.generated.model.CreateEntityRequestDto;
// Bad
import com.example.api.generated.model.*;
Controller Implementation
Controllers implement generated API interfaces:
@RestController
@RequiredArgsConstructor
public class EntityController implements EntitiesApi {
private final EntityApplicationService entityApplicationService;
@Override
public ResponseEntity<EntityDto> createEntity(CreateEntityRequestDto request) {
EntityDto created = entityApplicationService.createEntity(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}
Mapper Pattern
Mappers convert between domain entities and DTOs:
@Component
public class EntityMapper {
public EntityDto toDto(Entity domain) {
EntityDto dto = new EntityDto();
dto.setPublicId(domain.getPublicId());
dto.setTitle(domain.getTitle());
dto.setStatus(toApiStatus(domain.getStatus()));
// ... map all fields
return dto;
}
public EntityStatus toDomainStatus(EntityStatusDto apiStatus) {
return switch (apiStatus) {
case ACTIVE -> EntityStatus.ACTIVE;
case INACTIVE -> EntityStatus.INACTIVE;
case ARCHIVED -> EntityStatus.ARCHIVED;
};
}
private EntityStatusDto toApiStatus(EntityStatus domainStatus) {
return switch (domainStatus) {
case ACTIVE -> EntityStatusDto.ACTIVE;
case INACTIVE -> EntityStatusDto.INACTIVE;
case ARCHIVED -> EntityStatusDto.ARCHIVED;
};
}
}
Reference Resolution Pattern
Use RefResolver for looking up entities by publicId or displayKey:
@Component
@RequiredArgsConstructor
public class RefResolver {
private final EntityRepository entityRepository;
public Entity resolve(String ref) {
return entityRepository.findByPublicId(ref)
.or(() -> entityRepository.findByKey(ref))
.orElseThrow(() -> new EntityNotFoundException("Entity", ref));
}
// For optional references (can be null or empty string to clear)
public Entity resolveOptional(String ref) {
if (ref == null || ref.isBlank()) {
return null;
}
return resolve(ref);
}
}
Exception Handling
Custom Exceptions
public class EntityNotFoundException extends RuntimeException {
public EntityNotFoundException(String entityType, String reference) {
super(entityType + " not found: " + reference);
}
}
public class ResourceConflictException extends RuntimeException {
public ResourceConflictException(String message) {
super(message);
}
}
Global Exception Handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponseDto> handleNotFound(EntityNotFoundException ex) {
ErrorResponseDto error = new ErrorResponseDto();
error.setCode("NOT_FOUND");
error.setMessage(ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponseDto> handleValidation(MethodArgumentNotValidException ex) {
ErrorResponseDto error = new ErrorResponseDto();
error.setCode("VALIDATION_ERROR");
error.setMessage("Validation failed");
error.setDetails(ex.getBindingResult().getFieldErrors().stream()
.map(fe -> {
FieldErrorDto fieldError = new FieldErrorDto();
fieldError.setField(fe.getField());
fieldError.setMessage(fe.getDefaultMessage());
return fieldError;
})
.toList());
return ResponseEntity.badRequest().body(error);
}
}
Testing Patterns
Integration Tests with Schema Isolation
@AutoConfigureMockMvc
@Transactional
class EntityControllerTest extends AbstractIntegrationTest {
private static final String SCHEMA_NAME = "entity_controller_test";
@DynamicPropertySource
static void configureSchema(DynamicPropertyRegistry registry) {
AbstractIntegrationTest.configureSchema(registry, SCHEMA_NAME);
}
@Autowired private MockMvc mockMvc;
@MockitoBean private CurrentUserService currentUserService;
@BeforeEach
void setUp() {
testUser = userRepository.save(new User(...));
when(currentUserService.getCurrentUser()).thenReturn(testUser);
}
@Test
@WithMockUser(username = "user")
void createEntity_shouldReturnCreatedEntity() throws Exception {
CreateEntityRequestDto request = new CreateEntityRequestDto();
request.setTitle("Test Entity");
mockMvc.perform(post("/entities")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("Test Entity"));
}
}
Code Style
Checkstyle Rules
- No star imports (
import x.y.*) - No unused imports
- No redundant imports
Spotless Formatting
Run mvn spotless:apply before committing.
Switch Expressions
Use modern switch expressions:
// Good
private EntityStatusDto toApiStatus(EntityStatus status) {
return switch (status) {
case ACTIVE -> EntityStatusDto.ACTIVE;
case INACTIVE -> EntityStatusDto.INACTIVE;
case ARCHIVED -> EntityStatusDto.ARCHIVED;
};
}
// Bad
private EntityStatusDto toApiStatus(EntityStatus status) {
switch (status) {
case ACTIVE: return EntityStatusDto.ACTIVE;
case INACTIVE: return EntityStatusDto.INACTIVE;
case ARCHIVED: return EntityStatusDto.ARCHIVED;
default: throw new IllegalArgumentException();
}
}
Build Commands
# Generate OpenAPI code
mvn generate-sources
# Format code
mvn spotless:apply
# Check style
mvn checkstyle:check
# Run tests
mvn test
# Full build
mvn clean verify