Email copiado — support@tuurt.com
Cargando experiencia
arquitectura · 15 de octubre de 2026 · 7 min

Database per service en microservicios .NET: qué consultas conjuntas se vuelven más caras y cómo las resolvemos

Separar la base de datos por servicio rompe los joins que antes resolvia un indice. Vemos que consultas se encarecen de verdad y que patron -composicion en el gateway o cache local via eventos- resuelve cada caso sin reconstruir un monolito con pasos extra.

Por Equipo Tuurt

Database per service en microservicios .NET: qué consultas conjuntas se vuelven más caras y cómo las resolvemos

El patrón database-per-service tiene una promesa clara: cada servicio es dueño de su esquema, nadie fuera de ese servicio toca su tabla directamente, y los equipos pueden evolucionar su modelo de datos sin coordinar un deploy con media organización. La promesa se cumple. Lo que no se dice tan seguido es qué pasa con las consultas que, en un monolito con una sola base, eran un JOIN de una línea, y que después de separar las bases se convierten en un problema de diseño que hay que resolver de nuevo, servicio por servicio.

La consulta que perdimos

En un sistema de pedidos sobre .NET con Entity Framework Core, una consulta típica antes de separar las bases se veía así:

var pedidos = await db.Pedidos
    .Where(p => p.Estado == EstadoPedido.Pendiente)
    .Select(p => new PedidoConCliente
    {
        PedidoId = p.Id,
        Total = p.Total,
        NombreCliente = p.Cliente.Nombre,
        NivelDescuento = p.Cliente.NivelDescuento
    })
    .ToListAsync();

Postgres resuelve ese JOIN con un índice en la foreign key y listo. Cuando Pedidos y Clientes pasan a vivir en bases distintas, propiedad de servicios distintos, esa consulta ya no existe como tal. El servicio de Pedidos no tiene la tabla Clientes, y no debería tenerla: ese es justamente el límite que el patrón nos pide respetar. El problema no es técnico en el sentido de que "ya no se puede hacer un JOIN" -eso lo sabíamos desde el día uno-. El problema es que seguimos necesitando ese dato combinado, y ahora hay que decidir dónde se calcula y con qué nivel de consistencia.

Qué tipo de consultas se encarecen de verdad

No todas las consultas conjuntas sufren igual. Las que se vuelven caras tienen un patrón reconocible: necesitan combinar un conjunto no trivial de filas de un servicio con datos de otro servicio, y lo necesitan en el camino caliente de una respuesta HTTP.

El caso más común es el listado paginado con un filtro que vive en el otro servicio. Pedir "los pedidos pendientes de clientes con nivel de descuento Oro" exige, en un esquema separado, primero traer todos los pedidos pendientes y después resolver el nivel de descuento de cada cliente involucrado, porque el filtro no se puede empujar a la base de Pedidos. La forma ingenua de resolverlo es la que primero se nos ocurre a todos, y es la que hay que evitar:

var pedidos = await pedidosDb.Pedidos
    .Where(p => p.Estado == EstadoPedido.Pendiente)
    .ToListAsync();

var resultado = new List<PedidoConCliente>();
foreach (var pedido in pedidos)
{
    var cliente = await clienteServiceClient.ObtenerClienteAsync(pedido.ClienteId);
    if (cliente.NivelDescuento == "Oro")
        resultado.Add(new PedidoConCliente(pedido, cliente));
}

Esto es un N+1 disfrazado de llamada a microservicio, y es peor que el N+1 original de un ORM mal usado: cada iteración no es una consulta SQL contra la misma base, es un round-trip de red completo, con su propia latencia y su propia posibilidad de timeout. Esta versión multiplica esa latencia por la cantidad de pedidos, y además acopla la disponibilidad del listado de Pedidos a la disponibilidad del servicio de Clientes en tiempo real.

Composición en el API gateway: cuándo alcanza

La primera herramienta que probamos es la composición en la capa que agrega las respuestas -un BFF o el propio API gateway- en vez de dentro del servicio de Pedidos. La diferencia con el ejemplo anterior no es evitar la llamada entre servicios, es dejar de hacerla una vez por fila:

var pedidos = await pedidosServiceClient.ObtenerPendientesAsync();
var clienteIds = pedidos.Select(p => p.ClienteId).Distinct();
var clientes = await clienteServiceClient.ObtenerPorIdsAsync(clienteIds);
var clientesPorId = clientes.ToDictionary(c => c.Id);

var resultado = pedidos
    .Where(p => clientesPorId[p.ClienteId].NivelDescuento == "Oro")
    .Select(p => new PedidoConCliente(p, clientesPorId[p.ClienteId]));

Esto exige que el servicio de Clientes expone un endpoint de batch (ObtenerPorIdsAsync recibiendo una lista, no un endpoint pensado para un solo id llamado en loop), y con eso el costo de red pasa de ser proporcional a la cantidad de pedidos a ser de dos llamadas fijas. Esta composición funciona bien cuando el filtro por el otro servicio reduce poco el conjunto -seguimos trayendo casi todos los pedidos pendientes- y cuando ambos conjuntos de datos son razonablemente chicos para vivir en memoria durante el request. Deja de funcionar cuando el filtro es altamente selectivo y el conjunto de pedidos pendientes es grande: ahí estamos trayendo y descartando la mayoría de las filas en la capa de composición, que es exactamente el trabajo que un índice en una base compartida haría gratis.

