Skip to main content

Design Document: Cart API — Endpoint Add Item

Overview

Este design define a arquitetura e componentes necessários para implementar o endpoint POST api/cart/{cartId}/items no coezzion-service-cart. O endpoint permite que o checkout service adicione um item recomendado ao carrinho, resolvendo todos os dados do item a partir do CartItemRecommendationModel e marcando a recomendação como efetivada.

O fluxo segue o padrão existente da Cart API: Controller → CartService.Handle(command) → Repository, usando BaseResult para respostas de negócio. O handler valida o cart, a recomendação, constrói o CartItemsModel, recalcula totais e persiste.

Contexto

Este endpoint é chamado exclusivamente pelo coezzion-service-checkout (task 194880) via x-api-key: "internal". O consumidor é o CartIntegrationService no checkout service.

Decisões de Design

  1. Handler via CartService (não ZZMediator): A Cart API usa ICartService com sobrecarga de métodos Handle para commands e queries. O novo endpoint segue este padrão — CartService.Handle(AddItemToCartCommand) — consistente com DeleteCartCommand, ChangeCartDataCommand, etc.

  2. Sem lock distribuído: Ao contrário do endpoint de checkout (task 194880), não há race condition sobre pagamentos. O lock não é necessário.

  3. EF Core com tracking para persistência: GetByIdWithRecommendationsAsync usa _dbContext (write context) com .Include(c => c.CartItems).Include(c => c.CartItemRecommendations), sem AsNoTracking, para permitir SaveChangesAsync ao final.

  4. Body mínimo: Apenas { recommendedItemId: int }. Todos os dados do item (productId, size, fullPrice, unitPrice, discountType, discountValue, hasEmployeeDiscount) são resolvidos do CartItemRecommendationModel. Quantity sempre 1.

  5. ApiKey "internal": [AllowAnonymous] + [UseApiKey("internal")] — mesmo padrão de POST api/cart/assistant. A chave é configurada em appsettings.jsonApiKeys:internal.

  6. Response 200 sem corpo: Consistente com DeleteCartCommand. O consumidor (checkout service) não precisa de resposta — apenas confirmação de sucesso.

Architecture

Diagrama de Fluxo

Camadas e Responsabilidades

CamadaComponenteTipoResponsabilidade
APICartControllerModificadoNovo endpoint POST api/cart/{cartId}/items
DomainAddItemToCartCommandNovoDTO do command com CartId e RecommendedItemId
APICartServiceModificadoHandler: validações + orquestração + persistência
DomainICartRepositoryModificadoNovo método GetByIdWithRecommendationsAsync
DomainICartServiceModificadoNova assinatura Handle(AddItemToCartCommand)
InfrastructureCartRepositoryModificadoImplementação EF Core do novo método de query

Components and Interfaces

Novos Arquivos

ArquivoCamadaTipo
src/Cart.Domain/Commands/AddItemToCartCommand.csDomainCommand

Arquivos Modificados

ArquivoModificação
src/Cart.API/Controllers/CartController.csNovo endpoint POST api/cart/{cartId}/items
src/Cart.Domain/Services/ICartService.csNova assinatura Handle(AddItemToCartCommand)
src/Cart.API/Application/CartService.csImplementação do handler
src/Cart.Domain/Interfaces/Repositories/ICartRepository.csNovo método GetByIdWithRecommendationsAsync
src/Cart.Infrastructure/Data/Repositories/CartRepository.csImplementação do novo método

Interfaces

// ICartRepository — NOVO MÉTODO (Cart.Domain/Interfaces/Repositories/ICartRepository.cs)
public interface ICartRepository : IRepository<CartModel>
{
// ... métodos existentes ...
Task<CartModel?> GetByIdWithRecommendationsAsync(int cartId); // NOVO
}

// ICartService — NOVA SOBRECARGA (Cart.Domain/Services/ICartService.cs)
public interface ICartService
{
// ... handles existentes ...
Task<BaseResult> Handle(AddItemToCartCommand command); // NOVO
}

Controller

