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