Spring Boot Best Practices Guide 🚀¶
Una guía completa de mejores prácticas para desarrollo backend con Spring Boot, enfocada en código limpio, escalabilidad y mantenibilidad.
📋 Tabla de Contenidos¶
- Inicialización y Estructura del Proyecto
- Dependencias Recomendadas
- Mejores Prácticas de Código
- Configuración y Variables de Entorno
- Inyección de Dependencias
- DTOs y Mapeo
- Validación
- Manejo de Excepciones
- Seguridad
- Documentación de API
- Observabilidad y Monitoreo
- Testing
- Docker y CI/CD
- Arquitectura Limpia
🏗️ Inicialización y Estructura del Proyecto¶
Estructura Recomendada¶
src/
├── main/
│ ├── java/
│ │ └── com/company/project/
│ │ ├── config/ # Configuraciones
│ │ ├── controller/ # Controladores REST
│ │ ├── service/ # Lógica de negocio
│ │ ├── domain/ # Entidades y modelos
│ │ ├── repository/ # Acceso a datos
│ │ ├── dto/ # Data Transfer Objects
│ │ ├── mapper/ # Mappers (MapStruct)
│ │ └── exception/ # Manejo de excepciones
│ └── resources/
│ ├── application.yml
│ ├── application-dev.yml
│ ├── application-prod.yml
│ └── static/
└── test/
Mejores Prácticas de Estructura¶
- ✅ Usar Spring Initializr para inicializar proyectos
- ✅ Separar por capas siguiendo arquitectura hexagonal
- ✅ Multi-módulo para proyectos grandes
- ✅ Profiles por ambiente (dev, staging, prod)
- ✅ Externalizar configuración con variables de entorno
📦 Dependencias Recomendadas¶
Dependencias Core¶
<dependencies>
<!-- Framework Core -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Development Tools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- Lombok - Reduce Boilerplate Code -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- MapStruct - Object Mapping -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
<scope>provided</scope>
</dependency>
<!-- Monitoring & Documentation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
💻 Mejores Prácticas de Código¶
❌ Anti-patrones a Evitar¶
// ❌ NO HACER - Field Injection
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
}
// ❌ NO HACER - Getters/Setters manuales
public class User {
private String name;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
// ❌ NO HACER - Lógica en controladores
@RestController
public class UserController {
public ResponseEntity<User> getUser(Long id) {
User user = userRepository.findById(id).orElse(null);
if (user == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(user);
}
}
✅ Patrones Recomendados¶
// ✅ Constructor Injection con Lombok
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
public UserDto findById(Long id) {
return userRepository.findById(id)
.map(userMapper::toDto)
.orElseThrow(() -> new UserNotFoundException(id));
}
}
// ✅ Lombok para reducir boilerplate
@Entity
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(unique = true)
private String email;
}
// ✅ Controller limpio con delegación
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Validated
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable @Positive Long id) {
UserDto user = userService.findById(id);
return ResponseEntity.ok(user);
}
}
⚙️ Configuración y Variables de Entorno¶
application.yml con Variables de Entorno¶
server:
port: ${SERVER_PORT:8080}
servlet:
context-path: ${CONTEXT_PATH:/api}
spring:
application:
name: ${APP_NAME:my-spring-app}
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/mydb}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:password}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
show-sql: ${JPA_SHOW_SQL:false}
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${JWT_ISSUER_URI:https://auth.example.com}
logging:
level:
com.company.project: ${LOG_LEVEL:INFO}
pattern:
console: "${LOG_PATTERN:%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n}"
management:
endpoints:
web:
exposure:
include: ${ACTUATOR_ENDPOINTS:health,info,metrics}
endpoint:
health:
show-details: ${HEALTH_SHOW_DETAILS:when_authorized}
@ConfigurationProperties para Configuración Tipada¶
@Data
@ConfigurationProperties(prefix = "app")
@Component
public class AppProperties {
private Security security = new Security();
private Pagination pagination = new Pagination();
@Data
public static class Security {
private String jwtSecret = "${JWT_SECRET:default-secret}";
private int jwtExpirationMs = 86400000; // 24 hours
}
@Data
public static class Pagination {
private int defaultPageSize = 20;
private int maxPageSize = 100;
}
}
🔧 Inyección de Dependencias¶
✅ Constructor Injection (Recomendado)¶
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private final NotificationService notificationService;
// Spring inyecta automáticamente todas las dependencias
// Lombok genera el constructor
}
✅ Field Injection solo para Testing¶
@ExtendWith(SpringExtension.class)
@SpringBootTest
class OrderServiceTest {
@MockBean
private OrderRepository orderRepository;
@MockBean
private PaymentService paymentService;
@Autowired
private OrderService orderService;
}
🗺️ DTOs y Mapeo¶
DTO con Validación¶
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateUserRequest {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters")
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Email should be valid")
private String email;
@NotNull(message = "Age is required")
@Min(value = 18, message = "Age must be at least 18")
@Max(value = 120, message = "Age must be less than 120")
private Integer age;
}
MapStruct para Mapeo¶
@Mapper(componentModel = "spring")
public interface UserMapper {
UserDto toDto(User user);
User toEntity(CreateUserRequest request);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
User toEntity(UpdateUserRequest request);
List<UserDto> toDtoList(List<User> users);
}
✅ Validación¶
Validación en Controladores¶
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Validated
public class UserController {
@PostMapping
public ResponseEntity<UserDto> createUser(
@Valid @RequestBody CreateUserRequest request) {
UserDto user = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
@GetMapping
public ResponseEntity<Page<UserDto>> getUsers(
@Valid Pageable pageable,
@RequestParam(required = false) @Size(min = 2) String name) {
Page<UserDto> users = userService.findUsers(name, pageable);
return ResponseEntity.ok(users);
}
}
Validación Personalizada¶
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "Email already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
@Autowired
private UserRepository userRepository;
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
return email != null && !userRepository.existsByEmail(email);
}
}
🚨 Manejo de Excepciones¶
Global Exception Handler¶
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleEntityNotFound(EntityNotFoundException ex) {
log.warn("Entity not found: {}", ex.getMessage());
ErrorResponse error = ErrorResponse.builder()
.timestamp(Instant.now())
.status(HttpStatus.NOT_FOUND.value())
.error("Not Found")
.message(ex.getMessage())
.path(getCurrentPath())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationExceptions(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
ErrorResponse error = ErrorResponse.builder()
.timestamp(Instant.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Validation Failed")
.message("Invalid input parameters")
.validationErrors(errors)
.path(getCurrentPath())
.build();
return ResponseEntity.badRequest().body(error);
}
}
Excepciones Personalizadas¶
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(Long id) {
super("User not found with id: " + id);
}
}
public class EmailAlreadyExistsException extends RuntimeException {
public EmailAlreadyExistsException(String email) {
super("User already exists with email: " + email);
}
}
🔐 Seguridad¶
Configuración de Security¶
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtDecoder(jwtDecoder()))
)
.exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint))
.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
}
}
Autorización a Nivel de Método¶
@Service
@RequiredArgsConstructor
public class UserService {
@PreAuthorize("hasRole('ADMIN') or authentication.name == #userId.toString()")
public UserDto findById(Long userId) {
// Solo admin o el propio usuario puede acceder
}
@PreAuthorize("hasRole('ADMIN')")
public List<UserDto> findAll() {
// Solo admin puede listar todos los usuarios
}
}
📚 Documentación de API¶
Configuración de OpenAPI¶
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("User Management API")
.version("1.0.0")
.description("API for managing users in the system")
.contact(new Contact()
.name("Development Team")
.email("dev@company.com")
)
)
.addSecurityItem(new SecurityRequirement().addList("JWT"))
.components(new Components()
.addSecuritySchemes("JWT", new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
)
);
}
}
Documentación en Controladores¶
@RestController
@RequestMapping("/api/v1/users")
@Tag(name = "Users", description = "User management operations")
@RequiredArgsConstructor
public class UserController {
@Operation(
summary = "Get user by ID",
description = "Retrieves a user by their unique identifier"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "User found"),
@ApiResponse(responseCode = "404", description = "User not found"),
@ApiResponse(responseCode = "403", description = "Access denied")
})
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(
@Parameter(description = "User ID", example = "1")
@PathVariable @Positive Long id) {
return ResponseEntity.ok(userService.findById(id));
}
}
📊 Observabilidad y Monitoreo¶
Actuator Endpoints Personalizados¶
@Component
@Endpoint(id = "custom-health")
public class CustomHealthEndpoint {
private final DatabaseHealthIndicator databaseHealth;
@ReadOperation
public Map<String, Object> health() {
Map<String, Object> health = new HashMap<>();
health.put("status", "UP");
health.put("database", databaseHealth.isHealthy());
health.put("timestamp", Instant.now());
return health;
}
}
Logging Estructurado¶
@Service
@Slf4j
@RequiredArgsConstructor
public class UserService {
public UserDto createUser(CreateUserRequest request) {
MDC.put("operation", "createUser");
MDC.put("email", request.getEmail());
try {
log.info("Creating new user with email: {}", request.getEmail());
User user = userMapper.toEntity(request);
User savedUser = userRepository.save(user);
log.info("User created successfully with id: {}", savedUser.getId());
return userMapper.toDto(savedUser);
} catch (Exception e) {
log.error("Error creating user: {}", e.getMessage(), e);
throw e;
} finally {
MDC.clear();
}
}
}
🧪 Testing¶
Test de Integración¶
@SpringBootTest
@Testcontainers
@Transactional
class UserServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Autowired
private UserService userService;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldCreateUserSuccessfully() {
// Arrange
CreateUserRequest request = CreateUserRequest.builder()
.name("John Doe")
.email("john@example.com")
.age(25)
.build();
// Act
UserDto result = userService.createUser(request);
// Assert
assertThat(result.getId()).isNotNull();
assertThat(result.getName()).isEqualTo("John Doe");
assertThat(result.getEmail()).isEqualTo("john@example.com");
}
}
Test de Controlador¶
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUserWhenValidId() throws Exception {
// Arrange
UserDto user = UserDto.builder()
.id(1L)
.name("John Doe")
.email("john@example.com")
.build();
when(userService.findById(1L)).thenReturn(user);
// Act & Assert
mockMvc.perform(get("/api/v1/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("John Doe"))
.andExpect(jsonPath("$.email").value("john@example.com"));
}
}
🐳 Docker y CI/CD¶
Dockerfile Multi-stage¶
# Build stage
FROM maven:3.9-openjdk-21 AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
# Runtime stage
FROM openjdk:21-jre-slim
WORKDIR /app
# Create non-root user
RUN addgroup --system spring && adduser --system spring --ingroup spring
USER spring:spring
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
GitHub Actions CI/CD¶
name: CI/CD Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- name: Run tests
run: mvn clean verify
- name: Generate test report
uses: dorny/test-reporter@v1
if: success() || failure()
with:
name: Maven Tests
path: target/surefire-reports/*.xml
reporter: java-junit
build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -t myapp:${{ github.sha }} .
docker tag myapp:${{ github.sha }} myapp:latest
🏛️ Arquitectura Limpia¶
Estructura por Capas¶
// Domain Layer - Libre de dependencias de Spring
public class User {
private final UserId id;
private final Email email;
private final Name name;
// Constructor, getters, business logic
}
// Application Layer - Casos de uso
@UseCase
@RequiredArgsConstructor
public class CreateUserUseCase {
private final UserRepository userRepository;
private final EmailService emailService;
public User execute(CreateUserCommand command) {
// Lógica del caso de uso
}
}
// Infrastructure Layer - Implementaciones concretas
@Repository
@RequiredArgsConstructor
public class JpaUserRepository implements UserRepository {
private final SpringDataUserRepository springRepository;
private final UserMapper mapper;
@Override
public User save(User user) {
UserEntity entity = mapper.toEntity(user);
UserEntity saved = springRepository.save(entity);
return mapper.toDomain(saved);
}
}
// Presentation Layer - Controllers
@RestController
@RequiredArgsConstructor
public class UserController {
private final CreateUserUseCase createUserUseCase;
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(@RequestBody CreateUserRequest request) {
CreateUserCommand command = CreateUserCommand.from(request);
User user = createUserUseCase.execute(command);
return ResponseEntity.ok(UserResponse.from(user));
}
}
📝 Checklist de Mejores Prácticas¶
✅ Configuración del Proyecto¶
- [ ] Estructura de paquetes por capas
- [ ] Profiles por ambiente (dev, staging, prod)
- [ ] Variables de entorno para configuración sensible
- [ ] @ConfigurationProperties para configuración tipada
✅ Código Limpio¶
- [ ] Constructor injection con @RequiredArgsConstructor
- [ ] Lombok para reducir boilerplate
- [ ] MapStruct para mapeo de objetos
- [ ] DTOs para APIs públicas
- [ ] Validación con Bean Validation
✅ Manejo de Errores¶
- [ ] @ControllerAdvice para manejo global
- [ ] Excepciones personalizadas
- [ ] Códigos de error consistentes
- [ ] Logging estructurado
✅ Seguridad¶
- [ ] Autenticación JWT stateless
- [ ] Autorización granular
- [ ] Endpoints públicos claramente definidos
- [ ] Configuración de CORS apropiada
✅ Testing¶
- [ ] Tests unitarios con cobertura > 80%
- [ ] Tests de integración con Testcontainers
- [ ] Tests de controladores con @WebMvcTest
- [ ] Naming convention para tests
✅ Observabilidad¶
- [ ] Actuator endpoints habilitados
- [ ] Logging en formato JSON
- [ ] Health checks personalizados
- [ ] Métricas de negocio
✅ Documentación¶
- [ ] OpenAPI/Swagger configurado
- [ ] README.md actualizado
- [ ] Comentarios en código complejo
- [ ] Arquitectura documentada
🔗 Enlaces Útiles¶
- Spring Boot Reference Documentation
- Spring Security Reference
- MapStruct Reference Guide
- Testcontainers Documentation
- OpenAPI Specification
🤝 Contribución¶
Para contribuir a este proyecto:
- Fork el repositorio
- Crea una rama feature (
git checkout -b feature/amazing-feature) - Commit tus cambios (
git commit -m 'Add amazing feature') - Push a la rama (
git push origin feature/amazing-feature) - Abre un Pull Request
📄 Licencia¶
Este proyecto está bajo la licencia MIT - ver el archivo LICENSE para más detalles.