// CartController — NOVO ENDPOINT (Cart.API/Controllers/CartController.cs)
// POST api/cart/{cartId}/items
/// <summary>
/// Adiciona um item recomendado ao carrinho
/// </summary>
/// <response code="200">Item adicionado com sucesso</response>
/// <response code="400">Falha na requisição</response>
/// <response code="401">ApiKey inválida</response>
[HttpPost("{cartId:int}/items")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[AllowAnonymous]
[UseApiKey("internal")]
public async Task<IActionResult> AddItemToCart(int cartId, [FromBody] AddItemToCartCommand command)
{
command.CartId = cartId;
var result = await _cartService.Handle(command);
return CustomResponse(result);
}

Data Models

AddItemToCartCommand (Novo)

// Cart.Domain/Commands/AddItemToCartCommand.cs
using System.Text.Json.Serialization;

namespace Cart.Domain.Commands;

public class AddItemToCartCommand
{
[JsonIgnore]
public int CartId { get; set; }

public int RecommendedItemId { get; set; }
}

Entidades envolvidas (JÁ EXISTEM)

// CartModel — métodos relevantes (Core.OrgDB.Entities)
public class CartModel
{
public int Id { get; set; }
public bool SaleEcommerce { get; set; }
public ICollection<CartItemsModel> CartItems { get; set; }
public ICollection<CartItemRecommendationModel> CartItemRecommendations { get; set; }

public void AddItems(ICollection<CartItemsModel> items);
public void CalculateTotal();
}

// CartItemsModel — construtor relevante (Core.OrgDB.Entities)
public class CartItemsModel
{
public CartItemsModel(int cartId, int productId, int quantity, string size,
decimal unitPrice, DiscountType discount, decimal discountValue,
decimal fullPrice = 0, bool hasEmployeeDiscount = false);
public bool FromRecommendation { get; set; } // default: false
}

// CartItemRecommendationModel — propriedades relevantes (Core.OrgDB.Entities)
public class CartItemRecommendationModel
{
public int Id { get; set; }
public int CartId { get; set; }
public int ProductId { get; set; }
public int? CartItemId { get; set; } // FK → CartItemsModel (nullable)
public string Size { get; set; }
public DiscountType DiscountType { get; set; }
public decimal DiscountValue { get; set; }
public decimal UnitPrice { get; set; }
public decimal FullPrice { get; set; }
public bool HasEmployeeDiscount { get; set; }
}

Diagrama de Relacionamento

Error Handling

Tabela de Erros

CenárioStatusMensagem
ApiKey ausente/inválida401(sem body — UseApiKeyAttribute)
Cart não encontrado400"Carrinho não encontrado"
SaleEcommerce == true400"Operação não disponível para ecommerce"
recommendedItemId <= 0400"recommendedItemId deve ser um número inteiro positivo"
Recomendação não encontrada400"Recomendação não encontrada"
Recomendação não pertence ao cart400"Recomendação não pertence ao carrinho informado"
Recomendação já efetivada (CartItemId != null)400"Item já adicionado ao carrinho"
FullPrice <= 0 ou UnitPrice <= 0400"Item com preço inválido"
Erro de persistência500"Erro interno ao adicionar item"

Handler Pseudocode (CartService)

public async Task<BaseResult> Handle(AddItemToCartCommand command)
{
// REQ-03: Fetch cart with recommendations and items
var cart = await _cartRepository.GetByIdWithRecommendationsAsync(command.CartId);
if (cart is null)
return BaseResult.WithError("Carrinho não encontrado");

if (cart.SaleEcommerce)
return BaseResult.WithError("Operação não disponível para ecommerce");

// REQ-03: Find the recommendation in cart's collection
var recommendation = cart.CartItemRecommendations
.FirstOrDefault(r => r.Id == command.RecommendedItemId);
if (recommendation is null)
return BaseResult.WithError("Recomendação não encontrada");

if (recommendation.CartId != command.CartId)
return BaseResult.WithError("Recomendação não pertence ao carrinho informado");

if (recommendation.CartItemId is not null)
return BaseResult.WithError("Item já adicionado ao carrinho");

if (recommendation.FullPrice <= 0 || recommendation.UnitPrice <= 0)
return BaseResult.WithError("Item com preço inválido");

// REQ-04: Build CartItemsModel from recommendation
var cartItem = new CartItemsModel(
cartId: command.CartId,
productId: recommendation.ProductId,
quantity: 1,
size: recommendation.Size,
unitPrice: recommendation.UnitPrice,
discount: recommendation.DiscountType,
discountValue: recommendation.DiscountValue,
fullPrice: recommendation.FullPrice,
hasEmployeeDiscount: recommendation.HasEmployeeDiscount
)
{
FromRecommendation = true
};

cart.AddItems([cartItem]);
cart.CalculateTotal();

// REQ-04: Mark recommendation as fulfilled
// (EF Core tracking: CartItemId will be populated after SaveChangesAsync
// since CartItemsModel.Id is an identity column)
recommendation.CartItemId = cartItem.Id; // 0 antes do SaveChangesAsync — ok,
// será 0 na FK mas o EF usa a
// referência em memória para o INSERT

// REQ-05: Persist
try
{
_cartRepository.Update(cart);
await _cartRepository.SaveAsync();
return new BaseResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to persist cart item. CartId: {CartId}, RecommendationId: {RecommendationId}",
command.CartId, command.RecommendedItemId);
return BaseResult.WithError("Erro interno ao adicionar item");
}
}

