IA-Ingeniería-MLOps

Patrones JPA/Hibernate para Spring Boot

Aplicado a PedidosNow — plataforma B2B de gestión de pedidos entre distribuidores y restaurantes.

Java 17+
🍃 Spring Boot 3.2
🐘 PostgreSQL 15
🔗 HikariCP
📦 Flyway
🚨

Problemas detectados en el código actual

  • Problema N+1: 1 query por Order al listar órdenes → 500+ queries por request en producción
  • Sin índices en tenant_id, status, created_at → full table scan
  • Transacciones en el Controller — sin rollback garantizado, sin optimización de lectura
  • Sin paginación — carga completa de todos los pedidos de una vez
  • FetchType.EAGER en colecciones — problemas de memoria y joins innecesarios
Queries / request (antes)
503
↑ N+1 en OrderLines
Queries / request (después)
3
↓ 99.4% reducción
Latencia P99 (antes)
4.2s
Inaceptable en prod
Latencia P99 (después)
38ms
↓ 99.1% mejora
Pool size HikariCP
20
Configurado para 4 vCPUs
1
Diseño de Entidades — Dominio PedidosNow
Entity Design + Auditing + Indexes
TenantEntity.java
Raíz de agregado
@Entity
@Table(name = "tenants", indexes = {
  @Index(name = "idx_tenants_slug",
         columnList = "slug", unique = true),
  @Index(name = "idx_tenants_status",
         columnList = "status")
})
@EntityListeners(AuditingEntityListener.class)
public class TenantEntity {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false, length = 200)
  private String name;

  @Column(nullable = false, unique = true, length = 120)
  private String slug;

  @Enumerated(EnumType.STRING)
  @Column(nullable = false, length = 20)
  private TenantStatus status = TenantStatus.ACTIVE;

  // 1:N lazy (no EAGER) — cargado solo con JOIN FETCH
  @OneToMany(mappedBy = "tenant",
    cascade = CascadeType.ALL, orphanRemoval = true)
  private List<OrderEntity> orders = new ArrayList<>();

  @CreatedDate private Instant createdAt;
  @LastModifiedDate private Instant updatedAt;
}
OrderEntity.java
Índice compuesto
@Entity
@Table(name = "orders", indexes = {
  // Cubre el filtro más frecuente: tenant + estado + fecha
  @Index(name = "idx_orders_tenant_status_date",
         columnList = "tenant_id, status, created_at DESC")
})
@EntityListeners(AuditingEntityListener.class)
public class OrderEntity {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @ManyToOne(fetch = FetchType.LAZY, optional = false)
  @JoinColumn(name = "tenant_id", nullable = false)
  private TenantEntity tenant;

  @Enumerated(EnumType.STRING)
  private OrderStatus status = OrderStatus.DRAFT;

  // LAZY en colecciones — evita el N+1
  @OneToMany(mappedBy = "order",
    cascade = CascadeType.ALL, orphanRemoval = true)
  private List<OrderLineEntity> lines = new ArrayList<>();

  @Column(precision = 12, scale = 2)
  private BigDecimal totalAmount;

  @CreatedDate private Instant createdAt;
  @LastModifiedDate private Instant updatedAt;
}
2
Repositorios — Prevención N+1 + Proyecciones DTO
Fetch Join + Interface Projections
❌ ANTES — Código que dispara N+1
503 queries
// ❌ MAL: findAll() carga solo Order; al acceder a .getLines() Hibernate
// dispara 1 query adicional POR CADA Order → N+1 explosión
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {}

