Persistencia y transacciones en microservicios con Spring Data

Capítulo 4

Tiempo estimado de lectura: 11 minutos

+ Ejercicio

Modelar datos por servicio: base de datos por microservicio

En microservicios, el principio práctico es: cada servicio es dueño de sus datos. Esto suele materializarse como una base de datos (o esquema) por microservicio. La razón principal es evitar el acoplamiento: si dos servicios comparten tablas, cualquier cambio de modelo, índice o consulta puede romper al otro, obligando a coordinar despliegues y degradando la autonomía.

¿Base de datos separada o esquema separado?

  • Base de datos separada: mayor aislamiento (seguridad, límites de recursos, backups), ideal cuando el equipo y el ciclo de vida son independientes.
  • Esquema separado: aislamiento moderado, útil cuando hay restricciones operativas (un único clúster/instancia) pero se quiere evitar compartir tablas.

En ambos casos, la regla es la misma: no acceder a la base de datos de otro servicio. Si necesitas datos de otro dominio, consúmelos por API o por eventos, y persiste una copia local si hace falta (patrón data replication / read model).

Ejemplo de modelado por servicio

Supongamos dos servicios: orders-service y catalog-service.

  • catalog-service es dueño de products (precio, nombre, stock).
  • orders-service es dueño de orders y order_items. Si necesita el nombre/precio del producto para mostrar el pedido, puede almacenar un snapshot (por ejemplo, productName, unitPrice) en order_items para evitar dependencias en tiempo real.

Implementar repositorios con Spring Data JPA

Spring Data JPA es una opción común cuando el microservicio usa un modelo relacional. Si tu caso requiere alta escritura con esquemas flexibles, podrías usar Spring Data MongoDB; si necesitas clave-valor, Spring Data Redis, etc. La idea es la misma: repositorios declarativos y persistencia encapsulada.

Dependencias y configuración mínima

Ejemplo con Maven (JPA + PostgreSQL):

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

<dependencies>  <dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-data-jpa</artifactId>  </dependency>  <dependency>    <groupId>org.postgresql</groupId>    <artifactId>postgresql</artifactId>    <scope>runtime</scope>  </dependency></dependencies>

Propiedades típicas (evita ddl-auto en producción; usa migraciones):

spring.datasource.url=jdbc:postgresql://localhost:5432/ordersdb spring.datasource.username=orders spring.datasource.password=secret spring.jpa.hibernate.ddl-auto=validate spring.jpa.open-in-view=false

Mapeo de entidades: ejemplo práctico

Un pedido con ítems. Observa: claves, relaciones, y campos que ayudan a auditoría.

@Entity @Table(name = "orders", indexes = { @Index(name = "idx_orders_customer", columnList = "customer_id"), @Index(name = "idx_orders_created_at", columnList = "created_at") }) public class OrderEntity {   @Id   @GeneratedValue(strategy = GenerationType.UUID)   private UUID id;   @Column(name = "customer_id", nullable = false)   private UUID customerId;   @Enumerated(EnumType.STRING)   @Column(nullable = false)   private OrderStatus status;   @Column(name = "created_at", nullable = false)   private Instant createdAt;   @Version   private long version;   @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)   private List<OrderItemEntity> items = new ArrayList<>();   public void addItem(UUID productId, String productName, BigDecimal unitPrice, int quantity) {     OrderItemEntity item = new OrderItemEntity(this, productId, productName, unitPrice, quantity);     items.add(item);   } }
@Entity @Table(name = "order_items", indexes = { @Index(name = "idx_order_items_order", columnList = "order_id"), @Index(name = "idx_order_items_product", columnList = "product_id") }) public class OrderItemEntity {   @Id   @GeneratedValue(strategy = GenerationType.UUID)   private UUID id;   @ManyToOne(fetch = FetchType.LAZY, optional = false)   @JoinColumn(name = "order_id", nullable = false)   private OrderEntity order;   @Column(name = "product_id", nullable = false)   private UUID productId;   @Column(name = "product_name", nullable = false)   private String productName;   @Column(name = "unit_price", nullable = false, precision = 19, scale = 2)   private BigDecimal unitPrice;   @Column(nullable = false)   private int quantity;   protected OrderItemEntity() {}   public OrderItemEntity(OrderEntity order, UUID productId, String productName, BigDecimal unitPrice, int quantity) {     this.order = order;     this.productId = productId;     this.productName = productName;     this.unitPrice = unitPrice;     this.quantity = quantity;   } }

Puntos clave del mapeo:

  • @Version habilita control de concurrencia optimista (útil en microservicios para evitar bloqueos largos).
  • Índices en columnas de búsqueda frecuente (cliente, fecha, claves foráneas).
  • Snapshot de datos externos (productName, unitPrice) para desacoplar lecturas de otros servicios.

Repositorios con Spring Data

Un repositorio típico:

public interface OrderRepository extends JpaRepository<OrderEntity, UUID> {   Page<OrderEntity> findByCustomerId(UUID customerId, Pageable pageable);   @Query("select o from OrderEntity o join fetch o.items where o.id = :id")   Optional<OrderEntity> findByIdWithItems(UUID id); }

