| applyTo | src/**/*.java |
|---|---|
| description | Convenciones para código Spring Boot 3 generado durante migración desde J2EE, Spring legacy, u Oracle Forms. Se aplica cuando ADR-001 (o equivalente) define Spring Boot 3 como target. |
Estas convenciones aplican a todo código nuevo generado en src/ cuando el target stack es Spring Boot 3 sobre Java 21 (o Java 17 según ADR).
- Spring Boot: 3.4.x (o la última estable LTS al momento del proyecto)
- Java: 21 LTS (o 17 según ADR-002)
- Hibernate: la que traiga Spring Boot, sin override manual
- JPA: Jakarta Persistence 3.x
NO usar versiones snapshot ni milestone en producción.
com.{{client}}.{{projectName}}
├── domain/ # Entities, Value Objects, Domain Services puros
│ ├── customer/
│ ├── order/
│ └── ...
├── application/ # Use Cases, Services orquestadores
│ ├── customer/
│ ├── order/
│ └── ...
├── infrastructure/ # Adapters: persistence, messaging, external APIs
│ ├── persistence/
│ ├── messaging/
│ ├── external/
│ └── config/
├── presentation/ # Controllers, DTOs, Request/Response, Mappers
│ ├── rest/
│ ├── web/ # Si Thymeleaf
│ └── advice/ # ExceptionHandlers
└── config/ # SpringBootApplication, security, etc.
NO mezclar capas. Un controller no llama directo a repository.
- SIEMPRE
jakarta.*para EE APIs (persistence, validation, servlet, etc.) - NUNCA mezclar
javax.*yjakarta.*en el mismo proyecto - PREFERIR imports específicos sobre wildcard (
import jakarta.persistence.Entity;NOimport jakarta.persistence.*;)
- Constructor injection siempre. NO field injection con
@Autowired. - Constructor sin
@Autowired(Spring lo infiere desde Boot 2.6+). - Marcar campos como
final.
// Correcto
@Service
public class CustomerService {
private final CustomerRepository repo;
private final ValidationService validator;
public CustomerService(CustomerRepository repo, ValidationService validator) {
this.repo = repo;
this.validator = validator;
}
}
// Incorrecto
@Service
public class CustomerService {
@Autowired
private CustomerRepository repo;
}@Entity+@Table(name = "...")siempre con nombre explícito@Column(name = "...")siempre con nombre explícito (no depender de naming strategy implícita)- Usar
@GeneratedValuecon sequence generator para Oracle,IDENTITYpara Postgres/SQL Server - Constructor protected sin args (para JPA) + constructor de negocio público
- NO
@Datade Lombok en entities (problemas con equals/hashCode + lazy loading) equals/hashCodebasado en business key, no enid
@Entity
@Table(name = "T_CUSTOMER")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "customer_seq")
@SequenceGenerator(name = "customer_seq", sequenceName = "S_CUSTOMER_ID", allocationSize = 1)
@Column(name = "customer_id")
private Long id;
@NotBlank
@Size(max = 9)
@Column(name = "cedula", unique = true, nullable = false, length = 9)
private String cedula;
@NotBlank
@Size(max = 100)
@Column(name = "name", nullable = false, length = 100)
private String name;
protected Customer() {} // JPA
public Customer(String cedula, String name) {
this.cedula = cedula;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer that)) return false;
return Objects.equals(cedula, that.cedula); // business key, no id
}
@Override
public int hashCode() {
return Objects.hash(cedula);
}
// getters
}@ManyToOnesiempreFetchType.LAZY(default es EAGER, malo)@OneToManyy@ManyToManysiempre LAZY (default ya)- NO usar
EAGERsalvo justificación documentada en código - Para cargar relaciones, usar
@EntityGrapho JOIN FETCH en queries específicas - Evitar N+1 queries: profilar con Hibernate logging activo
- Heredar
JpaRepository<Entity, IdType>para CRUD estándar - Query methods con naming Spring Data (
findByName,existsByCedula, etc.) @Querycon JPQL para queries complejas@Query(nativeQuery = true)solo cuando JPQL no alcanza- NO inyectar
EntityManageren repositories: usar custom repository pattern
public interface CustomerRepository extends JpaRepository<Customer, Long> {
Optional<Customer> findByCedula(String cedula);
@Query("SELECT c FROM Customer c LEFT JOIN FETCH c.orders WHERE c.id = :id")
Optional<Customer> findByIdWithOrders(@Param("id") Long id);
}- Lógica de negocio aquí, NO en controllers
@Transactionala nivel de método (no clase) para granularidad@Transactional(readOnly = true)para queries puras- Exceptions de negocio: subclases de
RuntimeException, capturadas por@ControllerAdvice
@Service
public class CustomerService {
private final CustomerRepository repo;
public CustomerService(CustomerRepository repo) {
this.repo = repo;
}
@Transactional
public Long createCustomer(CreateCustomerCommand cmd) {
if (repo.findByCedula(cmd.cedula()).isPresent()) {
throw new ConflictException("Cedula ya existe");
}
Customer c = new Customer(cmd.cedula(), cmd.name());
return repo.save(c).getId();
}
@Transactional(readOnly = true)
public Customer findById(Long id) {
return repo.findById(id)
.orElseThrow(() -> new NotFoundException("Customer not found: " + id));
}
}@RestControllerpara APIs JSON,@Controllerpara vistas server-rendered- Path bajo
/api/v1/...(versionado en URL) - DTOs separados:
XxxRequest(input),XxxResponse(output) - NUNCA exponer entities directo en endpoints (riesgo de fugas y problemas de lazy loading)
ResponseEntitycuando necesitas headers o status custom@Validen request bodies que necesitan validación
@RestController
@RequestMapping("/api/v1/customers")
public class CustomerController {
private final CustomerService service;
public CustomerController(CustomerService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<CustomerResponse> create(@Valid @RequestBody CreateCustomerRequest req) {
Long id = service.createCustomer(req.toCommand());
return ResponseEntity.created(URI.create("/api/v1/customers/" + id))
.body(new CustomerResponse(id));
}
@GetMapping("/{id}")
public CustomerResponse get(@PathVariable Long id) {
return CustomerResponse.from(service.findById(id));
}
}- Records de Java (no clases) cuando son inmutables
- Validation annotations en records:
@NotBlank,@Email, etc. - Factory method estático
from(Entity)en response DTOs - Factory method
toCommand()en request DTOs
public record CreateCustomerRequest(
@NotBlank @Size(min = 9, max = 9) String cedula,
@NotBlank @Size(max = 100) String name,
@Email String email
) {
public CreateCustomerCommand toCommand() {
return new CreateCustomerCommand(cedula, name, email);
}
}
public record CustomerResponse(Long id, String cedula, String name) {
public static CustomerResponse from(Customer c) {
return new CustomerResponse(c.getId(), c.getCedula(), c.getName());
}
}@RestControllerAdvicecentralizado para excepciones REST- Excepciones de dominio:
NotFoundException,ConflictException,ValidationException - Mapeo a HTTP status codes consistente
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex) {
return ResponseEntity.status(404).body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(ConflictException.class)
public ResponseEntity<ErrorResponse> handleConflict(ConflictException ex) {
return ResponseEntity.status(409).body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
// ... extraer field errors
}
}application.ymlpor sobreapplication.properties(más legible para nested)- Profiles:
application-dev.yml,application-prod.yml, etc. - Secretos: nunca hardcoded, usar env vars
${VAR}o vault @ConfigurationPropertiespara configs estructurados, NO@Valuedisperso
@ConfigurationProperties(prefix = "app.notifications")
@Validated
public record NotificationProperties(
@NotBlank String fromEmail,
@NotEmpty List<String> adminEmails,
@Min(1) int retryAttempts
) {}- SLF4J + Logback (default de Spring Boot)
- NO
System.out.printlnnie.printStackTrace() - Logger por clase:
private static final Logger log = LoggerFactory.getLogger(MyClass.class); - O usar
@Slf4jde Lombok si Lombok está en el proyecto - Niveles: DEBUG para diagnóstico, INFO para flujo normal, WARN para situaciones recuperables, ERROR para fallos
- NUNCA loggear passwords, tokens, datos PII
- JUnit 5 + AssertJ + Mockito
- Tests de repository:
@DataJpaTest+ Testcontainers (BD real, no H2 en memoria) - Tests de service:
@SpringBootTestcon@MockBeanpara dependencias externas, O test unitario con Mockito puro - Tests de controller:
@WebMvcTest+ MockMvc - Tests de integración end-to-end:
@SpringBootTest(webEnvironment = RANDOM_PORT)+ TestRestTemplate / WebTestClient
@DataJpaTest
@Testcontainers
class CustomerRepositoryTest {
@Container
static OracleContainer oracle = new OracleContainer("gvenzl/oracle-xe:21-slim");
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", oracle::getJdbcUrl);
// ...
}
@Autowired CustomerRepository repo;
@Test
void shouldFindByCedula() {
repo.save(new Customer("112233445", "Acme"));
Optional<Customer> found = repo.findByCedula("112233445");
assertThat(found).isPresent();
}
}NO usar H2 en memoria como sustituto de la BD real cuando el target es Oracle/Postgres específico: los dialectos divergen.
- Flyway o Liquibase desde el día 1
- Scripts en
src/main/resources/db/migration/V{N}__{descripcion}.sql - Numeración secuencial: V1__init.sql, V2__add_customer_table.sql
- NEVER editar una migration ya aplicada en producción
- Para entornos legacy: primera migration suele ser
V1__baseline.sqlconflyway baseline
- ❌
@Autowireden fields (usar constructor) - ❌
@Transactionalen controllers (debe estar en services) - ❌ Exponer Entities en controllers (siempre DTOs)
- ❌ Catch de Exception genérico
- ❌
EAGERfetching por default - ❌ Queries dentro de loops (causa N+1)
- ❌
e.printStackTrace()en código productivo - ❌ Hardcodear strings de error (i18n con messages.properties)
- ❌
H2como reemplazo de la BD real en tests críticos - ❌ Mezclar
javax.*yjakarta.* - ❌ Configuración XML salvo casos específicos (Spring Security a veces, Camel routes)