// En el servicio:
List<OrderEntity> orders = orderRepo.findAll();  // 1 query
orders.forEach(o -> o.getLines().size());       // +500 queries 💀
✅ DESPUÉS — Fetch Join + Paginación
3 queries
public interface OrderRepository
    extends JpaRepository<OrderEntity, Long> {

  // Carga Order + lines en 1 sola query
  @Query("select o from OrderEntity o " +
         "left join fetch o.lines " +
         "where o.id = :id")
  Optional<OrderEntity> findWithLines(
      @Param("id") Long id);

  // Listado paginado por tenant + estado (usa el índice compuesto)
  @Query("select o from OrderEntity o " +
         "where o.tenant.id = :tenantId " +
         "and o.status = :status")
  Page<OrderEntity> findByTenantAndStatus(
      @Param("tenantId") Long tenantId,
      @Param("status") OrderStatus status,
      Pageable pageable);

  // Cursor-pagination (evita OFFSET en tablas grandes)
  @Query("select o from OrderEntity o " +
         "where o.tenant.id = :tenantId " +
         "and o.id > :lastId " +
         "order by o.id asc")
  List<OrderEntity> findNextPage(
      @Param("tenantId") Long tenantId,
      @Param("lastId") Long lastId,
      Pageable pageable);
}
Proyecciones DTO — Solo columnas necesarias
Lightweight
// Proyección para listado (select solo id, status, total, fecha)
public interface OrderSummary {
  Long getId();
  OrderStatus getStatus();
  BigDecimal getTotalAmount();
  Instant getCreatedAt();
  // Expresión derivada con @Value (SpEL)
  @Value("#{target.tenant.name}")
  String getTenantName();
}

// En el repositorio:
Page<OrderSummary> findAllBy(Pageable pageable);

// Hibernate genera SELECT optimizado:
// SELECT o.id, o.status, o.total_amount,
//        o.created_at, t.name
// FROM orders o JOIN tenants t ON o.tenant_id = t.id
// → Sin cargar entidad completa ni listas


// DTO record para API response (Java 17)
public record OrderListItem(
  Long id,
  String tenantName,
  OrderStatus status,
  BigDecimal total,
  Instant createdAt
) {
  public static OrderListItem from(OrderSummary s) {
    return new OrderListItem(
      s.getId(), s.getTenantName(),
      s.getStatus(), s.getTotalAmount(),
      s.getCreatedAt());
  }
}
3
Servicio Transaccional Correcto
@Transactional en Service, no en Controller
OrderService.java
Service Layer
@Service
@RequiredArgsConstructor
public class OrderService {

  private final OrderRepository orderRepo;
  private final TenantRepository tenantRepo;

  // ✅ readOnly = true → Hibernate omite dirty checking
  //    + PostgreSQL puede enrutar a réplica de lectura
  @Transactional(readOnly = true)
  public Page<OrderListItem> listOrders(
      Long tenantId, OrderStatus status,
      int page, int size) {

    Pageable pageable = PageRequest.of(
      page, size,
      Sort.by("createdAt").descending());

    return orderRepo
      .findByTenantAndStatus(tenantId, status, pageable)
      .map(OrderListItem::from);
  }

  // ✅ Escritura: transacción completa con rollback automático
  @Transactional
  public OrderListItem confirmOrder(Long orderId) {
    OrderEntity order = orderRepo
      .findWithLines(orderId)
      .orElseThrow(() -> new EntityNotFoundException(
        "Order not found: " + orderId));

    // Validación de negocio dentro de la misma tx
    if (order.getLines().isEmpty()) {
      throw new IllegalStateException(
        "Cannot confirm empty order");
    }

    order.setStatus(OrderStatus.CONFIRMED);
    order.setTotalAmount(calculateTotal(order));
    // Sin save() explícito — dirty checking lo persiste
    return OrderListItem.from(order);
  }
}
Propagación de transacciones
Operación Anotación Por qué
Listado / búsqueda readOnly=true Sin dirty check; permite réplica
Crear / actualizar @Transactional Rollback garantizado
Borrado lógico @Transactional Atomicidad con auditoría
Método interno No @Transactional Spring AOP no intercepta self-calls
Soft Delete Pattern
Auditoría
// Mixin de auditoría reutilizable
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
  @CreatedDate
  private Instant createdAt;
  @LastModifiedDate
  private Instant updatedAt;
  @Column
  private Instant deletedAt;

  public boolean isDeleted() {
    return deletedAt != null;
  }
}

