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-servicees dueño deproducts(precio, nombre, stock).orders-servicees dueño deordersyorder_items. Si necesita el nombre/precio del producto para mostrar el pedido, puede almacenar un snapshot (por ejemplo,productName,unitPrice) enorder_itemspara 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):
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
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=falseMapeo 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:
@Versionhabilita 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
@Querypara casos específicos. - Para evitar el problema N+1, usa
join fetcho@EntityGraphen 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
LAZYen relaciones@ManyToOney 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_idycreated_atpara listados. - Índices por
order_iden 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
@Transactionalen 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=falsepara 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-servicecrea pedido en estadoPENDINGy publicaOrderCreated.catalog-servicereserva stock y publicaStockReservedoStockRejected.orders-serviceal recibirStockReservedcambia aCONFIRMED; si recibeStockRejectedcambia aCANCELLED.
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):
- En la misma transacción que actualiza el agregado, inserta un registro en
outbox_eventscon el payload del evento. - Un publisher (scheduler o worker) lee eventos pendientes y los envía al broker.
- Marca el evento como publicado (o guarda
published_at). - 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.sqlConvenciones:
V{n}__descripcion.sqlpara 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=trueNota: 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:
- Construir artefacto e imagen.
- Ejecutar pruebas (incluyendo integración con BD efímera).
- Desplegar migraciones (job único con credenciales controladas).
- Desplegar el servicio con
ddl-auto=validatepara 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.