Skip to main content

Design Document

Overview

Este design define a arquitetura e componentes para implementar o endpoint DELETE api/cart/{cartId}/items/{cartItemId} no coezzion-service-cart. O endpoint permite que o checkout service remova um CartItem originado de uma recomendação aceita anteriormente, aplicando soft-delete no item, desvinculando a recomendação e recalculando os totais do cart.

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, o item e a origem, delega a lógica de domínio ao CartModel.RevertRecommendedItem(), e persiste.

Contexto

  • Endpoint chamado exclusivamente pelo coezzion-service-checkout via x-api-key: "internal"
  • Consumidor: CartIntegrationService no checkout service
  • Semântica idempotente: cart/item não encontrado retorna 200 silencioso

Decisões de Design

#DecisãoJustificativa
1Handler via CartService.Handle(RemoveCartItemCommand) — não ZZMediatorPadrão existente: ICartService com sobrecargas de Handle
2Idempotente para cart/item não encontrado (200 silencioso)DELETE é idempotente; consistente com padrão do add-item
3EF Core com tracking via GetByIdWithRecommendationsAsyncNecessário para persist via change tracking; método já existe
4Sem body no command — ambos params da rotaDELETE sem body; controller cria command manualmente
5ApiKey "internal" com [AllowAnonymous] + [UseApiKey("internal")] + [AddSchema]Mesmo padrão do POST api/cart/{cartId}/items
6Response 200 sem corpoConsistente com DeleteCartCommand; checkout relê via GetCartInfoAsync
7Lógica de domínio delegada a CartModel.RevertRecommendedItem(cartItemId)Encapsula soft-delete + desvincular recomendação + recalcular total
8Erros de persistência retornam 400 (não 500)Padrão existente: CustomResponse(BaseResult) produz 400 para todos os erros

Architecture

Diagrama de Fluxo

Camadas e Responsabilidades

CamadaComponenteTipoResponsabilidade
APICartControllerModificadoNovo endpoint DELETE api/cart/{cartId}/items/{cartItemId}
DomainRemoveCartItemCommandNovoDTO do command com CartId e CartItemId
APICartServiceModificadoHandler: validações + orquestração + persistência
DomainICartServiceModificadoNova assinatura Handle(RemoveCartItemCommand)
DB-CoreCartModel.RevertRecommendedItemJá existeLógica de domínio: soft-delete + desvincular + recalcular

Components and Interfaces

Novos Arquivos

ArquivoCamadaTipo
src/Cart.Domain/Commands/RemoveCartItemCommand.csDomainCommand DTO

Arquivos Modificados

ArquivoModificação
src/Cart.API/Controllers/CartController.csNovo endpoint DELETE
src/Cart.Domain/Services/ICartService.csNova assinatura Handle(RemoveCartItemCommand)
src/Cart.API/Application/CartService.csImplementação do handler

Arquivos de Teste

ArquivoTipo
src/Cart.UnitTests/Application/Commands/RemoveCartItemHandlerTests.csTeste unitário do handler

Interface — ICartService (nova sobrecarga)

public interface ICartService
{
// ... handles existentes ...
Task<BaseResult> Handle(RemoveCartItemCommand command); // NOVO
}

Controller — Novo Endpoint

