Skip to main content

Pesquisa — RF-05: Query Separada vs JOIN no GetCartInfoAsync

Data: 2026-05-14
Contexto: Task #193232 — Ajuste endpoint GET api/payment/v2/{id} (GetInfo) — RF-05 Busca Paralela de Itens Recomendados


Estado Atual do Código

Fluxo GetInfoStoreAsync

Arquivo: coezzion-service-checkout/src/Checkout.API/Application/Messages/Queries/OrderQuery/Payment/GetInfoQueryHandler.cs (linhas 107–138)

private async Task<GetInfoBase> GetInfoStoreAsync(OrderTypeDTO order)
{
var paymentTask = _paymentSqlRepository.GetPaymentInfoAsync(order.Id);
var cartTask = _coreSqlRepository.GetCartInfoAsync(order.CartId);

await Task.WhenAll(paymentTask, cartTask); // paralelo hoje

var payment = await paymentTask;
var cart = await cartTask;

if (payment is null || cart is null) return null;

var customLink = await _coreSqlRepository.GetCustomLinkAsync(cart.StoreMainBrandId); // sequencial

if (IsNewPayment()) return new NewPayment(payment, customLink, cart);
if (IsPaymentPending()) return new WaitingPayment(payment, customLink, cart);
if (IsResumePayment()) return new ResumePayment(payment, customLink, cart);
return new VoidedPayment(payment, customLink);
}

GetCartInfoAsync — Query atual

Arquivo: coezzion-service-checkout/src/Checkout.Infraestructure/Data/Repositories/Read/CoreSqlRepository.cs (linhas 34–139)

Query Dapper com 5-way multi-map:

Carts
→ CartItems → Products (splitOn: "ProductName")
→ Addresses (splitOn: "Id")
→ StoresPayment → StorePaymentKeys (splitOn: "CreditCardEnabled")
→ StoresPaymentInstallmentRules (splitOn: "FromInstallment")

Resultado: CartInfoDTO com Items, Address, PaymentsData.InstallmentRules.

DTOs relevantes

Arquivo: coezzion-service-checkout/src/Checkout.Domain/DTO/GetInfo/CartInfoDTO.cs

  • CartInfoDTO.ItemsList<CartItemsInfoDTO>
  • CartItemsInfoDTO é nested record dentro de CartInfoDTO com campos: Id, ProductName, UrlThumbnail, GrossPrice, Size, Quantity, UnitPrice, Total, Sku
  • DateCreated não está presente no DTO atual — precisa ser adicionado (RF-02)

Alternativa A: JOIN na query existente do GetCartInfoAsync

Adicionar CartRecommendedItems ao SQL do GetCartInfoAsync, promovendo para um 6-way multi-map.

SQL resultante (esboço)

SELECT ...
-- itens normais (já existentes)
P."Name" ProductName,
...
-- itens recomendados (novos)
PR."Name" RecommendedName,
PR."UrlThumbnail" RecommendedThumbnail,
PR."Sku" RecommendedSku,
CRI."Id" RecommendedId
FROM {schema}."Carts" C
INNER JOIN {schema}."CartItems" CI ON CI."CartId" = C."Id"
INNER JOIN {schema}."Products" P ON CI."ProductId" = P."Id"
LEFT JOIN {schema}."CartRecommendedItems" CRI ON CRI."CartId" = C."Id"
LEFT JOIN {schema}."Products" PR ON CRI."ProductId" = PR."Id" -- segundo JOIN em Products
...

Problemas identificados

1. Explosão cartesiana

A query atual já produz linhas por (CartItem × InstallmentRule). Adicionando RecommendedItems:

linhas = CartItems × InstallmentRules × RecommendedItems

Exemplo concreto:

  • 3 itens no carrinho
  • 3 regras de parcelamento
  • 3 itens recomendados
  • Hoje: 3 × 3 = 9 linhas
  • Com JOIN: 3 × 3 × 3 = 27 linhas

O multi-map do Dapper deduplica via dicionário, mas o banco precisa materializar e transmitir todas as linhas. O custo cresce de forma cúbica com o número de recomendados.

2. Colisão de alias em Products

Products já é JOINado para os CartItems. Um segundo JOIN no mesmo Products para recomendados exige alias diferente (PR), forçando colunas com nomes distintos no SELECT. O splitOn do Dapper depende de nomes de coluna — a colisão exige renomeação de colunas existentes ou lógica adicional de mapeamento.

Antes:

splitOn: "ProductName, Id, CreditCardEnabled, FromInstallment"

Depois (frágil):

splitOn: "ProductName, RecommendedName, Id, CreditCardEnabled, FromInstallment"

Qualquer reordenação de colunas no SELECT quebra o mapeamento silenciosamente.

3. Acoplamento de fluxos