Recomendaciones:

  • Usa métodos derivados para consultas simples y @Query para casos específicos.
  • Para evitar el problema N+1, usa join fetch o @EntityGraph en lecturas que requieren relaciones.
  • Evita exponer entidades JPA fuera de la capa de persistencia; mapea a DTOs en el servicio/aplicación.

Estrategias de carga (fetch) y rendimiento

En microservicios, el rendimiento suele degradarse por consultas inadvertidas. Algunas pautas prácticas:

  • Preferir LAZY en relaciones @ManyToOne y colecciones; cargar explícitamente cuando se necesite.
  • Lecturas específicas: crea consultas que traigan exactamente lo necesario (por ejemplo, proyecciones).
  • Proyecciones para listados: evita cargar el agregado completo si solo necesitas un resumen.

Ejemplo de proyección:

public interface OrderSummary {   UUID getId();   Instant getCreatedAt();   OrderStatus getStatus(); } public interface OrderRepository extends JpaRepository<OrderEntity, UUID> {   Page<OrderSummary> findByCustomerId(UUID customerId, Pageable pageable); }

Índices: cómo decidirlos

Regla práctica: indexa lo que filtras/ordenas frecuentemente y lo que usas para joins. En un servicio típico:

  • Índices por customer_id y created_at para listados.
  • Índices por order_id en tablas hijas.
  • Índices compuestos si filtras por múltiples columnas (por ejemplo, (customer_id, created_at)).

Evita sobre-indexar: cada índice penaliza escrituras. Mide con métricas y planes de ejecución.

Transacciones locales en un microservicio

Una transacción local abarca operaciones dentro de un solo microservicio y, típicamente, una sola base de datos. Es el caso ideal: ACID, simple de razonar y de probar.

Uso de @Transactional

Ejemplo de caso de uso: crear un pedido y sus ítems en una sola transacción.

@Service public class CreateOrderService {   private final OrderRepository orderRepository;   public CreateOrderService(OrderRepository orderRepository) {     this.orderRepository = orderRepository;   }   @Transactional   public UUID create(UUID customerId, List<CreateItem> items) {     OrderEntity order = new OrderEntity();     order.setCustomerId(customerId);     order.setStatus(OrderStatus.CREATED);     order.setCreatedAt(Instant.now());     for (CreateItem i : items) {       order.addItem(i.productId(), i.productName(), i.unitPrice(), i.quantity());     }     OrderEntity saved = orderRepository.save(order);     return saved.getId();   } }

Buenas prácticas:

  • Coloca @Transactional en la capa de aplicación/servicio (casos de uso), no en controladores.
  • Mantén la transacción corta: no llames a servicios remotos dentro de una transacción de base de datos.
  • Configura spring.jpa.open-in-view=false para evitar cargas perezosas fuera del contexto transaccional y forzar lecturas explícitas.

Bloqueo optimista vs pesimista

  • Optimista (@Version): recomendado cuando los conflictos son raros; escala mejor.
  • Pesimista (LockModeType.PESSIMISTIC_WRITE): úsalo con cuidado; puede reducir throughput y aumentar latencia.

Cuando hay más de un servicio: consistencia eventual

En microservicios, una transacción distribuida (2PC/XA) suele evitarse por complejidad, acoplamiento y fragilidad. En su lugar se usa consistencia eventual: cada servicio confirma su transacción local y se coordina con otros mediante mensajes/eventos y compensaciones.

Patrón Saga (orquestación o coreografía)

Una saga es una secuencia de transacciones locales. Si una falla, se ejecutan acciones compensatorias.

  • Orquestación: un coordinador (por ejemplo, orders-service) decide el siguiente paso.
  • Coreografía: cada servicio reacciona a eventos y publica nuevos eventos.

Ejemplo conceptual (coreografía):

  • orders-service crea pedido en estado PENDING y publica OrderCreated.
  • catalog-service reserva stock y publica StockReserved o StockRejected.
  • orders-service al recibir StockReserved cambia a CONFIRMED; si recibe StockRejected cambia a CANCELLED.

Patrón Outbox: publicar eventos sin perder consistencia

Problema: si guardas en BD y luego publicas a un broker, puede ocurrir que guardes pero no publiques (o publiques y no guardes). El patrón Transactional Outbox resuelve esto guardando el evento en una tabla outbox dentro de la misma transacción local, y un proceso separado lo publica.

Paso a paso (implementación típica):

  1. En la misma transacción que actualiza el agregado, inserta un registro en outbox_events con el payload del evento.
  2. Un publisher (scheduler o worker) lee eventos pendientes y los envía al broker.
  3. Marca el evento como publicado (o guarda published_at).
  4. Consumidores deben ser idempotentes (pueden recibir duplicados).

Tabla outbox (ejemplo):

create table outbox_events (  id uuid primary key,  aggregate_type varchar(100) not null,  aggregate_id uuid not null,  event_type varchar(200) not null,  payload jsonb not null,  created_at timestamptz not null,  published_at timestamptz null ); create index idx_outbox_unpublished on outbox_events(published_at) where published_at is null;

