Guides & specifications
MMCA Business Workflow Analysis
As of: 2026-09-07.
Workflow List Summary
| # | Workflow | Entry Point | Module |
|---|---|---|---|
| 1 | User Registration | POST /auth/register |
Identity |
| 2 | User Login | POST /auth/login |
Identity |
| 3 | Token Refresh | POST /auth/refresh |
Identity |
| 4 | Token Revocation | POST /auth/revoke |
Identity |
| 5 | Change Password | PUT /auth/password |
Identity |
| 6 | Change Customer Name | PUT /customers/{id}/name |
Identity |
| 7 | Change Customer Address | PUT /customers/{id}/address |
Identity |
| 8 | Change Customer Email | PUT /customers/{id}/email |
Identity |
| 9 | Create Customer | POST /customers |
Identity |
| 10 | Delete Customer | DELETE /customers/{id} |
Identity |
| 11 | Create Category | POST /categories |
Catalog |
| 12 | Rename Category | PUT /categories/{id}/name |
Catalog |
| 13 | Assign Parent Category | PUT /categories/{id}/parentcategory |
Catalog |
| 14 | Delete Category | DELETE /categories/{id} |
Catalog |
| 15 | Create Product | POST /products |
Catalog |
| 16 | Rename Product | PUT /products/{id}/name |
Catalog |
| 17 | Change Product Description | PUT /products/{id}/description |
Catalog |
| 18 | Change Product Brand | PUT /products/{id}/brand |
Catalog |
| 19 | Assign Category to Product | PUT /products/{id}/category |
Catalog |
| 20 | Delete Product | DELETE /products/{id} |
Catalog |
| 21 | Add Product Variant | POST /products/{id}/productvariants |
Catalog |
| 22 | Remove Product Variant | DELETE /products/{id}/productvariants/{variantId} |
Catalog |
| 23 | Change Variant SKU | PUT /products/{id}/productvariants/{variantId}/sku |
Catalog |
| 24 | Change Variant Price | PUT /products/{id}/productvariants/{variantId}/price |
Catalog |
| 25 | Add Item to Cart | POST /shoppingcarts/{id}/shoppingcartitems |
Sales |
| 26 | Change Cart Item Quantity | PUT /shoppingcarts/{id}/shoppingcartitems/{variantId}/quantity |
Sales |
| 27 | Remove Cart Item | DELETE /shoppingcarts/{id}/shoppingcartitems/{variantId} |
Sales |
| 28 | Clear Cart | PUT /shoppingcarts/{id}/clear |
Sales |
| 29 | Checkout (Cart to Order) | PUT /shoppingcarts/{id}/checkout |
Sales |
| 30 | Create Stripe Checkout Session | POST /orders/{id}/checkout |
Sales |
| 31 | Stripe Payment Webhook | POST /payments/webhook |
Sales |
| 32 | Manual Admin Payment | PUT /orders/{id}/pay |
Sales |
| 33 | Deliver Order | PUT /orders/{id}/deliver |
Sales |
| 34 | Cancel Order | PUT /orders/{id}/cancel |
Sales |
| 35 | Increase Inventory | PUT /inventoryitems/{id}/increaseinventory |
Sales |
| 36 | Decrease Inventory | PUT /inventoryitems/{id}/decreaseinventory |
Sales |
| 37 | Set Inventory | PUT /inventoryitems/{id}/setinventory |
Sales |
| 38 | Bulk Set Inventory | PUT /inventoryitems/bulk-setinventory |
Sales |
| 39 | Verify Payment (webhook backstop) | PUT /orders/{id}/verify-payment |
Sales |
| 40 | Upload Product Image | POST /products/{productId}/images |
Catalog |
| 41 | Delete Product Image | DELETE /products/{productId}/images/{imageId} |
Catalog |
| 42 | Reorder Product Images | PUT /products/{productId}/images/order |
Catalog |
| 43 | Read Product Image | GET /products/{productId}/images/{imageId} |
Catalog |
| 44 | Change UI Preferences | PUT /auth/preferences |
Identity |
| 45 | Export Personal Data | GET /users/{userId}/export |
Identity |
| 46 | Erase User Account | DELETE /users/{userId} |
Identity |
| 47 | Ship Order | PUT /orders/{id}/ship |
Sales |
| 48 | Correct Shipment | PUT /orders/{id}/shipment |
Sales |
| 49 | Submit Product Review | POST /reviews/by-product/{productId} |
Catalog |
| 50 | Revise Product Review | PUT /reviews/{id} |
Catalog |
| 51 | Withdraw Product Review | DELETE /reviews/{id} |
Catalog |
| 52 | Hide Product Review | PUT /reviews/{id}/hide |
Catalog |
| 53 | Unhide Product Review | PUT /reviews/{id}/unhide |
Catalog |
| 54 | Set Variant Discount | PUT /products/{id}/productvariants/{variantId}/discount |
Catalog |
| 55 | Clear Variant Discount | DELETE /products/{id}/productvariants/{variantId}/discount |
Catalog |
| 56 | Discount All Variants | PUT /products/{id}/discount |
Catalog |
1. Identity Module Workflows
1.1 User Registration
Entry Point: POST /auth/register, AuthController.RegisterAsync(), AllowAnonymous
Execution Path:
AuthController.RegisterAsync()
-> AuthenticationService.RegisterAsync()
-> Repository: check email uniqueness
-> PasswordHasher.HashPassword() (PBKDF2-HMAC-SHA512, 600k iterations, per-user salt)
-> User.Create() factory method
-> Repository.AddAsync() + UnitOfWork.SaveChangesAsync()
-> [Domain Event] UserRegisteredHandler
-> Customer.Create() (creates linked customer profile)
-> User.LinkCustomer(customerId)
-> TokenService.GenerateTokens()
Business Steps:
- Validate registration input (email, password, first name, last name, optional address)
- Check email is not already registered
- Hash password with PBKDF2-HMAC-SHA512 (600,000 iterations, per-user salt)
- Create User aggregate (IsActive=true, Role="Customer")
- Persist user: triggers
UserRegistereddomain event - Domain event handler creates Customer profile and links it to User
- Generate access token (15 min, JWT with claims: sub, jti, iat, user_id, email, role, customer_id) + refresh token (7 days, 64-byte random)
- Return authentication response with tokens
Decision Points:
- Email already exists -> Conflict error
- Role="Admin" -> no Customer profile created; Role="Customer" -> Customer auto-created
State Changes:
- Creates
User(Active, with RefreshToken and RefreshTokenExpiry) - Creates
Customer(linked via User.CustomerId)
Domain Events: UserRegistered -> UserRegisteredHandler creates the linked Customer, which raises CustomerChanged with the Added state
1.2 User Login
Entry Point: POST /auth/login, AuthController.LoginAsync(), AllowAnonymous
Execution Path:
AuthController.LoginAsync()
-> AuthenticationService.LoginAsync()
-> Repository: find User by email
-> PasswordHasher.VerifyPassword() (constant-time comparison)
-> Check User.IsActive
-> TokenService.GenerateTokens()
-> Update User.RefreshToken + RefreshTokenExpiry
-> UnitOfWork.SaveChangesAsync()
Business Steps:
- Look up user by email
- Verify password against stored hash+salt
- Verify user is active
- Generate new access + refresh tokens
- Store refresh token on user entity
- Return tokens
Decision Points:
- Email not found -> Unauthorized
- Password mismatch -> Unauthorized
- User deactivated -> Unauthorized
State Changes: User.RefreshToken and User.RefreshTokenExpiry updated
1.3 Token Refresh
Entry Point: POST /auth/refresh, AllowAnonymous
Execution Path:
AuthController.RefreshAsync()
-> TokenService.GetPrincipalFromExpiredToken() (validates structure, allows expired)
-> Extract user_id claim -> fetch User
-> Validate RefreshToken matches + not expired + user active
-> Generate new tokens -> update User.RefreshToken
-> SaveChangesAsync()
Decision Points:
- Invalid/expired refresh token -> Unauthorized
- User deactivated -> Unauthorized
State Changes: Refresh token rotated on User entity
1.4 Token Revocation
Entry Point: POST /auth/revoke, Requires Authorization
Business Steps: Clears User.RefreshToken and User.RefreshTokenExpiry to null.
1.5 Change Password
Entry Point: PUT /auth/password, Requires Authorization
Execution Path:
AuthController.ChangePasswordAsync()
-> ChangePasswordHandler.Handle()
-> Verify current password
-> Hash new password
-> User.ChangePassword() -> publishes UserPasswordChanged
-> SaveChangesAsync()
Decision Points: Current password incorrect -> Bad Request
1.6 Customer Profile Management
| Workflow | Endpoint | Domain Method | Event |
|---|---|---|---|
| Change Name | PUT /customers/{id}/name |
Customer.ChangeName() |
CustomerChanged (Updated) |
| Change Address | PUT /customers/{id}/address |
Customer.ChangeAddress() |
CustomerChanged (Updated) |
| Change Email | PUT /customers/{id}/email |
Customer.ChangeEmail() |
CustomerChanged (Updated) |
Email change includes uniqueness check across customers.
1.7 Customer CRUD (Admin)
| Workflow | Endpoint | Auth | Notes |
|---|---|---|---|
| Create Customer | POST /customers |
Admin | Idempotent via [Idempotent] attribute |
| Delete Customer | DELETE /customers/{id} |
Admin | Soft delete (IsDeleted=true), raises CustomerChanged with the Deleted state |
| Get Customer | GET /customers/{id} |
Authenticated | Owner or admin |
| List Customers | GET /customers |
Admin | All customers |
| List Customers (paged) | GET /customers/paged |
Admin | Paged |
| Customer Lookup | GET /customers/lookup |
Admin | Id/name pairs for pickers |
The controller is [Authorize(RequireAuthenticated)] with the collection reads and the create/delete
actions individually raised to [Authorize(RequireAdmin)].
1.8 UI Preferences
| Workflow | Endpoint | Auth | Notes |
|---|---|---|---|
| Read preferences | GET /auth/preferences |
Authenticated | Stored UI culture/theme, read at login so the choice follows the account across devices |
| Change preferences | PUT /auth/preferences |
Authenticated | Persists culture (ADR-027) and theme (ADR-028) |
1.9 Data Subject Rights (GDPR/CCPA)
Both endpoints authorize the account owner or an Admin, enforced in the handlers rather than by a controller-wide policy (ADR-005).
| Workflow | Endpoint | Auth | Notes |
|---|---|---|---|
| Export personal data | GET /users/{userId}/export |
Owner or Admin | Portable JSON of the personal data held for the user (access/portability) |
| Erase account | DELETE /users/{userId} |
Owner or Admin | Deletes the account and irreversibly anonymizes its personal data; distinct from the soft-delete used for ordinary lifecycle |
2. Catalog Module Workflows
2.1 Category Management
Create Category
Entry Point: POST /categories, Admin only, [Idempotent]
CategoriesController.CreateAsync()
-> CreateCategoryHandler
-> CategoryCreateRequestValidator (name: required, max 255)
-> Category.Create(id, name, parentCategoryId?) -> raises CategoryChanged (Added)
-> Repository.AddAsync() + SaveChangesAsync()
Response: 201 Created with CategoryDTO
Rename Category
Entry Point: PUT /categories/{id}/name, Admin only
CategoriesController.RenameAsync()
-> RenameCategoryHandler
-> Validate name (max 255)
-> Fetch category -> category.Rename(name)
-> Only updates if name differs (case-insensitive comparison)
-> Raises CategoryChanged (Updated) if changed
-> Cache invalidation
Assign Parent Category
Entry Point: PUT /categories/{id}/parentcategory, Admin only
Sets or clears ParentCategoryId to establish hierarchy. No domain event raised.
Delete Category
Entry Point: DELETE /categories/{id}, Admin only
Soft delete (IsDeleted=true). Raises CategoryChanged with the Deleted state.
Query Endpoints
| Endpoint | Auth | Notes |
|---|---|---|
GET /categories |
AllowAnonymous | Returns all categories (capped at MaxPageSize) |
GET /categories/paged |
AllowAnonymous | Paginated with filters, sorting, field projection |
GET /categories/lookup |
AllowAnonymous | Returns ID + name pairs for dropdowns |
GET /categories/{id} |
AllowAnonymous | Single category with optional FK/children includes |
2.2 Product Management
Create Product
Entry Point: POST /products, Admin only, [Idempotent]
ProductsController.CreateAsync()
-> CreateProductHandler
-> Validate: name (required, max 100), description (max 4000), brand (max 100, no whitespace if provided)
-> Product.Create() factory -> raises ProductChanged (Added)
-> Repository.AddAsync() + SaveChangesAsync()
Update Operations
| Workflow | Endpoint | Validation | Event |
|---|---|---|---|
| Rename | PUT /products/{id}/name |
Max 100, required | ProductChanged (Updated) plus the ProductInfoChanged integration event (Updated) |
| Change Description | PUT /products/{id}/description |
Max 4000, nullable | None |
| Change Brand | PUT /products/{id}/brand |
Max 100, no whitespace | None |
| Assign Category | PUT /products/{id}/category |
Nullable FK | None |
Delete Product
Entry Point: DELETE /products/{id}, Admin only. Soft delete, raises ProductChanged with the Deleted state plus the ProductInfoChanged integration event (Deleted).
Query Endpoints
| Endpoint | Auth | Notes |
|---|---|---|
GET /products |
AllowAnonymous | Returns all products (capped at MaxPageSize) |
GET /products/paged |
AllowAnonymous | Paginated with filters, sorting, field projection |
GET /products/lookup |
AllowAnonymous | Returns ID + name pairs for dropdowns |
GET /products/variant-lookup |
AllowAnonymous | Returns ProductVariantCartInfoDTO rows: the variant detail the cart UI needs without a per-variant round trip |
GET /products/{id} |
AllowAnonymous | Single product with optional FK/children includes |
2.3 Product Variant Management
Add Product Variant
Entry Point: POST /products/{id}/productvariants, Admin only
ProductVariantsController.CreateAsync()
-> AddVariantHandler
-> Validate: SKU (max 50), Price (positive Money)
-> IProductVariantService.SkuExistsAsync() -> global SKU uniqueness check
-> Fetch Product with variants
-> product.AddProductVariant(variantId, sku, price)
-> ProductVariant.Create() validates price not negative
-> Raises no Catalog domain event
-> SaveChangesAsync()
-> IEventBus.PublishAsync(ProductVariantChanged, Added) after the commit
Cross-Module: SKU uniqueness checked via IProductVariantService
Change Variant SKU
Entry Point: PUT /products/{id}/productvariants/{variantId}/sku, Admin only
SKU uniqueness verified globally (excluding current variant). Raises the ProductVariantChanged integration event with the Updated state if changed.
Change Variant Price
Entry Point: PUT /products/{id}/productvariants/{variantId}/price, Admin only
Price must be positive. Raises the ProductVariantChanged integration event with the Updated state if changed. The price edited here is
the LIST price. A variant that carries a discount re-validates it against the new price and the
change is refused when the pair would be inconsistent (a special price no longer below the list
price, or a percentage leaving nothing), so the discount is cleared first.
Set Variant Discount
Entry Point: PUT /products/{id}/productvariants/{variantId}/discount, catalog:pricing:manage
ProductVariantsController.SetDiscountAsync()
-> SetProductVariantDiscountCommand (If-Match product ETag + VariantRowVersion in the body)
-> VariantDiscountRules validates kind, percentage, special price, window order, label
-> ProductVariantSetDiscountRequest.ToDiscount()
-> VariantDiscount.CreatePercentage() / CreateSpecialPrice() -> Result<VariantDiscount>
-> product.SetProductVariantDiscount(variantId, discount)
-> ProductVariant.SetDiscount() runs VariantDiscount.ApplyTo(Price) first
-> Publishes ProductVariantChanged (Updated) with the LIST price
-> SaveChangesAsync()
-> Evicts the catalog:products output-cache tag
Replaces any discount already on the variant. Returns 204; 400 on a broken invariant, 404 on an
unknown product or variant, 412 on a stale ETag, 428 when If-Match is missing
(ADR-035 two-token rule: the header carries the product's
ETag, the body carries the variant's row version).
Clear Variant Discount
Entry Point: DELETE /products/{id}/productvariants/{variantId}/discount, catalog:pricing:manage
Returns the variant to its list price, which was never rewritten, so there is nothing to restore.
Clearing a variant that carries no discount changes nothing, so clearing twice is safe. Publishes
ProductVariantChanged (Updated) and evicts the catalog:products tag. The body carries only the
variant's row version; the status codes match the set action.
Discount All Variants
Entry Point: PUT /products/{id}/discount, catalog:pricing:manage
ProductsController.SetDiscountAsync()
-> SetProductDiscountCommand (If-Match product ETag)
-> ProductSetDiscountRequest.ToDiscount() -> Result<VariantDiscount>
-> product.SetDiscountOnAllVariants(discount)
-> A product with NO active variant is refused first: Product.NoActiveVariants (400)
-> ApplyTo() is checked against EVERY active variant's list price FIRST
-> All-or-nothing: one refusal leaves the product exactly as it was
-> Publishes one ProductVariantChanged (Updated) per variant, each with its LIST price
-> SaveChangesAsync()
-> Evicts the catalog:products output-cache tag
A product whose variants are priced differently either takes the promotion whole or is left untouched, so a half-discounted catalog page is not reachable. A product with nothing to discount, because it has no variants yet or every one is soft-deleted, is a 400 rather than a write that succeeds over an empty set.
Remove Product Variant
Entry Point: DELETE /products/{id}/productvariants/{variantId}, Admin only
Soft delete on variant. Raises the ProductVariantChanged integration event with the Deleted state.
2.4 Product Image Management
Behind the CatalogFeatures.ProductImages feature gate (ADR-031).
Image binaries live as rows in the Catalog database (ProductImageData), not in managed blob storage:
ADR-045 is the MMCA.ADC avatar amendment and is not
adopted here, which is why the output cache below exists. Mutations require Admin;
retrieval is anonymous and output-cached for 300 seconds to keep repeated BLOB reads off the database.
| Workflow | Endpoint | Auth | Notes |
|---|---|---|---|
| Read primary image | GET /products/{id}/image |
Anonymous | Lowest display order; 300s response + output cache |
| Read image by id | GET /products/{productId}/images/{imageId} |
Anonymous | Same caching |
| Upload image | POST /products/{productId}/images |
Admin | 201 Created with ProductImageDTO and a Location header; 6 MB request limit covering the 5 MB domain constraint plus multipart overhead |
| Upload image (legacy) | PUT /products/{id}/image |
Admin | The pre-collection single-image route, kept for backward compatibility; same handler as the POST above |
| Delete primary image | DELETE /products/{id}/image |
Admin | Legacy single-image route |
| Delete image by id | DELETE /products/{productId}/images/{imageId} |
Admin | Collection route |
| Reorder images | PUT /products/{productId}/images/order |
Admin | Reassigns display order from the supplied id list; the first id becomes the primary image (order 0) |
Every mutation evicts the products cache so the next read reflects the change.
2.5 Product Reviews
Earn the Entitlement (OrderFulfilled -> VerifiedPurchase)
Entry Point: the Sales.OrderFulfilled.v1 integration event, consumed by
OrderFulfilledHandler in its own DI scope. No HTTP surface: nothing else in the system creates a
verified purchase.
Sales: order.MarkAsDelivered()
-> raises OrderDelivered (in-process) AND OrderFulfilled (integration, line snapshot)
-> stamps Order.FulfilledPublishedOn in the same unit of work
-> outbox row commits with the order write, then ships to the broker
Catalog: OrderFulfilledHandler
-> resolve the event's variant ids to product ids in ONE projection
(soft-deleted variants are not resolved: a delisted product stays unreviewable)
-> de-duplicate: two sizes of the same shirt are one entitlement
-> read the order's existing entitlement rows (first idempotency guard)
-> VerifiedPurchase.Create() for each missing product, one SaveChangesAsync
Idempotency: twice over. The existing-rows read means a redelivered event inserts nothing, and the
unique (OrderId, ProductId) index is the backstop for two deliveries racing. Deliveries are
at-least-once, so both halves are load-bearing. A variant that does not resolve is logged and skipped
rather than thrown, because a redelivery would fail on it identically forever and take the customer's
other entitlements down with it.
Backfill: OrderFulfilledBackfillService publishes the event on startup for orders delivered
before it existed, selecting only Delivered orders whose FulfilledPublishedOn is still null. Bounded
per run and settings-gated (OrderFulfilledBackfill:Enabled).
Submit Review
Entry Point: POST /reviews/by-product/{productId}, authenticated customer, [Idempotent]
ReviewsController.SubmitAsync()
-> resolve the caller: customer_id claim (fail closed with 403 when absent, admins not exempt)
-> reviewer display name read from the token's name claim, never from the body
-> SubmitReviewHandler
-> VerifiedPurchase for (customer, product)? no -> Review.NotVerifiedPurchase
-> existing review by this customer for this product? yes -> Review.AlreadyReviewed
-> entitling order = lowest matching OrderId (the earliest entitlement)
-> ProductReview.Create() -> validates rating 1..5, title <= 120, body <= 2000
-> raises ProductReviewChanged (Added), status Published
-> AddAsync + SaveChangesAsync
-> evict the catalog:products output-cache tag
(then, after commit)
-> ProductReviewChangedHandler [own DI scope]
-> recompute AVG/COUNT over the product's published, non-deleted reviews
-> Product.UpdateRatingSummary(), written only when the summary actually differs
Response: 200 OK with ProductReviewDTO
Revise / Withdraw Review
Entry Point: PUT /reviews/{id} and DELETE /reviews/{id}, owner or Admin
Ownership is checked in the controller and a non-owner is answered 404, not 403, so the existence
of another customer's review id cannot be probed for. The revise path is conditional
(ADR-035): the If-Match header is mandatory and an edit
decided against a stale view answers 412. The delete is unconditional (soft delete; a delete has no
field to lose to a concurrent edit). Both raise ProductReviewChanged, so the rating summary is
recomputed either way. The frozen ReviewerName is deliberately not revisable.
Hide / Unhide Review (Moderation)
Entry Point: PUT /reviews/{id}/hide, PUT /reviews/{id}/unhide, catalog:reviews:moderate
Both are idempotent at the aggregate (a review already in the target status succeeds without raising a
second event) and conditional on the same If-Match terms, so two moderators working from the same
stale list cannot silently overwrite each other. A hidden review leaves the public list and drops out
of the product's rating summary; the moderation list still shows it.
Erasure
CustomerErasedHandler consumes Identity's CustomerErased.v1 and anonymizes every review that
customer wrote, hidden and soft-deleted rows included: name, title and body cleared, star rating kept
(ADR-005). Only rows that still carry personal data are
loaded, so a redelivery finds nothing to do and churns no audit fields.
Query Endpoints
| Endpoint | Auth | Notes |
|---|---|---|
GET /reviews/by-product/{productId}/paged |
Anonymous | Published reviews only, whoever asks, newest first; output-cached under the shared ProductsCache policy, which caches regardless of auth state (a role-varying response would be served to the wrong caller) |
GET /reviews/by-product/{productId}/mine |
Authenticated customer | HasPurchased and CanReview as separate booleans, plus the caller's own review; the pair is what lets the UI tell "never bought it" apart from "already had their say" |
GET /reviews/paged |
catalog:reviews:moderate |
Every review whatever its status, filterable and sortable; never cached |
Page sizes are capped server-side at 100. Every mutation evicts the catalog:products output-cache
tag as well as invalidating the application cache, because a review moves the stars the product reads
carry.
3. Sales Module Workflows
3.1 Shopping Cart
Add Item to Cart
Entry Point: POST /shoppingcarts/{customerId}/shoppingcartitems, Authenticated (owner or admin via OwnerOrAdminFilter)
ShoppingCartsController.CreateShoppingCartItemAsync()
-> AddItemHandler
-> Validate quantity > 0
-> IProductVariantService.ExistsAsync() [cross-module: Catalog]
-> Fetch ShoppingCart by CustomerId
-> If no cart exists -> ShoppingCart.Create(customerId) -> ShoppingCartChanged (Added)
-> If cart is CheckedOut -> shoppingCart.Reactivate() (clears items, resets to Active)
-> shoppingCart.AddShoppingCartItem(variantId, quantity)
-> If item already in cart -> IncreaseQuantity -> ShoppingCartItemChanged (Updated)
-> If new item -> ShoppingCartItem.Create() -> ShoppingCartItemChanged (Added)
-> SaveChangesAsync()
Decision Points:
- Product variant doesn't exist -> NotFound
- Quantity not positive -> Invariant error
- Cart checked out -> auto-reactivated (all old items cleared)
Change Item Quantity
Entry Point: PUT /shoppingcarts/{id}/shoppingcartitems/{variantId}/quantity
Validates cart is Active, item exists, quantity > 0. Raises ShoppingCartItemChanged with the Updated state, carrying the old and new quantity.
Remove Item
Entry Point: DELETE /shoppingcarts/{id}/shoppingcartitems/{variantId}
Validates cart is Active. Soft-deletes item. Raises ShoppingCartItemChanged with the Deleted state.
Clear Cart
Entry Point: PUT /shoppingcarts/{id}/clear
Deletes all items. Raises ShoppingCartChanged with the Updated state.
Cart Query Endpoints
| Endpoint | Auth | Notes |
|---|---|---|
GET /shoppingcarts/{id} |
Authenticated + OwnerOrAdmin | The customer's cart with optional children (items). Returns an empty DTO (not 404) when no cart exists yet (lazy cart pattern) |
GET /shoppingcarts |
Authenticated | Row-scoped by the ownership specification: a customer sees only their own cart, an admin sees every cart (ADR-033) |
GET /shoppingcarts/paged |
Authenticated | Paged, same row-scoping |
GET /shoppingcarts/lookup |
Admin | Id/name pairs for pickers |
Ownership on the collection reads is enforced in the query, not against a route value: there is no owner parameter to compare, so the specification narrows the rows before they are read.
Shopping Cart Status State Machine
Active --(MarkAsCheckedOut)--> CheckedOut
^ |
+----(Reactivate on AddItem)-----+
(clears all items)
3.2 Checkout (Cart to Order): Primary Business Workflow
Entry Point: PUT /shoppingcarts/{customerId}/checkout, authenticated, marked ICacheInvalidating.
CheckOutCommand is deliberately NOT ITransactional (CheckOutCommand.cs:9): the handler opens its
own transaction around the write phase only, so the cross-module price fetch never holds database locks.
ShoppingCartsController.CheckOutAsync()
-> CheckOutHandler
-> Fetch ShoppingCart with items (tracking)
-> Collect productVariantIds from cart items
-> IProductVariantService.GetUnitPricesAsync(ids) [cross-module: Catalog, OUTSIDE the transaction]
each entry is effective price + list price + promotion label (VariantUnitPrice)
-> Translate each entry into Sales' own OrderLinePricing at the module boundary
-> Reject any variant missing from the price map (soft-deleted between add and checkout)
-> Fetch InventoryItems for all variants
-> CheckOutDomainService.Execute():
1. Validate cart not empty
2. Validate inventory exists for all items
3. Fail-fast sufficiency check per item against the point-in-time snapshot
(NOT the oversell guard: that is the atomic decrement below)
4. Build order items (variant + pricing triple + quantity)
5. Order.Create(customerId, items) -> raises OrderChanged (Added)
6. shoppingCart.MarkAsCheckedOut() -> publishes ShoppingCartCheckedOut
-> orderRepository.AddAsync(order)
-> unitOfWork.ExecuteInTransactionAsync: <-- the whole write phase, one transaction
-> IInventoryAllocationService.DecrementAsync(decrements)
atomic conditional UPDATE per variant (ExecuteUpdateAsync); a row that no longer has
enough stock matches zero rows and fails the Result, rolling the transaction back
-> unitOfWork.SaveChangesAsync() <-- order insert + cart transition
-> Return OrderDTO
Business Steps:
- Validate cart exists and has items
- Fetch current pricing from Catalog module, and reject variants that no longer exist
- Fail-fast inventory sufficiency check against the loaded snapshot
- Create Order aggregate with OrderLines. Each line freezes the effective price it is charged, the
list price that price was struck from and the active promotion's label, so the order can say what
it saved without asking Catalog again. Only the effective price is money owed: the line total, the
order total and the Stripe amount follow from it alone.
OrderLine.SavingsandOrder.TotalSavingsare computed from the frozen pair on read and never stored (ADR-112) - Transition cart to CheckedOut status
- Commit the write phase in one explicit transaction: atomic inventory decrements, then the order insert and cart transition
Decision Points:
- Cart empty -> Validation error
- Product variant no longer exists in Catalog ->
ProductVariant.NotFound, naming the offending variant - Insufficient inventory at snapshot time -> Invariant error (fail-fast)
- Insufficient inventory at commit time -> the conditional UPDATE matches no row, the transaction rolls back
- Inventory record missing -> NotFound (specific missing variant ID reported)
State Changes:
- Creates
Order(status: PendingPayment) withOrderLinechildren - Decrements
InventoryItem.AvailableQuantityfor each item ShoppingCart.Status-> CheckedOut
Domain Events: OrderChanged (Added), ShoppingCartCheckedOut.
No
InventoryAdjustedon the checkout path. The decrement runs asExecuteUpdateAsync, which bypasses the save pipeline (and therefore the audit interceptor and domain-event dispatch) by design: that is what makes it a single atomic statement and the real oversell guard.InventoryAdjustedis still raised by the admin adjustment endpoints in 3.4, which go through the aggregate.
3.3 Order Lifecycle
Order Status State Machine
+------------------------------------------+
| |
v |
PendingPayment --> PaymentInitiated --> Paid --> Shipped --> Delivered
| | ^ | ^
| | | +-------------------+
| v (MarkAsPaidManually) (no carrier leg)
| PaymentFailed --------+
| |
| v (retry)
| PaymentInitiated
| |
v v
Cancelled <------- Cancelled
(from PendingPayment, PaymentInitiated, or PaymentFailed)
Terminal States: Cancelled, Delivered
Shipped is an optional stop, not a mandatory one: a fulfilment with no tracking to record still goes
Paid -> Delivered directly, which is why Paid keeps both outgoing edges. Cancellation is refused from
Shipped exactly as it is from Paid. UpdateShipment is a self-transition on Shipped. The enum's
numeric values are persisted and travel on the user_sales_export gRPC contract, so Shipped is
appended LAST in OrderStatus even though it sits mid-lifecycle.
Create Stripe Checkout Session
Entry Point: POST /orders/{id}/checkout, Authenticated (owner or admin)
OrdersController.CreateCheckoutSessionAsync()
-> CreateCheckoutSessionHandler
-> Fetch Order with OrderLines
-> IPaymentService.CreateCheckoutSessionAsync()
-> StripePaymentService creates Stripe Checkout Session
-> Converts Money to smallest currency unit (amount * 100)
-> Sets success/cancel URIs, stores orderId in metadata
-> Returns sessionId + checkoutUri
-> order.InitiatePayment(sessionId)
-> Validates status is PendingPayment or PaymentFailed
-> Validates order not empty
-> Sets StripeSessionId, status -> PaymentInitiated
-> Publishes OrderPaymentInitiated
-> SaveChangesAsync()
-> Return sessionId + checkoutUri
External Interaction: Stripe API: creates payment session with order line items
Stripe Payment Webhook
Entry Point: POST /payments/webhook, AllowAnonymous, ITransactional, ICacheInvalidating
PaymentsController.HandleWebhookAsync()
-> ProcessPaymentWebhookHandler
-> IPaymentService.ParseWebhookEvent() (Stripe signature verification)
-> Route by event type:
checkout.session.completed:
-> Find Order by StripeSessionId
-> Idempotent: if already Paid, return success
-> order.MarkAsPaid(paymentIntentId)
-> Status -> Paid, publishes OrderPaid
checkout.session.expired / payment_intent.payment_failed:
-> Find Order by StripeSessionId
-> Idempotent: if already PaymentFailed or Cancelled, return success
-> order.MarkAsPaymentFailed()
-> Status -> PaymentFailed, publishes OrderPaymentFailed
Other events: silently ignored (returns success)
Idempotency: Already-paid or already-failed orders return success without modification.
Controller behavior: Returns 400 only for signature verification failures; returns 200 for all other cases (including handler errors) to prevent Stripe retries.
Receipt email: OrderPaidHandler turns OrderPaid into the payment confirmation. The event
carries the order's total savings and each line's frozen list price and promotion label, so a
discounted line prints a struck "Was" amount above the price charged with the label (HTML-encoded)
beneath it, and the order gets a "You saved" total. Each of those appears only when there is a saving
to show.
Manual Admin Payment
Entry Point: PUT /orders/{id}/pay, Admin only
Validates status allows manual payment (PendingPayment, PaymentInitiated, or PaymentFailed). Sets StripePaymentIntentId = "manual-admin-override", status -> Paid. Publishes OrderPaid.
Verify Payment (webhook backstop)
Entry Point: PUT /orders/{id}/verify-payment, owner or Admin (ownership validated in the controller)
Polls Stripe directly for the order's checkout session instead of waiting on a webhook, for the case
where webhook delivery failed or is delayed. If Stripe reports the session paid, the order is marked
Paid exactly as the webhook would have marked it; if the order is already Paid or is not in a
verifiable state, the call succeeds without changes. This is the reconciliation half of
ADR-054: the webhook is the fast path, this is
the backstop that keeps a paid customer from sitting in PaymentInitiated forever.
Ship Order
Entry Point: PUT /orders/{id}/ship, sales:orders:manage, mandatory If-Match
OrdersController.ShipAsync()
-> ShipOrderHandler
-> Shipment.Create(carrier, trackingNumber, shippedOn, estimatedDeliveryOn?, trackingUrl?)
-> tracking number required, max 100; shippedOn normalized to UTC
-> estimated delivery cannot precede the ship date (same day allowed)
-> a caller-supplied https tracking URL is accepted ONLY for carrier Other
-> order.Ship(shipment)
-> validates status is Paid (Shipped/Delivered/Cancelled refuse)
-> sets Shipment, status -> Shipped, raises OrderShipped
-> SaveChangesAsync()
(then, after commit)
-> OrderShippedHandler [own DI scope]
-> ICustomerService.GetContactInfoByIdAsync() for the address
-> IEmailSender: carrier, tracking number, tracking link, dates
-> send failures are logged, never fatal: the transition is already committed
The tracking link is computed from the carrier's own template plus the tracking number for UPS,
FedEx, USPS and DHL, so the URL is written down in exactly one place and the client renders whatever
arrives rather than rebuilding a carrier URL of its own. Only Carrier.Other carries a stored URL.
Response: 204 No Content. 412 when the If-Match token is stale, 428 when the header is absent.
Correct Shipment
Entry Point: PUT /orders/{id}/shipment, sales:orders:manage, mandatory If-Match
Same request body as ship, because the shipment is a value object and a correction replaces it whole
rather than patching a field. Allowed from Shipped only; the status does not move, and OrderShipped
is raised again on purpose so a customer who was told the wrong tracking number gets the right one
(the notification is a re-send, not a duplicate). Kept as a separate endpoint from ship deliberately:
ship must refuse an already-shipped order and this one must refuse an order that never shipped, so
folding them together would make "silently re-ship" indistinguishable from "fix the tracking number".
Deliver Order
Entry Point: PUT /orders/{id}/deliver, sales:orders:manage, mandatory If-Match
Validates status is Paid or Shipped. Status -> Delivered, FulfilledPublishedOn stamped in the
same unit of work. Raises two events: the in-process OrderDelivered, and the OrderFulfilled
integration event carrying the delivered line snapshot, which Catalog turns into the verified-purchase
entitlements behind product reviews (Section 2.5). The snapshot travels on the event because the
consumer lives in another service and cannot query back (ADR-006),
which is also why callers load OrderLines with the aggregate.
The admin order grid searches tracking numbers: the filter name ShipmentTrackingNumber maps to the
scalar column the owned shipment flattens onto, so it is one server-side CONTAINS rather than a client
scan.
Cancel Order
Entry Point: PUT /orders/{id}/cancel, Owner or Admin
OrdersController.CancelAsync()
-> CancelOrderHandler
-> Fetch Order with OrderLines
-> order.MarkAsCancelled()
-> Validates status is PendingPayment, PaymentInitiated, or PaymentFailed
-> Status -> Cancelled, publishes OrderCancelled
-> SaveChangesAsync() <-- cancellation commits here; no inventory work inline
(then, after commit)
-> OrderCancelledSagaHandler [ADR-054 compensating action, own DI scope]
-> Skip if order.InventoryRestored is already set (idempotent under redelivery)
-> Restore stock for each OrderLine
-> order.MarkInventoryRestored()
-> ONE SaveChangesAsync commits the marker and the inventory increases together
Inventory Impact: All reserved inventory is restored on cancellation, by a compensating handler
that runs after the cancellation transaction, not inside it (ADR-054).
Order.InventoryRestored is the idempotency marker, committed by the same SaveChangesAsync as the
restoration, so an at-least-once redelivery cannot double-restore; the order's rowversion token makes
two concurrent deliveries mutually exclusive. The sibling OrderPaymentFailedSagaHandler compensates
the payment-failure path the same way. Splitting compensation out of CancelOrderHandler is what lets
further compensating actions (refund, notification) be added as new handlers rather than edits.
Order Query Endpoints
| Endpoint | Auth | Notes |
|---|---|---|
GET /orders |
Authenticated | Customers see only own orders (OrdersByCustomerSpecification); admins see all |
GET /orders/paged |
Authenticated | Paginated with same ownership filter |
GET /orders/{id} |
Authenticated | Single order with optional includes |
GET /orders/lookup |
Authenticated | Id/name pairs for pickers, same ownership filter |
3.4 Inventory Management
| Workflow | Endpoint | Auth | Behavior |
|---|---|---|---|
| Increase | PUT /inventoryitems/{id}/increaseinventory |
Admin | Adds quantity, validates > 0 |
| Decrease | PUT /inventoryitems/{id}/decreaseinventory |
Admin | Subtracts, validates sufficient stock |
| Set | PUT /inventoryitems/{id}/setinventory |
Admin | Absolute set (creates if not exists) |
| Bulk set | PUT /inventoryitems/bulk-setinventory |
Admin | Sets inventory for many variants in one transaction, creating the ones that do not exist; returns a BulkSetInventoryResultDTO |
| List | GET /inventoryitems |
Admin | All inventory items |
| List (paged) | GET /inventoryitems/paged |
Admin | Paged inventory items |
| Get By ID | GET /inventoryitems/{id} |
Admin | Single inventory item |
All mutation operations validate that the product variant exists via IProductVariantService and
publish InventoryAdjusted when the quantity actually changes. IncreaseInventory and
DecreaseInventory both route through SetInventory, which is the single place the event is raised.
The whole controller is [Authorize(Policy = RequireAdmin)]; there is no anonymous inventory read.
4. UI Workflows
4.1 Customer Shopping Experience (UI-Side)
The UI provides a complete shopping experience through the CartDrawer component and Blazor pages.
Cart Drawer (Sole Cart Interface)
The CartDrawer is the only cart UI: there is no dedicated cart page. It is a 380px right-side temporary drawer accessible from any page via the cart icon in the top app bar.
Features:
- View cart items with product name, SKU, and quantity
- Quantity +/- controls per item (when cart is Active)
- Remove individual items (when cart is Active)
- Clear all items
- Checkout & Pay (creates order + Stripe session, redirects to payment)
- "Checked Out" chip display when cart is not Active
- Read-only quantity display when cart is CheckedOut
- "Continue Shopping" navigation to catalog
- Empty cart state with "Browse Products" link
State Management: ICartStateService singleton manages cart state centrally: all cart operations go through this service which refreshes the cart DTO after each mutation and notifies subscribers via OnChange event. Cart items are enriched with product names and SKUs from the Catalog API.
Catalog Browse & Product Detail
/catalog, Product grid with name search, category filter, name/newest sort, quick "Add to Cart" per variant, and a star rating with review count on each card (read off the product's denormalizedRatingSummary, so a grid costs no per-card aggregate query)/catalog/{id}, Product detail with breadcrumbs, variant list, quantity selector, "Add to Cart" button, "Buy Now" (direct Stripe checkout), and a reviews section (anchor#reviews) listing published reviews with the review editor for an eligible signed-in customer- A discounted variant reads as a sale on both pages: the card shows a sale badge and an effective-price range, the variant card shows the effective price beside the struck-through list price, and the pair carries a "Was X, now Y" accessible label. The numbers are server-resolved, so a browser clock never decides whether a promotion is running
Order Management
/orders, MudDataGrid listing orders with ID, customer, total, status chips (Shipped included), item count; the admin search box also matches tracking numbers/orders/{id}, Two-column layout: order summary (status, total, payment/shipping/delivery actions, and the carrier, tracking number and tracking link once the order ships) + order lines. The admin ship and correct-tracking actions open oneShipOrderDialog, which serves both endpoints- A discounted order line reads the same way the storefront does: the struck list price beside the price charged, a chip carrying the promotion label, and a "Was X, now Y" sentence for assistive tech since the two bare numbers are hidden from it. The order adds a "you saved" row when its total savings are above zero. Every number is the one frozen at checkout, so an order page never re-prices against a promotion that has since ended
4.2 Admin UI Workflows
| Page | Route | Purpose |
|---|---|---|
| Categories | /categories |
MudDataGrid: CRUD, search, pagination |
| Category Create | /categories/create |
Form: name, parent category select |
| Category Detail | /categories/{id} |
View/edit mode, parent link, product list |
| Products | /products |
MudDataGrid: name, brand, category, variants |
| Product Create | /products/create |
Form: name, description, brand, category |
| Product Detail | /products/{id} |
View/edit product, inline variant editor (add/edit/delete); the variants table adds a Discount column showing the badge and effective price against the struck-through list price, per-row Discount and Clear discount actions opening an inline dialog, and a "Discount all variants" button that applies one discount across the whole product |
| Reviews | /reviews |
MudDataGrid: every review whatever its status, search, hide/unhide moderation |
| Inventory | /inventory |
MudDataGrid: product name, SKU, quantity, in-stock |
| Inventory Create | /inventory/create |
Initialize new inventory item |
| Inventory Detail | /inventory/{id} |
Edit inventory quantity |
| Shopping Carts | /shoppingcarts |
MudDataGrid: customer, items count, status |
| Cart Detail | /shoppingcarts/{id} |
Admin cart view with add/remove/clear/checkout |
| Customers | /customers |
MudDataGrid: name, email, delete |
| Customer Create | /customers/create |
Customer creation form |
| Customer Detail | /customers/{id} |
Customer view/edit |
4.3 User Profile & Auth
| Page | Route | Purpose |
|---|---|---|
| Login | /login |
Email/password form, link to register |
| Register | /register |
Name, email, password, optional address |
| Profile | /profile |
Edit name, address, change password |
4.4 Navigation Structure
Sidebar Navigation (role-based, dynamically populated from IUIModule registrations):
Customer:
- Home (
/) - Shop (
/catalog) - My Orders (
/orders) - My Profile (
/profile)
Admin:
- Home (
/) - Categories (
/categories) - Products (
/products) - Reviews (
/reviews) - Inventory (
/inventory) - Shopping Carts (
/shoppingcarts) - Orders (
/orders) - Customers (
/customers) - My Profile (
/profile)
Top App Bar:
- Shopping cart icon with badge (authenticated, opens CartDrawer)
- User email display
- Logout button (authenticated)
- Login / Register buttons (anonymous)
5. Cross-Module Interactions
| From -> To | Interface | Methods Used | Context |
|---|---|---|---|
| Sales -> Catalog | IProductVariantService |
ExistsAsync() |
Cart item validation, inventory creation |
| Sales -> Catalog | IProductVariantService |
GetUnitPricesAsync() |
Checkout pricing: effective price, list price and promotion label per variant |
| Sales -> Catalog | IProductVariantService |
SkuExistsAsync() |
SKU uniqueness (Catalog internal) |
| Sales -> Catalog | IProductVariantService |
GetIdBySkuAsync() |
Seed data inventory setup |
| Sales -> Identity | ICustomerService |
GetContactInfoByIdAsync() |
Contact details for the order paid / shipped / payment-failed emails |
| Identity -> Sales | IUserSalesExportService |
GetUserSalesExportAsync() |
Orders section of the data-subject export |
| Identity -> Catalog | IUserCatalogExportService |
GetUserCatalogExportAsync() |
Product-reviews section of the same export |
| Identity (event) | Domain Event | UserRegisteredHandler |
Auto-creates Customer on registration |
Both export edges are best-effort: an unreachable peer degrades that one section rather than failing the export, so neither carries a startup wait.
Asynchronously, four integration events cross the boundary through the outbox and the broker. Each is the consumer's trigger to refresh a denormalized copy IT owns, never a prompt to query back into the publisher (ADR-006):
| Event | Publisher -> Consumer | What the consumer does |
|---|---|---|
Catalog.ProductVariantChanged.v1 |
Catalog -> Sales | Creates the zero-stock inventory record; refreshes the denormalized SKU / product sort labels |
Catalog.ProductInfoChanged.v1 |
Catalog -> Sales | Fans a product rename or delete out to those same labels |
Sales.OrderFulfilled.v1 |
Sales -> Catalog | Writes one VerifiedPurchase per delivered product (Section 2.5) |
Identity.CustomerErased.v1 |
Identity -> Sales and Catalog | Sales clears the frozen order customer name; Catalog anonymizes the customer's reviews, keeping the ratings |
Module dependency: Sales declares a hard dependency on Catalog (RequiresDependencies = true). When Catalog is disabled, a DisabledProductVariantService stub is registered and Sales will fail to start.
6. External Interactions
| System | Purpose | Integration Point |
|---|---|---|
| Stripe | Payment processing | StripePaymentService, creates checkout sessions, handles webhooks |
| SQL Server | Primary persistence | Via EF Core + Aspire container orchestration |
| SQLite / Cosmos DB | Alternative persistence | Configurable via IDbContextFactory strategy |
| SMTP | Transactional email | SmtpEmailSender, driven by the password-reset handler and the Sales handlers OrderPaidHandler, OrderShippedHandler and OrderPaymentFailedSagaHandler |
| Message broker | Cross-service integration events | MassTransit over RabbitMQ locally and Azure Service Bus in production, fed by the per-service outbox |
7. Cross-Cutting Concerns Participating in Workflows
| Concern | Implementation | Impact |
|---|---|---|
| Transactions | ITransactional marker -> TransactionalCommandDecorator wraps in DB transaction |
Checkout, webhook processing |
| Cache Invalidation | ICacheInvalidating marker -> CachingCommandDecorator clears cache by prefix |
All mutation commands |
| Idempotency | [Idempotent] attribute -> IdempotencyFilter caches response by Idempotency-Key header for 24h with per-key locking |
All Create (POST) endpoints |
| Authorization | OwnerOrAdminFilter on cart/order endpoints |
Customers access only own data |
| Rate Limiting | FixedWindow: 100 req/min, queue 2, oldest-first | All API endpoints |
| Exception Handling | Middleware chain: Domain -> Validation -> DbUpdate -> OperationCanceled -> Global | Maps errors to HTTP status codes |
| Profiling | ProfilingCommandDecorator / ProfilingQueryDecorator via MiniProfiler |
All handlers when UseMiniProfiler=true |
| API Versioning | Header-based via api-version header, all controllers declare [ApiVersion("1.0")] |
All endpoints |
8. End-to-End Customer Journey
1. Register POST /auth/register -> User + Customer created, tokens issued
2. Browse Catalog GET /products -> View products with variants and prices
3. Add to Cart POST /shoppingcarts/{id}/shoppingcartitems -> Cart created/updated
4. Adjust Cart PUT/DELETE cart items -> Modify quantities or remove items
5. Checkout PUT /shoppingcarts/{id}/checkout -> Order created, inventory reserved
6. Pay POST /orders/{id}/checkout -> Stripe session created, redirect to payment
7. Payment Complete POST /payments/webhook -> Stripe confirms, order marked Paid
8. Shipment PUT /orders/{id}/ship -> Admin records carrier + tracking, customer emailed
9. Delivery PUT /orders/{id}/deliver -> Admin marks as Delivered, OrderFulfilled published
10. Entitlement (broker) -> Catalog writes one VerifiedPurchase per product
11. Review POST /reviews/by-product/{id} -> Customer rates the product they received
Alternative Flows:
- Payment fails -> Order status
PaymentFailed-> customer can retry (create new Stripe session) - Cancel order -> Status
Cancelled(from PendingPayment, PaymentInitiated, or PaymentFailed) -> inventory restored - Cart reactivation -> Checked-out cart auto-reactivated on next add-item (all old items cleared)
- Manual payment -> Admin bypasses Stripe, sets
StripePaymentIntentId = "manual-admin-override"
9. Potentially Missing or Incomplete Workflows
| Observation | Evidence | Recommendation |
|---|---|---|
| No refund workflow | Order can be cancelled only before payment completes (PendingPayment/PaymentInitiated/PaymentFailed); no refund logic for Paid orders | Verify if refunds are handled externally via Stripe dashboard or if a refund workflow is planned |
| No order editing | Once checkout completes, order lines cannot be modified | Confirm if this is intentional or if order amendment is planned |
| No order-confirmation or delivery email | Email handlers exist for payment, shipment and payment failure, but OrderChanged (Added) and OrderDelivered have no email handler, and OrderCancelled has only the inventory-restoring saga |
Both would be an extra IDomainEventHandler<T> on an event that already exists, not new plumbing |
| No full-text search | Catalog browse offers a name-contains search box (E2E-covered); there is no full-text/fuzzy search | Consider full-text search for larger catalogs |
| Inventory not checked during cart add | Inventory validation only happens at checkout, not when adding to cart | Could lead to poor UX if items go out of stock between add and checkout |
| No post-payment cancellation | Cancellation allowed from PendingPayment, PaymentInitiated, or PaymentFailed; cannot cancel after payment succeeds | Verify if post-payment cancellation with Stripe refund is needed |
| No customer deactivation endpoint | User.Deactivate() method and UserDeactivated event exist in domain but no API endpoint exposes this. (Account erasure is exposed, see 1.9; deactivation is the separate reversible state.) |
May be an admin feature not yet implemented |
| Category deletion has no cascade check | Deleting a category doesn't check for assigned products | Products with deleted category may have orphaned CategoryId |
| No partial fulfillment | Orders ship and are delivered as a whole: Shipped applies to the order, not to individual lines, and an order carries one Shipment value object |
A second parcel means turning the shipment into a collection; clarify whether split shipments are a real business case first |
| No refund compensation handler | ADR-054 makes refunds a natural third saga handler alongside OrderCancelledSagaHandler and OrderPaymentFailedSagaHandler, but none exists |
Add when post-payment cancellation lands |
This document is derived from source code analysis. All workflows, decisions, and behaviors described above are confirmed implementations traceable to the referenced source files. Last updated: 2026-09-05 (order shipment tracking and product reviews).