Vista materializada local: cuando la selectividad lo exige

Para ese segundo caso -consultas frecuentes, con filtro selectivo sobre un atributo que cambia poco en el otro servicio- la alternativa que terminamos usando es mantener una copia local, de solo lectura, del dato que necesitamos para filtrar. No es una vista materializada de Postgres en sentido estricto porque cruza bases distintas; es el mismo principio aplicado vía eventos: el servicio de Pedidos guarda una tabla delgada con el nivel de descuento de cada cliente, y la mantiene actualizada escuchando los eventos que el servicio de Clientes publica cuando ese nivel cambia.

public class NivelDescuentoClienteActualizadoHandler : IEventHandler<NivelDescuentoClienteActualizado>
{
    private readonly PedidosDbContext _db;

    public async Task HandleAsync(NivelDescuentoClienteActualizado evento)
    {
        var copia = await _db.ClientesCache
            .FirstOrDefaultAsync(c => c.ClienteId == evento.ClienteId);

        if (copia is null)
        {
            _db.ClientesCache.Add(new ClienteCache
            {
                ClienteId = evento.ClienteId,
                NivelDescuento = evento.NivelDescuentoNuevo
            });
        }
        else
        {
            copia.NivelDescuento = evento.NivelDescuentoNuevo;
        }

        await _db.SaveChangesAsync();
    }
}

Con esta tabla local, la consulta original vuelve a ser un JOIN de una línea, ahora contra ClientesCache en la misma base de Pedidos:

var pedidos = await pedidosDb.Pedidos
    .Join(pedidosDb.ClientesCache, p => p.ClienteId, c => c.ClienteId, (p, c) => new { p, c })
    .Where(x => x.p.Estado == EstadoPedido.Pendiente && x.c.NivelDescuento == "Oro")
    .Select(x => new PedidoConCliente(x.p, x.c))
    .ToListAsync();

El costo que estamos pagando por recuperar esa velocidad es consistencia eventual: entre el momento en que un cliente sube de nivel y el momento en que ese evento llega y se procesa, la copia local está desactualizada. Para un filtro de listado interno esa ventana suele ser aceptable. Para una decisión que afecta dinero en el momento exacto del request -por ejemplo, aplicar el descuento al cobrar- no lo es, y ahí la respuesta correcta no es ninguna de las dos anteriores: es una llamada síncrona directa al servicio de Clientes para ese dato puntual, aceptando la latencia y el acoplamiento de disponibilidad solo en ese punto crítico, no en el listado completo.

Las señales de que la separación fue prematura

Después de resolver varios de estos casos, aprendimos a reconocer cuándo el problema no es de implementación sino de límites de servicio mal trazados. La señal más clara es cuando un mismo caso de uso de negocio necesita leer y escribir, en la misma transacción lógica, atributos que viven en dos servicios distintos y que deben quedar consistentes entre sí sin ventana de staleness aceptable. Si eso pasa una sola vez en el sistema, se resuelve con una llamada síncrona puntual como la del ejemplo anterior. Si pasa repetidamente para el mismo par de entidades, es una señal de que Pedidos y Clientes -o los dos servicios que tengamos en ese lugar- nunca debieron separarse por ese eje: la frontera de servicio no coincide con la frontera de consistencia transaccional que el negocio realmente necesita.

La segunda señal es el volumen de tablas de caché local que un servicio acumula solo para poder filtrar y ordenar. Cuando un servicio termina manteniendo copias de atributos de otros tres o cuatro servicios, ya no está siendo dueño de un dominio acotado: está reconstruyendo, vía eventos, una base compartida con pasos adicionales y más latencia de propagación que la original. Ahí la pregunta ya no es qué patrón de composición usar, sino si esos servicios deberían fusionarse en uno con un esquema compartido para ese subdominio.

Qué revisamos antes de dar por buena una separación de bases

Antes de aceptar un database-per-service entre dos servicios, identificamos qué consultas del negocio hoy cruzan esa frontera y clasificamos cada una: si el filtro es poco selectivo, composición en el gateway con endpoints de batch alcanza. Si es selectivo y la consistencia eventual es tolerable para ese caso de uso, construimos una copia local vía eventos, aceptando explícitamente la ventana de staleness en vez de ignorarla. Si la consistencia debe ser inmediata, aislamos esa lectura puntual como llamada síncrona directa, sin intentar resolverla con cache. Y si encontramos que varias consultas críticas del mismo par de servicios exigen la tercera opción de forma recurrente, lo tomamos como la señal de que el corte de servicios, no la consulta, es lo que hay que reconsiderar.

dotnet microservicios bases-de-datos arquitectura
← Volver al blog