Inserción del evento dentro de la transacción:

@Transactional public UUID createOrder(...) {   OrderEntity saved = orderRepository.save(order);   OutboxEventEntity evt = OutboxEventEntity.newEvent(     "Order", saved.getId(), "OrderCreated", payloadAsJson   );   outboxRepository.save(evt);   return saved.getId(); }

Idempotencia y deduplicación en consumidores

Como los mensajes pueden reintentarse, un consumidor debe tolerar duplicados. Estrategias comunes:

  • Tabla de mensajes procesados con message_id único; si ya existe, se ignora.
  • Upserts (insert/update) basados en claves naturales.
  • Versionado de eventos y control de orden si aplica (por ejemplo, por aggregate_id).

Lecturas entre servicios: CQRS ligero

Si un endpoint necesita datos de varios servicios, evita hacer múltiples llamadas en cascada en tiempo real. Alternativas:

  • Read model local: el servicio mantiene una proyección local alimentada por eventos.
  • API Composition en un BFF o API Gateway (si existe), con caché y tolerancia a fallos.

Migraciones con scripts: estructura recomendada e integración al despliegue

Para microservicios, las migraciones deben ser automáticas, repetibles y versionadas. Herramientas comunes: Flyway o Liquibase. Aquí se muestra un enfoque con Flyway por simplicidad.

Estructura de carpetas recomendada

En cada microservicio, mantén migraciones junto al código:

src/main/resources/db/migration/  V1__init.sql  V2__add_order_status_index.sql  V3__create_outbox_table.sql

Convenciones:

  • V{n}__descripcion.sql para migraciones versionadas.
  • Una migración por cambio lógico (más fácil de revertir/entender).
  • Evita cambios destructivos sin plan (por ejemplo, drop column) en sistemas en producción; prefiere migraciones en fases (agregar columna, backfill, cambiar lectura, luego eliminar).

Ejemplo de scripts

V1__init.sql:

create table orders (  id uuid primary key,  customer_id uuid not null,  status varchar(30) not null,  created_at timestamptz not null,  version bigint not null ); create index idx_orders_customer on orders(customer_id); create index idx_orders_created_at on orders(created_at); create table order_items (  id uuid primary key,  order_id uuid not null references orders(id),  product_id uuid not null,  product_name varchar(255) not null,  unit_price numeric(19,2) not null,  quantity int not null ); create index idx_order_items_order on order_items(order_id); create index idx_order_items_product on order_items(product_id);

V3__create_outbox_table.sql:

create table outbox_events (  id uuid primary key,  aggregate_type varchar(100) not null,  aggregate_id uuid not null,  event_type varchar(200) not null,  payload jsonb not null,  created_at timestamptz not null,  published_at timestamptz null ); create index idx_outbox_unpublished on outbox_events(published_at) where published_at is null;

Configurar Flyway en Spring Boot

Dependencia:

<dependency>  <groupId>org.flywaydb</groupId>  <artifactId>flyway-core</artifactId></dependency>

Propiedades típicas:

spring.flyway.enabled=true spring.flyway.locations=classpath:db/migration spring.flyway.baseline-on-migrate=true

Nota: baseline-on-migrate puede ser útil al adoptar Flyway en una BD existente; en proyectos nuevos suele mantenerse en false.

Integración al ciclo de despliegue

En microservicios, una práctica robusta es que el servicio ejecute migraciones al arrancar, pero con controles:

  • Entornos no productivos: migración automática en startup suele ser suficiente.
  • Producción: preferible ejecutar migraciones como un paso explícito del pipeline (job) o como un init container antes de levantar réplicas, para evitar que múltiples instancias intenten migrar a la vez.

Flujo recomendado en CI/CD:

  1. Construir artefacto e imagen.
  2. Ejecutar pruebas (incluyendo integración con BD efímera).
  3. Desplegar migraciones (job único con credenciales controladas).
  4. Desplegar el servicio con ddl-auto=validate para asegurar que el esquema coincide.

Consejos operativos:

  • Usa un usuario de BD con permisos mínimos: el job de migración puede tener permisos DDL; el runtime del servicio puede limitarse a DML si tu operación lo permite.
  • Versiona cambios de datos (backfills) con scripts controlados y medibles; para grandes volúmenes, considera jobs separados.
  • Monitorea el tiempo de migración y bloqueos; planifica ventanas o migraciones online cuando sea necesario.

Ahora responde el ejercicio sobre el contenido:

¿Cuál práctica ayuda a evitar inconsistencias al publicar eventos cuando un microservicio guarda cambios en su base de datos y luego debe enviar un mensaje al broker?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

El patrón Transactional Outbox inserta el evento en una tabla outbox en la misma transacción que actualiza los datos. Luego un proceso externo lo publica, reduciendo el riesgo de “guardar sin publicar” o “publicar sin guardar”.

Siguiente capítulo

Comunicación entre microservicios y resiliencia

Arrow Right Icon
Portada de libro electrónico gratuitaMicroservicios con Spring Boot desde Cero
33%

Microservicios con Spring Boot desde Cero

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.