Design Document: [Back] Substituir consultas concorrentes por consulta única na validação de marcas
Requisitos: task-197183-...-requisitos.md Glossário: ../context/CONTEXT.md
Grilling: concluído em 26/06/2026 — decisões sincronizadas nos requisitos (RF-01 a RF-05)
Overview
Refatoração pontual no ProductRepository.IsBrandsValid (Cart.Infrastructure) para substituir N consultas EF concorrentes por uma única conexão e uma única query Dapper ao DbCore, preservando o contrato de IProductRepository e o comportamento dos consumidores.
O fluxo de criação de carrinho permanece inalterado: CartController → CartService.Handle(CreateCartCommand) → pipeline de handlers (SaleEcommerceBrandsValidHandler) → IProductRepository.IsBrandsValid. Apenas a implementação interna do repositório muda.
Escopo da entrega: 1 método refatorado + testes unitários novos no Cart.UnitTests. Nenhum handler, controller ou interface pública é alterado.
Contexto
A US 197129 identifica débito técnico na validação de marcas do fluxo de venda ecommerce (SaleEcommerce = true). O AS-IS em ProductRepository.IsBrandsValid cria, para cada ProductId:
- um
IServiceScopenovo; - uma instância ad-hoc de
MultiTenantContextProviderRead<CoreOrgDbContextRead>; - uma query EF individual executada em paralelo via
Task.WhenAll.
Com carrinhos de muitos itens, isso gera pressão desnecessária no pool de conexões. A task 197183 consolida essa leitura em uma operação única via IDbConnectionFactory + Dapper, seguindo o padrão já adotado em ResolveEcommercePrice e CartRepository, conforme RF-01 a RF-04.
Consumidores que dependem do método (inalterados):
| Consumidor | Quando invoca |
|---|---|
SaleEcommerceBrandsValidHandler | CreateCartCommand.SaleEcommerce = true |
CartService (fluxo Assistant) | Antes de montar CreateCartCommand com SaleEcommerce = true |
Decisões de Design
1. Dapper com query IN em vez de EF paralelo (RF-01, RF-02)
| Alternativa | Prós | Contras | Decisão |
|---|---|---|---|
A — Dapper + WHERE "Id" = ANY(@ProductIds) | Uma connection, uma round-trip, alinhado aos requisitos | SQL explícito com schema multitenant | Escolhida |
B — EF Core com Where(x => productIds.Contains(x.Id)) em um único DbContext | Menos SQL manual | Requisitos pedem Dapper; ainda acopla ao EF para leitura pontual | Rejeitada |
C — Manter Task.WhenAll otimizando apenas o contexto | Mudança mínima | Continua com N queries e N round-trips | Rejeitada |
2. Conexão via IDbConnectionFactory + schema via parâmetro schemaName (RF-01)
| Alternativa | Prós | Contras | Decisão |
|---|---|---|---|
A — IDbConnectionFactory.GetConnectionAsync(ReadOnly, CoreDb) + schemaName interpolado na query | Padrão do projeto (ResolveEcommercePrice, CartRepository); uma connection via factory; reutiliza o schema já resolvido pelo caller | — | Escolhida |
B — IUserProvider.GetSchemaName() dentro do repositório | Centraliza resolução de schema | Duplica responsabilidade — o método já recebe schemaName; callers (SchemaNameHandler, CartService Assistant) já passam o valor | Rejeitada |
C — IConfiguration + NpgsqlConnection + PostgresCoreDbOrgRead | Controle direto da connection string | Fora do padrão do ProductRepository; AS-IS usava CoreOrg ad-hoc | Rejeitada |
D — MultiTenantContextProviderRead com EF (AS-IS otimizado) | Familiar ao código legado | N conexões / N queries | Rejeitada |
Detalhes da decisão (grilling 26/06/2026):
- Conexão:
_dbConnectionFactory.GetConnectionAsync(EConnectionType.ReadOnly, EDatabaseType.CoreDb)— mesma abordagem deGetProductByIdAndSkuAsync/ResolveEcommercePrice. - Schema: parâmetro
schemaNameinterpolado no SQL como{schemaName}."Products"— o caller já resolve o tenant schema antes de invocar o método. - Callers validados no código:
SaleEcommerceBrandsValidHandlerpassacontext.SchemaName(definido porSchemaNameHandlerviaIUserProvider);CartServiceAssistant passaschemade_userProvider.GetSchemaName(). O repositório não reconsultaIUserProvider.
2b. Fonte de dados: DbCore (não DbCoreOrg) (RF-01, grilling 26/06/2026)
| Alternativa | Prós | Contras | Decisão |
|---|---|---|---|
A — {schemaName}."Products" no DbCore via EDatabaseType.CoreDb | Mesmo padrão de CartRepository; alinhado ao IDbConnectionFactory escolhido | Diferente do AS-IS (que consultava CoreOrgDbContextRead.Products) | Escolhida |
B — Manter CoreOrgDbContextRead.Products (DbCoreOrg) | Comportamento idêntico ao legado | Inconsistente com factory CoreDb; exige conexão separada ao CoreOrg | Rejeitada |
O AS-IS abria MultiTenantContextProviderRead apontando para DbCoreOrg. A refatoração migra a leitura de Brand para a tabela Products no schema do tenant no DbCore — mesma referência usada em joins do CartRepository.
2c. Produto inexistente: sem validação de contagem 1:1 (RF-02, grilling 26/06/2026)
Quando algum ProductId não existe em Products, a query retorna marcas apenas dos IDs encontrados. A validação não compara COUNT(productIds) vs COUNT(brands retornadas) — comportamento AS-IS preservado. Se nenhuma marca for retornada, IsBrandsValid retorna false. Produtos inexistentes são tratados em etapas anteriores do pipeline (CommandToCartModelHandler, estoque).
2d. Guard defensivo para schemaName vazio (RF-01, grilling 26/06/2026)
Se schemaName for null ou whitespace, IsBrandsValid retorna false imediatamente — sem abrir conexão. Fail-safe que evita SQL malformado. No fluxo CreateCart, o SchemaNameHandler já barra antes; no Assistant, o guard protege caso IUserProvider retorne vazio.
3. Preservar IsMultibrandStore sem alteração (RF-02)
A regra de negócio permanece no método privado existente:
- válido: uma única marca;
- válido: exatamente
ActionBrands.AREZZO+ActionBrands.BRIZZA; - inválido: qualquer outra combinação;
- inválido: nenhuma marca retornada.
4. Handlers e contrato público intocados (RF-03)
SaleEcommerceBrandsValidHandler e IProductRepository não são modificados. A otimização é transparente aos consumidores.
5. Remoção de IServiceProvider e IConfiguration do ProductRepository (RF-01, grilling 26/06/2026)
Após a refatoração, IServiceProvider (scopes per-product) e IConfiguration (connection string CoreOrg ad-hoc) ficam sem uso no repositório. Remover ambos do construtor nesta entrega, ajustando DI e testes (ProductRepository_ResolveEcommercePriceTests e demais instanciações manuais).
Architecture
Diagrama de Fluxo
Camadas e Responsabilidades
| Camada | Componente | Tipo | Responsabilidade |
|---|---|---|---|
| API | CartController | Existente | Recebe CreateCartCommand, delega ao CartService |
| API | CartService | Existente | Orquestra pipeline de handlers; fluxo Assistant chama IsBrandsValid |
| API | SaleEcommerceBrandsValidHandler | Existente | Gate de negócio: valida marcas quando SaleEcommerce = true |
| Domain | IProductRepository | Existente | Contrato IsBrandsValid(IEnumerable<int>, string) — inalterado |
| Infrastructure | ProductRepository | Modificado | Implementa consulta única Dapper + IsMultibrandStore |
| Infrastructure | IDbConnectionFactory | Existente | Fornece conexão read-only ao DbCore (EDatabaseType.CoreDb) |
| Infrastructure | DbCore Products | Existente | Tabela {schemaName}."Products" — coluna Brand por Id (mesmo padrão de CartRepository) |
| UnitTests | ProductRepository_IsBrandsValidTests | Novo | Cobre regras de marca e verificação de query única |
Components and Interfaces
Novos Arquivos
| Arquivo | Camada | Tipo |
|---|---|---|
Cart.UnitTests/Infrastructure/Data/Repositories/ProductRepository_IsBrandsValidTests.cs | UnitTests | Testes do método refatorado |
Arquivos Modificados
| Arquivo | Modificação |
|---|---|
Cart.Infrastructure/Data/Repositories/ProductRepository.cs | Refatorar IsBrandsValid; remover IServiceProvider e IConfiguration do construtor; implementar query Dapper única via IDbConnectionFactory + parâmetro schemaName |
Cart.UnitTests/.../ProductRepository_ResolveEcommercePriceTests.cs | Ajustar construtor do SUT (remover mocks de IServiceProvider / IConfiguration) |
Interfaces
Contrato existente — sem alteração (RF-03):
// Cart.Domain/Interfaces/Repositories/IProductRepository.cs
Task<bool> IsBrandsValid(IEnumerable<int> products, string schemaName);
Implementação proposta do método (RF-01, RF-02):
// Cart.Infrastructure/Data/Repositories/ProductRepository.cs
public async Task<bool> IsBrandsValid(IEnumerable<int> products, string schemaName)
{
// RF-01 / 2d: guards defensivos
if (string.IsNullOrWhiteSpace(schemaName))
return false;
var productIds = products.Distinct().ToArray();
if (productIds.Length == 0)
return false;
// RF-01: schema recebido como parâmetro (já resolvido pelo caller)
var sql = $"""
SELECT "Brand"
FROM {schemaName}."Products"
WHERE "Id" = ANY(@ProductIds)
""";
// RF-01: uma única conexão read-only via factory (padrão ResolveEcommercePrice)
using var connection = await _dbConnectionFactory
.GetConnectionAsync(EConnectionType.ReadOnly, EDatabaseType.CoreDb);
var brands = (await connection.QueryAsync<int>(sql, new { ProductIds = productIds }))
.ToList();
// RF-02: delegar regra de negócio existente
if (!brands.Any())
return false;
return IsMultibrandStore(brands);
}
// RF-02: método privado preservado sem alteração
bool IsMultibrandStore(IEnumerable<int> brands)
{
var brandsSet = brands.ToHashSet();
return brandsSet.Count == 1
|| (brandsSet.Count > 1
&& brandsSet.SetEquals(new[] { (int)ActionBrands.AREZZO, (int)ActionBrands.BRIZZA }));
}
Data Models
Entidades envolvidas
// Tabela {schema}."Products" no DbCore — mesma referência usada em CartRepository
// Core.OrgDB/Entities/ProductModel.cs (entidade de domínio)
public class ProductModel : BaseEntity, IAggregateRoot
{
public int Brand { get; set; }
// ... demais propriedades não consultadas nesta task
}
// Core.DB/Enums/ActionBrands.cs — regra multibrand
public enum ActionBrands
{
AREZZO = 1,
SCHUTZ = 2,
BRIZZA = 31,
// ...
}
Diagrama de Relacionamento
Query SQL (equivalente lógico):
-- schemaName passado pelo caller (SchemaNameHandler / CartService Assistant)
SELECT "Brand"
FROM {schemaName}."Products"
WHERE "Id" = ANY(@ProductIds)
Error Handling
Tabela de Erros
| Cenário | Status HTTP | Mensagem | RF |
|---|---|---|---|
SaleEcommerce = false | — (handler não invoca) | — | RF-03 |
| Combinação de marcas inválida | 400 (via BaseResult) | Não é possível realizar venda de prateleira para produtos de marcas diferentes! | RF-02, RF-03 |
| Nenhum produto encontrado no banco | 400 (via BaseResult) | Mesma mensagem acima (IsBrandsValid retorna false) | RF-02 |
Lista de ProductId vazia | 400 (via BaseResult) | Mesma mensagem acima | RF-02 |
schemaName null ou vazio | 400 (via BaseResult) | Mesma mensagem acima (IsBrandsValid retorna false — guard 2d) | RF-01 |
| Exceção de banco (timeout, conexão) | 400 (via BaseResult) | Mensagem da exceção (comportamento AS-IS do handler) | RF-03 |
schemaName inválido/ausente | 400 (via SchemaNameHandler, antes da validação) | Não foi possível localizar o schema para conexão com o Checkout! | — |
Handler Pseudocode
SaleEcommerceBrandsValidHandler — sem alteração (RF-03):
public override async Task<CreateCartContext> ImplementHandle(CreateCartContext context)
{
// RF-03: skip quando não é venda ecommerce
if (context.Command.SaleEcommerce == false)
return context;
// RF-03: mesma chamada, implementação otimizada no repositório
var isBrandsValid = await _productRepository.IsBrandsValid(
context.Command.CartItems.Select(x => x.ProductId),
context.SchemaName!);
if (isBrandsValid)
return context;
// RF-03: mensagem de erro inalterada
return context with
{
BaseResult = BaseResult<CartCreatedDTO>.WithError(
"Não é possível realizar venda de prateleira para produtos de marcas diferentes!")
};
}
Testing Strategy
Testes Unitários do Handler (existentes — RF-04)
Os 5 testes em SaleEcommerceBrandsValidHandlerTest devem continuar passando sem modificação, pois mockam IProductRepository.
Testes Unitários do Repositório (novos — RF-04)
Arquivo: ProductRepository_IsBrandsValidTests.cs
Padrão: reutilizar FakeDbConnection e Mock<IDbConnectionFactory> já existentes em ProductRepository_ResolveEcommercePriceTests.
| Cenário | Entrada | Resultado esperado | RF |
|---|---|---|---|
| Marca única (AREZZO) | productIds: [1, 2], brands: [1, 1] | true | RF-02 |
| Combinação AREZZO + BRIZZA | productIds: [1, 2], brands: [1, 31] | true | RF-02 |
| Combinação inválida (AREZZO + SCHUTZ) | productIds: [1, 2], brands: [1, 2] | false | RF-02 |
| Nenhum produto no banco | productIds: [99], brands: [] | false | RF-02 |
| Produto parcialmente encontrado | productIds: [10, 20, 30], apenas 10 e 20 existem | Valida com marcas retornadas (sem exigir contagem 1:1) — AS-IS | RF-02 |
| Lista vazia de IDs | productIds: [] | false (sem abrir conexão) | RF-01, RF-02 |
| schemaName vazio | schemaName: "" ou null | false (sem abrir conexão — guard 2d) | RF-01 |
| IDs duplicados | productIds: [1, 1, 2] | Dedup antes da query; resultado igual ao cenário sem duplicatas | RF-01 |
| Query única | productIds: [1, 2, 3] | GetConnectionAsync(ReadOnly, CoreDb) chamado uma vez; QueryAsync chamado uma vez | RF-01, RF-04 |
| Schema via parâmetro | schemaName: "arezzo" | SQL gerado com {schemaName}."Products" — sem chamada a IUserProvider | RF-01 |
Dependências
| Dependência | Status | Nota |
|---|---|---|
coezzion-service-cart | Disponível | Repositório alvo |
IDbConnectionFactory | Disponível | EConnectionType.ReadOnly + EDatabaseType.CoreDb → PostgresCoreDbRead |
DbCore / tabela Products | Disponível | {schemaName}."Products" — coluna Brand (int) |
Dapper | Disponível | Já referenciado no projeto |
SaleEcommerceBrandsValidHandler | Disponível | Consumidor principal — inalterado |
CartService (Assistant) | Disponível | Segundo consumidor — inalterado |
| Task #197343 — [QA] Validação | Pendente | Testes funcionais pós-deploy |
Checklist de Qualidade
- RF-01 coberto — consulta única com uma connection Dapper
- RF-02 coberto —
IsMultibrandStorepreservado - RF-03 coberto — contrato e handlers inalterados
- RF-04 coberto — estratégia de testes definida
- Tratamento de erro mapeado para todos os cenários conhecidos
- Segue padrão existente do
ProductRepository(IDbConnectionFactory+ Dapper +using+ schema via parâmetro) - Sem lock desnecessário (elimina
lockdo AS-IS) - Response consistente com
BaseResult/ mensagem pt-BR existente