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.Items→List<CartItemsInfoDTO>CartItemsInfoDTOé nested record dentro deCartInfoDTOcom campos:Id,ProductName,UrlThumbnail,GrossPrice,Size,Quantity,UnitPrice,Total,SkuDateCreatednã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 recomendadosGetInfoEcommAsync(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
RecommendedItemsdentro 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
QueryMultipleexecuta 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,
QueryMultipleseria mais lento do queTask.WhenAllcom 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
| # | Vantagem | Detalhe |
|---|---|---|
| 1 | Zero impacto no multi-map existente | GetCartInfoAsync não muda; sem risco de regressão |
| 2 | Sem explosão cartesiana | A nova query retorna exatamente N linhas para N recomendados |
| 3 | Paralelismo real | 3 queries disparam simultaneamente via Task.WhenAll |
| 4 | Isolamento de fluxos | Apenas GetInfoStoreAsync chama GetRecommendedItemsAsync; ecomm não é tocado |
| 5 | splitOn não é perturbado | Sem colunas extras ou aliases duplicados |
| 6 | Facilita RF-02 | DateCreated adicionado ao CartInfoDTO sem impacto no multi-map dos items |
| 7 | Padrão idiomático | Task.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
GetCartInfoAsynceGetPaymentInfoAsync. A tabelaCartRecommendedItemsé pequena (poucos itens por carrinho, sem agregações), então a latência adicional é desprezível.
Comparativo Final
| Critério | Alternativa A (JOIN) | Alternativa A2 (QueryMultiple) | Alternativa B (Query Separada) |
|---|---|---|---|
| Produto cartesiano | Sim — cresce cubicamente | Não — SELECTs independentes | Não — query isolada |
Colisão de alias em Products | Sim — splitOn frágil | Não | Não |
| Padrão no codebase | Existente (multi-map) | Ausente — zero ocorrências no repo | Existente (Task.WhenAll) |
| Quebra de assinatura do método | Sim | Sim — retorno vira tupla ou duplica método | Não — método novo |
| Acoplamento com fluxo ecomm | Sim | Sim | Não — isolado no store |
| Paralelismo real | Não | Não — SELECTs sequenciais no statement | Sim — Task.WhenAll |
| Latência relativa | Pior | Pior que B (sem paralelismo) | Melhor |
| Conexão aberta por mais tempo | Não | Sim — GridReader mantém conexão | Não |
| Risco de regressão | Alto | Alto | Zero |
| Decisão | ❌ Descartada | ❌ Descartada | ✅ Adotada |