// Filter global — excluye soft-deleted automáticamente
@FilterDef(name = "deletedFilter",
  defaultCondition = "deleted_at IS NULL")
@Filter(name = "deletedFilter")
4
Paginación — Offset vs Cursor
Cuándo usar cada estrategia
Offset Pagination (panel de admin)
Saltable
// Bueno para: panel con control de página, exportaciones
// Malo para: tablas > 100k filas (OFFSET es costoso)

@GetMapping("/orders")
@Transactional(readOnly = true)
public ResponseEntity<Page<OrderListItem>> list(
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "20") int size,
    @RequestParam(defaultValue = "CONFIRMED")
        OrderStatus status) {

  Pageable p = PageRequest.of(
    page, size,
    Sort.by("createdAt").descending());

  return ResponseEntity.ok(
    orderService.listOrders(
      currentTenantId(), status, page, size));
}
Cursor Pagination (feed en tiempo real)
Escalable
// Bueno para: scroll infinito, sync incremental, APIs públicas
// Sin OFFSET → rendimiento estable en tablas grandes

@GetMapping("/orders/stream")
@Transactional(readOnly = true)
public CursorPage<OrderListItem> stream(
    @RequestParam(defaultValue = "0") Long lastId,
    @RequestParam(defaultValue = "20") int size) {

  List<OrderListItem> items = orderRepo
    .findNextPage(
      currentTenantId(), lastId,
      PageRequest.ofSize(size))
    .stream()
    .map(OrderListItem::from)
    .toList();

  Long nextCursor = items.isEmpty()
    ? null
    : items.getLast().id();

  return new CursorPage<>(items, nextCursor);
}
// SQL generado: WHERE id > :lastId ORDER BY id ASC LIMIT 20
// → usa Primary Key index → O(1) sin importar volumen
5
Configuración HikariCP + application.properties
Pooling optimizado para 4 vCPUs
⚙️ application-prod.properties
# ── DataSource ──────────────────────────────
spring.datasource.url=jdbc:postgresql://localhost:5432/pedidosnow
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver

# ── HikariCP ────────────────────────────────
# Fórmula Hikari: (core_count * 2) + effective_spindle_count
# Para 4 vCPUs + SSD: (4*2)+1 = 9 → redondeamos a 10-20
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=30000   # 30s
spring.datasource.hikari.idle-timeout=600000       # 10min
spring.datasource.hikari.max-lifetime=1800000      # 30min
spring.datasource.hikari.validation-timeout=5000   # 5s
spring.datasource.hikari.keepalive-time=30000      # evita timeout de firewall
spring.datasource.hikari.pool-name=PedidosNowPool

# ── JPA / Hibernate ─────────────────────────
spring.jpa.hibernate.ddl-auto=validate          # nunca update/create en prod
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true

# ── Logging (solo en dev/staging) ───────────
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE

# ── Flyway ──────────────────────────────────
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
spring.flyway.locations=classpath:db/migration
Batch writes — Optimización masiva
Performance
// ✅ saveAll() + batch_size=50 → 1 batch INSERT
// en lugar de 100 INSERT individuales
@Transactional
public void importOrderLines(
    OrderEntity order,
    List<OrderLineDTO> dtos) {

  List<OrderLineEntity> lines = dtos.stream()
    .map(dto -> OrderLineEntity.from(dto, order))
    .toList();

  // Hibernate agrupa en batches de 50
  lineRepo.saveAll(lines);
}

// Requiere: GenerationType.SEQUENCE (no IDENTITY)
// IDENTITY deshabilita el batch automáticamente
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
  generator = "order_line_seq")
@SequenceGenerator(name = "order_line_seq",
  allocationSize = 50)