GetCartInfoAsync é chamado por dois fluxos:

  • GetInfoStoreAsync (store/zzlink) — precisa de recomendados
  • GetInfoEcommAsync (ecomm) — não deve retornar recomendados (RNF-02)

Engordar a query afeta ambos os fluxos. Para isolar, seria necessário criar uma segunda versão do método ou passar um flag — aumentando a complexidade.


Alternativa A2: Multi-query (QueryMultiple) no GetCartInfoAsync

Variante da Alternativa A que evita o produto cartesiano: em vez de um JOIN adicional, enviar dois SELECTs separados por ; no mesmo SQL e consumi-los via connection.QueryMultiple() + GridReader do Dapper. Um roundtrip ao banco retornaria tanto os dados do cart quanto os recomendados.

Como ficaria

public async Task<(CartInfoDTO, List<CartItemsInfoDTO>)> GetCartInfoWithRecommendedAsync(int cartId)
{
var sql = $"""
-- SELECT 1: query existente do cart
SELECT C."Id" Id, ... FROM {_schema}."Carts" C ... WHERE C."Id" = @cartId;

-- SELECT 2: itens recomendados
SELECT CRI."Id" Id, P."Name" ProductName, P."UrlThumbnail" UrlThumbnail, P."Sku" Sku
FROM {_schema}."CartRecommendedItems" CRI
INNER JOIN {_schema}."Products" P ON P."Id" = CRI."ProductId"
WHERE CRI."CartId" = @cartId;
""";

using var connection = await _dbConnectionFactory.GetConnectionAsync(EConnectionType.ReadOnly);
using var multi = await connection.QueryMultipleAsync(sql, new { cartId });

// consome o primeiro resultset com o multi-map existente
var cartResult = (await multi.ReadAsync<CartInfoDTO, CartItemsInfoDTO, ...>(
map, splitOn: "ProductName, Id, CreditCardEnabled, FromInstallment"
)).FirstOrDefault();

// consome o segundo resultset de forma simples
var recommended = (await multi.ReadAsync<CartItemsInfoDTO>()).AsList();

return (cartResult, recommended);
}

Vantagem sobre a Alternativa A (JOIN simples)

  • Elimina o produto cartesiano — cada SELECT retorna exatamente suas linhas, sem multiplicação
  • Sem colisão de alias de Products — os dois SELECTs são independentes entre si

Por que ainda é inferior à Alternativa B

1. Padrão ausente no codebase — validado por busca direta

Busca realizada com grep em todos os .cs do repositório:

pattern: QueryMultiple|GridReader|reader\.Read
resultado: zero ocorrências

QueryMultiple/GridReader não existe em nenhum dos repositórios do projeto. Introduzir seria um padrão sem precedente local, aumentando a carga cognitiva para quem mantém o código.

2. Quebra a assinatura do GetCartInfoAsync

O método hoje retorna CartInfoDTO. Com QueryMultiple, o retorno precisa mudar para expor os dois resultsets. As opções são todas ruins:

  • Retornar uma tupla (CartInfoDTO, List<CartItemsInfoDTO>) — todos os chamadores precisam ser atualizados (GetInfoEcommAsync, InstallmentsQueryHandler)
  • Popular RecommendedItems dentro do repositório — viola RF-02 item 3, que exige que a lógica de validade e deduplicação fique no handler, não no repositório
  • Criar uma sobrecarga GetCartInfoWithRecommendedAsync — duplica a query do cart (a maior e mais complexa do repositório) para manter a assinatura original

3. Acoplamento de fluxos persiste

GetCartInfoAsync é compartilhado com GetInfoEcommAsync. O segundo SELECT de recomendados seria executado mesmo no fluxo ecomm, que não precisa desses dados (RNF-02). Para evitar, seria necessário um parâmetro de controle ou sobrecarga — mesma complexidade da Alternativa A.

4. O ganho de latência sobre a Alternativa B é ilusório

O único benefício real seria economizar 1 roundtrip. Mas:

  • Na Alternativa B, as 3 queries rodam em paralelo via Task.WhenAll — o tempo total é determinado pela mais lenta (GetCartInfoAsync), não pela soma
  • Um QueryMultiple executa os dois SELECTs sequencialmente no mesmo statement: o PostgreSQL processa o SELECT 2 após o SELECT 1; não há paralelismo interno
  • Na prática, QueryMultiple seria mais lento do que Task.WhenAll com queries separadas, pois o segundo SELECT aguarda o primeiro terminar

5. Conexão mantida aberta por mais tempo

O GridReader mantém o DbConnection aberto até ser descartado. O padrão atual usa using var connection com escopo mínimo por query. Com QueryMultiple, o ciclo de vida do reader precisa ser gerenciado explicitamente, aumentando o risco de leak de conexão se o using for omitido ou mal posicionado.