Nota sobre CartItemId: Como CartItemsModel.Id é identity column (gerado pelo banco), o Id do novo CartItemsModel será 0 até o SaveChangesAsync. No entanto, o EF Core com tracking gerencia a relação corretamente — ao chamar SaveChangesAsync, o INSERT gera o Id e o EF preenche o valor gerado no CartItemsModel.Id. Se o CartItemRecommendationModel.CartItemId foi setado para 0 (referenciando cartItem.Id antes do save), o EF Core pode não propagar o valor automaticamente. Alternativa recomendada: realizar o SaveChangesAsync em duas etapas ou usar o cartItem como referência de objeto e chamar SaveChangesAsync duas vezes:

cart.AddItems([cartItem]);
_cartRepository.Update(cart);
await _cartRepository.SaveAsync(); // persist item, Id populado
recommendation.CartItemId = cartItem.Id; // agora tem o Id real
await _cartRepository.SaveAsync(); // persist recommendation.CartItemId

Ou, alternativamente, usar cartItem como referência direta de objeto, deixando o EF gerenciar o CartItemId via FK — verificar abordagem final durante implementação.

New Repository Method

// CartRepository — implementação do novo método
// (Cart.Infrastructure/Data/Repositories/CartRepository.cs)
public async Task<CartModel?> GetByIdWithRecommendationsAsync(int cartId)
{
return await _dbContext.Carts
.Include(c => c.CartItems)
.Include(c => c.CartItemRecommendations)
.FirstOrDefaultAsync(x => x.Id == cartId);
}

Usa _dbContext (write context) — com tracking, pois o cart e a recomendação serão modificados e persistidos via SaveChangesAsync.

Testing Strategy

Testes Unitários do Handler (xUnit + Moq)

CenárioEntradaResultado esperado
Cart não encontradoGetByIdWithRecommendationsAsync retorna nullBaseResult com erro "Carrinho não encontrado"
SaleEcommerce == truecart.SaleEcommerce = trueBaseResult com erro "Operação não disponível para ecommerce"
Recomendação não encontradarecommendedItemId ausente na coleçãoBaseResult com erro "Recomendação não encontrada"
Recomendação não pertence ao cartrecommendation.CartId != command.CartIdBaseResult com erro
Recomendação já efetivadarecommendation.CartItemId != nullBaseResult com erro "Item já adicionado ao carrinho"
Preço inválido (FullPrice = 0)recommendation.FullPrice = 0BaseResult com erro "Item com preço inválido"
Preço inválido (UnitPrice = 0)recommendation.UnitPrice = 0BaseResult com erro "Item com preço inválido"
Sucesso — item adicionadoTodos os dados válidosBaseResult sucesso; CartItemsModel criado com FromRecommendation = true, Quantity = 1
Sucesso — totais recalculadosDados válidoscart.AddItems e cart.CalculateTotal chamados
Sucesso — recomendação marcadaDados válidosrecommendation.CartItemId setado
Falha de persistênciaSaveAsync lança ExceptionBaseResult com erro "Erro interno ao adicionar item"

Testes de Integração

  • Persistência real: criar cart com recomendação, chamar o handler, verificar que CartItems foi adicionado, CartTotal recalculado, e CartItemId na recomendação está setado
  • Transação: verificar rollback em caso de falha

Mock Setup (Exemplo)

// Setup típico do repositório
var cart = new CartModelBuilder()
.WithId(1)
.WithCartItemRecommendations(new List<CartItemRecommendationModel>
{
new(1, 100, "M", DiscountType.None, 0, 199.90m, 299.90m, false)
{
Id = 42
}
})
.Build();

_cartRepositoryMock
.Setup(r => r.GetByIdWithRecommendationsAsync(1))
.ReturnsAsync(cart);

Dependências

DependênciaStatusNota
CartItemRecommendationModel (Core.OrgDB)🟢 disponívelEntity já existe
CartItemsModel.FromRecommendation🟢 disponívelMigration 20260602152257
CartItemRecommendationModel.CartItemId🟢 disponívelMigration 20260615185608
CartModel.AddItems / CartModel.CalculateTotal🟢 disponívelMétodos existentes
UseApiKeyAttribute🟢 disponívelCart.API.Filters
BaseController / CustomResponse🟢 disponívelCoezzion.Common.Controllers
BaseResult🟢 disponívelCoezzion.Common.Communication
ApiKey "internal"🟢 disponívelConfigurada em appsettings.json

Checklist de Qualidade

  • Todos os requisitos (REQ-01 a REQ-05) cobertos
  • Tratamento de erro para todos os cenários conhecidos
  • Padrão de autenticação consistente com POST api/cart/assistant
  • Segue padrão existente ICartService.Handle(command)
  • Sem lock distribuído (não necessário)
  • Sem validação de estoque (fora de escopo)
  • Response 200 sem corpo (consumidor não precisa)
  • EF Core tracking para persistência em transação única