Skip to main content

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: CartControllerCartService.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 IServiceScope novo;
  • 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):

ConsumidorQuando invoca
SaleEcommerceBrandsValidHandlerCreateCartCommand.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)

AlternativaPrósContrasDecisão
A — Dapper + WHERE "Id" = ANY(@ProductIds)Uma connection, uma round-trip, alinhado aos requisitosSQL explícito com schema multitenantEscolhida
B — EF Core com Where(x => productIds.Contains(x.Id)) em um único DbContextMenos SQL manualRequisitos pedem Dapper; ainda acopla ao EF para leitura pontualRejeitada
C — Manter Task.WhenAll otimizando apenas o contextoMudança mínimaContinua com N queries e N round-tripsRejeitada

2. Conexão via IDbConnectionFactory + schema via parâmetro schemaName (RF-01)

AlternativaPrósContrasDecisão
A — IDbConnectionFactory.GetConnectionAsync(ReadOnly, CoreDb) + schemaName interpolado na queryPadrão do projeto (ResolveEcommercePrice, CartRepository); uma connection via factory; reutiliza o schema já resolvido pelo callerEscolhida
B — IUserProvider.GetSchemaName() dentro do repositórioCentraliza resolução de schemaDuplica responsabilidade — o método já recebe schemaName; callers (SchemaNameHandler, CartService Assistant) já passam o valorRejeitada
C — IConfiguration + NpgsqlConnection + PostgresCoreDbOrgReadControle direto da connection stringFora do padrão do ProductRepository; AS-IS usava CoreOrg ad-hocRejeitada
D — MultiTenantContextProviderRead com EF (AS-IS otimizado)Familiar ao código legadoN conexões / N queriesRejeitada

Detalhes da decisão (grilling 26/06/2026):

  • Conexão: _dbConnectionFactory.GetConnectionAsync(EConnectionType.ReadOnly, EDatabaseType.CoreDb) — mesma abordagem de GetProductByIdAndSkuAsync / ResolveEcommercePrice.
  • Schema: parâmetro schemaName interpolado no SQL como {schemaName}."Products" — o caller já resolve o tenant schema antes de invocar o método.
  • Callers validados no código: SaleEcommerceBrandsValidHandler passa context.SchemaName (definido por SchemaNameHandler via IUserProvider); CartService Assistant passa schema de _userProvider.GetSchemaName(). O repositório não reconsulta IUserProvider.

2b. Fonte de dados: DbCore (não DbCoreOrg) (RF-01, grilling 26/06/2026)

AlternativaPrósContrasDecisão
A — {schemaName}."Products" no DbCore via EDatabaseType.CoreDbMesmo padrão de CartRepository; alinhado ao IDbConnectionFactory escolhidoDiferente do AS-IS (que consultava CoreOrgDbContextRead.Products)Escolhida
B — Manter CoreOrgDbContextRead.Products (DbCoreOrg)Comportamento idêntico ao legadoInconsistente com factory CoreDb; exige conexão separada ao CoreOrgRejeitada

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

CamadaComponenteTipoResponsabilidade
APICartControllerExistenteRecebe CreateCartCommand, delega ao CartService
APICartServiceExistenteOrquestra pipeline de handlers; fluxo Assistant chama IsBrandsValid
APISaleEcommerceBrandsValidHandlerExistenteGate de negócio: valida marcas quando SaleEcommerce = true
DomainIProductRepositoryExistenteContrato IsBrandsValid(IEnumerable<int>, string) — inalterado
InfrastructureProductRepositoryModificadoImplementa consulta única Dapper + IsMultibrandStore
InfrastructureIDbConnectionFactoryExistenteFornece conexão read-only ao DbCore (EDatabaseType.CoreDb)
InfrastructureDbCore ProductsExistenteTabela {schemaName}."Products" — coluna Brand por Id (mesmo padrão de CartRepository)
UnitTestsProductRepository_IsBrandsValidTestsNovoCobre regras de marca e verificação de query única

Components and Interfaces

Novos Arquivos

ArquivoCamadaTipo
Cart.UnitTests/Infrastructure/Data/Repositories/ProductRepository_IsBrandsValidTests.csUnitTestsTestes do método refatorado

Arquivos Modificados

ArquivoModificação
Cart.Infrastructure/Data/Repositories/ProductRepository.csRefatorar IsBrandsValid; remover IServiceProvider e IConfiguration do construtor; implementar query Dapper única via IDbConnectionFactory + parâmetro schemaName
Cart.UnitTests/.../ProductRepository_ResolveEcommercePriceTests.csAjustar 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árioStatus HTTPMensagemRF
SaleEcommerce = false— (handler não invoca)RF-03
Combinação de marcas inválida400 (via BaseResult)Não é possível realizar venda de prateleira para produtos de marcas diferentes!RF-02, RF-03
Nenhum produto encontrado no banco400 (via BaseResult)Mesma mensagem acima (IsBrandsValid retorna false)RF-02
Lista de ProductId vazia400 (via BaseResult)Mesma mensagem acimaRF-02
schemaName null ou vazio400 (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/ausente400 (via SchemaNameHandler, antes da validação)Não foi possível localizar o schema para conexão com o Checkout!

Handler Pseudocode

SaleEcommerceBrandsValidHandlersem 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árioEntradaResultado esperadoRF
Marca única (AREZZO)productIds: [1, 2], brands: [1, 1]trueRF-02
Combinação AREZZO + BRIZZAproductIds: [1, 2], brands: [1, 31]trueRF-02
Combinação inválida (AREZZO + SCHUTZ)productIds: [1, 2], brands: [1, 2]falseRF-02
Nenhum produto no bancoproductIds: [99], brands: []falseRF-02
Produto parcialmente encontradoproductIds: [10, 20, 30], apenas 10 e 20 existemValida com marcas retornadas (sem exigir contagem 1:1) — AS-ISRF-02
Lista vazia de IDsproductIds: []false (sem abrir conexão)RF-01, RF-02
schemaName vazioschemaName: "" ou nullfalse (sem abrir conexão — guard 2d)RF-01
IDs duplicadosproductIds: [1, 1, 2]Dedup antes da query; resultado igual ao cenário sem duplicatasRF-01
Query únicaproductIds: [1, 2, 3]GetConnectionAsync(ReadOnly, CoreDb) chamado uma vez; QueryAsync chamado uma vezRF-01, RF-04
Schema via parâmetroschemaName: "arezzo"SQL gerado com {schemaName}."Products" — sem chamada a IUserProviderRF-01

Dependências

DependênciaStatusNota
coezzion-service-cartDisponívelRepositório alvo
IDbConnectionFactoryDisponívelEConnectionType.ReadOnly + EDatabaseType.CoreDbPostgresCoreDbRead
DbCore / tabela ProductsDisponível{schemaName}."Products" — coluna Brand (int)
DapperDisponívelJá referenciado no projeto
SaleEcommerceBrandsValidHandlerDisponívelConsumidor principal — inalterado
CartService (Assistant)DisponívelSegundo consumidor — inalterado
Task #197343 — [QA] ValidaçãoPendenteTestes funcionais pós-deploy

Checklist de Qualidade

  • RF-01 coberto — consulta única com uma connection Dapper
  • RF-02 coberto — IsMultibrandStore preservado
  • 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 lock do AS-IS)
  • Response consistente com BaseResult / mensagem pt-BR existente