Saltar a contenido

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

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


🤝 Contribución

Para contribuir a este proyecto:

  1. Fork el repositorio
  2. Crea una rama feature (git checkout -b feature/amazing-feature)
  3. Commit tus cambios (git commit -m 'Add amazing feature')
  4. Push a la rama (git push origin feature/amazing-feature)
  5. Abre un Pull Request

📄 Licencia

Este proyecto está bajo la licencia MIT - ver el archivo LICENSE para más detalles.