/// <summary>
/// Remove um item recomendado do carrinho
/// </summary>
/// <response code="200">Item removido com sucesso ou idempotente (cart/item não encontrado)</response>
/// <response code="400">Falha na validação</response>
/// <response code="401">ApiKey inválida</response>
[HttpDelete("{cartId:int}/items/{cartItemId:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[AllowAnonymous]
[UseApiKey("internal")]
[AddSchema]
public async Task<IActionResult> RemoveCartItem(int cartId, int cartItemId)
{
var command = new RemoveCartItemCommand { CartId = cartId, CartItemId = cartItemId };
var result = await _cartService.Handle(command);
return CustomResponse(result);
}

Handler — CartService.Handle(RemoveCartItemCommand)

public async Task<BaseResult> Handle(RemoveCartItemCommand command)
{
var baseResult = new BaseResult();

var cart = await _cartRepository.GetByIdWithRecommendationsAsync(command.CartId);

if (cart is null)
return baseResult;

if (cart.SaleEcommerce)
{
baseResult.Errors.Add("Operacao nao disponivel para este tipo de pedido");
return baseResult;
}

var item = cart.CartItems.FirstOrDefault(i => i.Id == command.CartItemId);

if (item is null)
return baseResult;

if (!item.FromRecommendation)
{
baseResult.Errors.Add("Item nao originado de recomendacao");
return baseResult;
}

cart.RevertRecommendedItem(command.CartItemId);

try
{
_cartRepository.Update(cart);
await _cartRepository.SaveAsync();
}
catch (Exception)
{
baseResult.Errors.Add("Erro interno ao processar a requisicao");
return baseResult;
}

return baseResult;
}

Nota sobre CustomResponse(BaseResult): Quando result.IsValid() é true e result.Response é null, CustomResponse retorna Ok(null) → HTTP 200 sem corpo. Quando há erros, retorna BadRequest com os erros → HTTP 400.

Data Models

RemoveCartItemCommand (Novo)

namespace Cart.Domain.Commands;

public class RemoveCartItemCommand
{
public int CartId { get; set; }
public int CartItemId { get; set; }
}

Sem [JsonIgnore]: Diferente do AddItemToCartCommand onde CartId tem [JsonIgnore] (vem da rota mas body contém RecommendedItemId), o RemoveCartItemCommand não possui body. Ambas propriedades setadas pelo controller a partir de route params.

Entidades Existentes (NÃO modificar — coezzion-db-core)

// CartModel — método de domínio (Core.OrgDB.Entities.CartModel.cs:209-229)
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 RevertRecommendedItem(int cartItemId)
{
var item = CartItems.FirstOrDefault(p => p.Id == cartItemId);
if (item != null)
{
if (item.FromRecommendation)
{
item.Delete();
var recommendation = CartItemRecommendations.FirstOrDefault(p => p.CartItemId == cartItemId);
if (recommendation != null)
{
recommendation.CartItem = null;
recommendation.CartItemId = null;
}
CalculateTotal();
}
}
}

public void CalculateTotal()
{
ItemsTotal = CartItems.Where(p => !p.DateDeleted.HasValue).Sum(p => p.CalculateTotal());
}
}

// CartItemsModel (Core.OrgDB.Entities.CartItemsModel.cs:57)
public class CartItemsModel
{
public int Id { get; set; }
public bool FromRecommendation { get; set; } // default: false
}

// CartItemRecommendationModel
public class CartItemRecommendationModel
{
public int Id { get; set; }
public int? CartItemId { get; set; } // null = disponível para re-adição
public CartItemsModel CartItem { get; set; }
}

Diagrama de Relacionamento

Correctness Properties

A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.

Property 1: Handler idempotency for missing resources

For any cartId that does not correspond to an existing cart, or for any cartItemId that does not exist in the cart's CartItems collection, the handler SHALL return a valid BaseResult without errors (HTTP 200), and SHALL NOT call Update or SaveAsync on the repository.

Validates: Requirements 3.2, 5.2

Property 2: Validation guards reject invalid state

For any cart where SaleEcommerce is true, or for any item where FromRecommendation is false, the handler SHALL return a BaseResult containing exactly one error message and SHALL NOT call Update or SaveAsync on the repository.

Validates: Requirements 4.1, 6.1

Error Handling

CenárioHTTP StatusMensagemResponsável
ApiKey ausente/inválida401(sem body)UseApiKeyAttribute (filtro)
cartId/cartItemId inválidos na rota400(ASP.NET route binding)Framework
Cart não encontrado200(sem erro — idempotente)Handler retorna BaseResult válido
SaleEcommerce == true400"Operacao nao disponivel para este tipo de pedido"Handler
Item não encontrado200(sem erro — idempotente)Handler retorna BaseResult válido
FromRecommendation == false400"Item nao originado de recomendacao"Handler
Sucesso — item removido200(sem corpo)Handler retorna BaseResult válido
Erro de persistência400"Erro interno ao processar a requisicao"Handler (catch)

400 vs 500 para erros de persistência: O padrão existente do codebase usa CustomResponse(BaseResult) que produz 400 para todos os erros. Os handlers AddItemToCartCommand e ChangeCartDataCommand usam o mesmo padrão baseResult.Errors.Add(...) no catch → 400. Este design mantém consistência com o codebase.

Testing Strategy

Abordagem

Este feature utiliza testes unitários example-based com xUnit + Moq como estratégia principal. As propriedades de correctness (idempotência e rejeição de guards) são verificáveis via testes example-based dado que os cenários são finitos e discretos — não há transformação de dados com amplitude de inputs que justifique 100+ iterações randomizadas.

Razões para não usar PBT com generators randomizados:

  • O handler é uma operação CRUD com guards sequenciais booleanos
  • Cada branch é determinístico (null/not-null, true/false)
  • A lógica de domínio (RevertRecommendedItem) já existe no db-core
  • 7 cenários example-based cobrem 100% dos branches do handler

Cenários de Teste

#CenárioMock SetupResultado Esperado
1Cart não encontradoGetByIdWithRecommendationsAsyncnullBaseResult válido, sem erros, Update não chamado
2SaleEcommerce == trueCart com SaleEcommerce = trueBaseResult com erro "Operacao nao disponivel para este tipo de pedido"
3Item não encontrado na coleçãocartItemId inexistente em CartItemsBaseResult válido, sem erros, Update não chamado
4FromRecommendation == falseItem encontrado com FromRecommendation = falseBaseResult com erro "Item nao originado de recomendacao"
5Sucesso — item removido e persistidoDados válidosBaseResult válido; Update once; SaveAsync once
6Sucesso — recomendação desvinculadaDados válidos com recommendation vinculadarecommendation.CartItemId == null; item com DateDeleted setado
7Erro de persistênciaSaveAsync lança ExceptionBaseResult com erro "Erro interno ao processar a requisicao"

Estrutura do Teste

  • Arquivo: src/Cart.UnitTests/Application/Commands/RemoveCartItemHandlerTests.cs
  • Classe: RemoveCartItemHandlerTests
  • Trait: [Trait("Layer", "Application - Commands")]
  • Instanciação: CartService com todos os mocks (mesmo padrão dos testes existentes)
  • Nomenclatura: Handle_[Cenário]_[ResultadoEsperado]

Dependências de Teste

MockUsado nos cenários
ICartRepository.GetByIdWithRecommendationsAsyncTodos (1-7)
ICartRepository.Update5, 6, 7
ICartRepository.SaveAsync5, 6, 7

Dependências

DependênciaStatusNota
CartModel.RevertRecommendedItem(int)🟢 disponívelCartModel.cs:209-229
CartModel.CalculateTotal() — filtra DateDeleted🟢 disponívelCartModel.cs:233
CartItemsModel.FromRecommendation (bool)🟢 disponívelCartItemsModel.cs:57
ICartRepository.GetByIdWithRecommendationsAsync(int)🟢 disponívelUsado pelo AddItemToCartCommand
CartRepository.Update() — invalida cache🟢 disponívelLinha 79
UseApiKeyAttribute🟢 disponívelCart.API.Filters
AddSchemaAttribute🟢 disponívelCart.API.Configuration
BaseController.CustomResponse(BaseResult)🟢 disponívelCoezzion.Common.Controllers
BaseResult🟢 disponívelCoezzion.Common.Communication