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
-
Handler via CartService (não ZZMediator): A Cart API usa
ICartServicecom sobrecarga de métodosHandlepara commands e queries. O novo endpoint segue este padrão —CartService.Handle(AddItemToCartCommand)— consistente comDeleteCartCommand,ChangeCartDataCommand, etc. -
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.
-
EF Core com tracking para persistência:
GetByIdWithRecommendationsAsyncusa_dbContext(write context) com.Include(c => c.CartItems).Include(c => c.CartItemRecommendations), semAsNoTracking, para permitirSaveChangesAsyncao final. -
Body mínimo: Apenas
{ recommendedItemId: int }. Todos os dados do item (productId,size,fullPrice,unitPrice,discountType,discountValue,hasEmployeeDiscount) são resolvidos doCartItemRecommendationModel.Quantitysempre1. -
ApiKey "internal":
[AllowAnonymous] + [UseApiKey("internal")]— mesmo padrão dePOST api/cart/assistant. A chave é configurada emappsettings.json→ApiKeys:internal. -
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
| Camada | Componente | Tipo | Responsabilidade |
|---|---|---|---|
| API | CartController | Modificado | Novo endpoint POST api/cart/{cartId}/items |
| Domain | AddItemToCartCommand | Novo | DTO do command com CartId e RecommendedItemId |
| API | CartService | Modificado | Handler: validações + orquestração + persistência |
| Domain | ICartRepository | Modificado | Novo método GetByIdWithRecommendationsAsync |
| Domain | ICartService | Modificado | Nova assinatura Handle(AddItemToCartCommand) |
| Infrastructure | CartRepository | Modificado | Implementação EF Core do novo método de query |
Components and Interfaces
Novos Arquivos
| Arquivo | Camada | Tipo |
|---|---|---|
src/Cart.Domain/Commands/AddItemToCartCommand.cs | Domain | Command |
Arquivos Modificados
| Arquivo | Modificação |
|---|---|
src/Cart.API/Controllers/CartController.cs | Novo endpoint POST api/cart/{cartId}/items |
src/Cart.Domain/Services/ICartService.cs | Nova assinatura Handle(AddItemToCartCommand) |
src/Cart.API/Application/CartService.cs | Implementação do handler |
src/Cart.Domain/Interfaces/Repositories/ICartRepository.cs | Novo método GetByIdWithRecommendationsAsync |
src/Cart.Infrastructure/Data/Repositories/CartRepository.cs | Implementaçã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ário | Status | Mensagem |
|---|---|---|
| ApiKey ausente/inválida | 401 | (sem body — UseApiKeyAttribute) |
| Cart não encontrado | 400 | "Carrinho não encontrado" |
SaleEcommerce == true | 400 | "Operação não disponível para ecommerce" |
recommendedItemId <= 0 | 400 | "recommendedItemId deve ser um número inteiro positivo" |
| Recomendação não encontrada | 400 | "Recomendação não encontrada" |
| Recomendação não pertence ao cart | 400 | "Recomendação não pertence ao carrinho informado" |
Recomendação já efetivada (CartItemId != null) | 400 | "Item já adicionado ao carrinho" |
FullPrice <= 0 ou UnitPrice <= 0 | 400 | "Item com preço inválido" |
| Erro de persistência | 500 | "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: ComoCartItemsModel.Idé identity column (gerado pelo banco), oIddo novoCartItemsModelserá0até oSaveChangesAsync. No entanto, o EF Core com tracking gerencia a relação corretamente — ao chamarSaveChangesAsync, o INSERT gera oIde o EF preenche o valor gerado noCartItemsModel.Id. Se oCartItemRecommendationModel.CartItemIdfoi setado para0(referenciandocartItem.Idantes do save), o EF Core pode não propagar o valor automaticamente. Alternativa recomendada: realizar oSaveChangesAsyncem duas etapas ou usar ocartItemcomo referência de objeto e chamarSaveChangesAsyncduas vezes:cart.AddItems([cartItem]);_cartRepository.Update(cart);await _cartRepository.SaveAsync(); // persist item, Id populadorecommendation.CartItemId = cartItem.Id; // agora tem o Id realawait _cartRepository.SaveAsync(); // persist recommendation.CartItemIdOu, alternativamente, usar
cartItemcomo referência direta de objeto, deixando o EF gerenciar oCartItemIdvia 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 viaSaveChangesAsync.
Testing Strategy
Testes Unitários do Handler (xUnit + Moq)
| Cenário | Entrada | Resultado esperado |
|---|---|---|
| Cart não encontrado | GetByIdWithRecommendationsAsync retorna null | BaseResult com erro "Carrinho não encontrado" |
| SaleEcommerce == true | cart.SaleEcommerce = true | BaseResult com erro "Operação não disponível para ecommerce" |
| Recomendação não encontrada | recommendedItemId ausente na coleção | BaseResult com erro "Recomendação não encontrada" |
| Recomendação não pertence ao cart | recommendation.CartId != command.CartId | BaseResult com erro |
| Recomendação já efetivada | recommendation.CartItemId != null | BaseResult com erro "Item já adicionado ao carrinho" |
| Preço inválido (FullPrice = 0) | recommendation.FullPrice = 0 | BaseResult com erro "Item com preço inválido" |
| Preço inválido (UnitPrice = 0) | recommendation.UnitPrice = 0 | BaseResult com erro "Item com preço inválido" |
| Sucesso — item adicionado | Todos os dados válidos | BaseResult sucesso; CartItemsModel criado com FromRecommendation = true, Quantity = 1 |
| Sucesso — totais recalculados | Dados válidos | cart.AddItems e cart.CalculateTotal chamados |
| Sucesso — recomendação marcada | Dados válidos | recommendation.CartItemId setado |
| Falha de persistência | SaveAsync lança Exception | BaseResult com erro "Erro interno ao adicionar item" |
Testes de Integração
- Persistência real: criar cart com recomendação, chamar o handler, verificar que
CartItemsfoi adicionado,CartTotalrecalculado, eCartItemIdna 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ência | Status | Nota |
|---|---|---|
CartItemRecommendationModel (Core.OrgDB) | 🟢 disponível | Entity já existe |
CartItemsModel.FromRecommendation | 🟢 disponível | Migration 20260602152257 |
CartItemRecommendationModel.CartItemId | 🟢 disponível | Migration 20260615185608 |
CartModel.AddItems / CartModel.CalculateTotal | 🟢 disponível | Métodos existentes |
UseApiKeyAttribute | 🟢 disponível | Cart.API.Filters |
BaseController / CustomResponse | 🟢 disponível | Coezzion.Common.Controllers |
BaseResult | 🟢 disponível | Coezzion.Common.Communication |
ApiKey "internal" | 🟢 disponível | Configurada 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