Alternativa B: Query separada GetRecommendedItemsAsync (paralela)

Nova query simples no ICoreSqlRepository, chamada em paralelo no handler.

Interface

// ICoreSqlRepository.cs
Task<List<CartInfoDTO.CartItemsInfoDTO>> GetRecommendedItemsAsync(int cartId);

Implementação

public async Task<List<CartInfoDTO.CartItemsInfoDTO>> GetRecommendedItemsAsync(int cartId)
{
var sql = $"""
SELECT CRI."Id" Id,
P."Name" ProductName,
P."UrlThumbnail" UrlThumbnail,
P."Sku" Sku
FROM {_schema}."CartRecommendedItems" CRI
INNER JOIN {_schema}."Products" P ON P."Id" = CRI."ProductId"
WHERE CRI."CartId" = @cartId
""";

using var connection = await _dbConnectionFactory.GetConnectionAsync(EConnectionType.ReadOnly);
var result = await connection.QueryAsync<CartInfoDTO.CartItemsInfoDTO>(sql, new { cartId });
return result.AsList();
}

Handler atualizado

private async Task<GetInfoBase> GetInfoStoreAsync(OrderTypeDTO order)
{
var paymentTask = _paymentSqlRepository.GetPaymentInfoAsync(order.Id);
var cartTask = _coreSqlRepository.GetCartInfoAsync(order.CartId);
var recommendedTask = _coreSqlRepository.GetRecommendedItemsAsync(order.CartId);

await Task.WhenAll(paymentTask, cartTask, recommendedTask); // 3 queries em paralelo

var payment = await paymentTask;
var cart = await cartTask;
var recommendedItems = await recommendedTask;

if (payment is null || cart is null) return null;

// RF-02: filtro de validade no handler
if (DateTime.Now <= cart.DateCreated.AddHours(1))
{
// RF-04: deduplicação por SKU
var cartSkus = cart.Items.Select(i => i.Sku).ToHashSet();
cart.RecommendedItems = recommendedItems
.Where(r => !cartSkus.Contains(r.Sku))
.ToList();
}
else
{
if (recommendedItems.Any())
_logger.Information("Itens recomendados não retornados para CartId {CartId}: janela de 1 hora expirada.", cart.Id);

cart.RecommendedItems = new List<CartInfoDTO.CartItemsInfoDTO>();
}

var customLink = await _coreSqlRepository.GetCustomLinkAsync(cart.StoreMainBrandId);
if (customLink is null) return null;

if (IsNewPayment()) return new NewPayment(payment, customLink, cart);
if (IsPaymentPending()) return new WaitingPayment(payment, customLink, cart);
if (IsResumePayment()) return new ResumePayment(payment, customLink, cart);
return new VoidedPayment(payment, customLink);
}

Vantagens

#VantagemDetalhe
1Zero impacto no multi-map existenteGetCartInfoAsync não muda; sem risco de regressão
2Sem explosão cartesianaA nova query retorna exatamente N linhas para N recomendados
3Paralelismo real3 queries disparam simultaneamente via Task.WhenAll
4Isolamento de fluxosApenas GetInfoStoreAsync chama GetRecommendedItemsAsync; ecomm não é tocado
5splitOn não é perturbadoSem colunas extras ou aliases duplicados
6Facilita RF-02DateCreated adicionado ao CartInfoDTO sem impacto no multi-map dos items
7Padrão idiomáticoTask.WhenAll com 3+ tarefas já existe em CreatePaymentCommandHandler e PosCaptureService

Desvantagem

  • Uma roundtrip adicional ao banco. Mitigada pelo paralelismo: a query de recomendados é disparada ao mesmo tempo que GetCartInfoAsync e GetPaymentInfoAsync. A tabela CartRecommendedItems é pequena (poucos itens por carrinho, sem agregações), então a latência adicional é desprezível.

Comparativo Final

CritérioAlternativa A (JOIN)Alternativa A2 (QueryMultiple)Alternativa B (Query Separada)
Produto cartesianoSim — cresce cubicamenteNão — SELECTs independentesNão — query isolada
Colisão de alias em ProductsSim — splitOn frágilNãoNão
Padrão no codebaseExistente (multi-map)Ausente — zero ocorrências no repoExistente (Task.WhenAll)
Quebra de assinatura do métodoSimSim — retorno vira tupla ou duplica métodoNão — método novo
Acoplamento com fluxo ecommSimSimNão — isolado no store
Paralelismo realNãoNão — SELECTs sequenciais no statementSim — Task.WhenAll
Latência relativaPiorPior que B (sem paralelismo)Melhor
Conexão aberta por mais tempoNãoSim — GridReader mantém conexãoNão
Risco de regressãoAltoAltoZero
Decisão❌ Descartada❌ Descartada✅ Adotada