private Long id;
Flyway — Migración típica
V1__init.sql
-- V1__create_orders_schema.sql
-- ✅ Idempotente y aditiva

CREATE TABLE IF NOT EXISTS tenants (
  id         BIGSERIAL PRIMARY KEY,
  slug       VARCHAR(120) NOT NULL UNIQUE,
  name       VARCHAR(200) NOT NULL,
  status     VARCHAR(20)  NOT NULL DEFAULT 'ACTIVE',
  created_at TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  deleted_at TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS idx_tenants_status
  ON tenants (status) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS orders (
  id           BIGSERIAL PRIMARY KEY,
  tenant_id    BIGINT      NOT NULL REFERENCES tenants(id),
  status       VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
  total_amount NUMERIC(12,2),
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Índice compuesto para el patrón de consulta dominante
CREATE INDEX IF NOT EXISTS idx_orders_tenant_status_date
  ON orders (tenant_id, status, created_at DESC);
6
Tests con @DataJpaTest + Testcontainers
Validación de queries y rendimiento SQL
OrderRepositoryTest.java
@DataJpaTest
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTest {

  @Container
  static PostgreSQLContainer<?> postgres =
    new PostgreSQLContainer<>("postgres:15-alpine")
      .withDatabaseName("pedidosnow_test");

  @Autowired OrderRepository orderRepo;
  @Autowired TenantRepository tenantRepo;
  @Autowired TestEntityManager em;

  @Test
  void findWithLines_shouldLoadOrderAndLines_inSingleQuery() {
    // Arrange
    TenantEntity tenant = buildTenant("distribuidora-norte");
    tenantRepo.save(tenant);
    OrderEntity order = buildOrderWithLines(tenant, 3);
    orderRepo.save(order);
    em.clear();  // limpia 1st-level cache → fuerza query real

    // Act — verificamos que carga lines sin N+1
    OrderEntity found = orderRepo
      .findWithLines(order.getId())
      .orElseThrow();

    // Assert
    assertThat(found.getLines()).hasSize(3);
    assertThat(found.getTenant().getSlug())
      .isEqualTo("distribuidora-norte");
  }

  @Test
  void findByTenantAndStatus_shouldPaginate() {
    // Arrange — 25 órdenes confirmadas
    TenantEntity tenant = tenantRepo.save(buildTenant("t1"));
    IntStream.range(0, 25).forEach(i ->
      orderRepo.save(buildConfirmedOrder(tenant)));
    em.clear();

    Pageable p = PageRequest.of(0, 10);

    // Act
    Page<OrderEntity> page = orderRepo
      .findByTenantAndStatus(
        tenant.getId(), OrderStatus.CONFIRMED, p);

    // Assert
    assertThat(page.getTotalElements()).isEqualTo(25);
    assertThat(page.getContent()).hasSize(10);
    assertThat(page.getTotalPages()).isEqualTo(3);
  }
}
Reglas de oro — Resumen accionable
🎯
Entidades lean
Solo mapea las columnas que la app necesita. Sin SELECT *, sin colecciones EAGER en entidades de alto volumen.
Queries intencionales
Cada query tiene un propósito. Usa JOIN FETCH cuando necesites el grafo completo. Proyecciones DTO para listados.
🔒
Transacciones cortas
No hagas llamadas HTTP o I/O dentro de una transacción. readOnly=true en toda lectura para evitar dirty checking.
📑
Flyway, no DDL-auto
Nunca update ni create en producción. Las migraciones Flyway son auditables, reversibles y versionadas.
📊
Índices para tus queries
Diseña índices a partir del patrón WHERE + ORDER BY real. Índices parciales (WHERE deleted_at IS NULL) para tablas con soft-delete.
🧪
Testcontainers, no H2
H2 no emula el dialecto PostgreSQL. Testcontainers con imagen postgres:15-alpine replica exactamente la base de producción.