Chapter 11: Service Layer

Clean Architecture Implementation for Multi-Tenant POS

This chapter provides the complete service layer architecture, including interfaces, implementations, unit of work patterns, and transaction handling.


11.1 Clean Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                        API Controllers                          │
│  ItemsController, SalesController, InventoryController, etc.   │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Application Services                        │
│  IOrderService, IInventoryService, ICustomerService, etc.       │
│  (Business logic, orchestration, validation)                    │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                        Domain Layer                              │
│  Entities, Value Objects, Domain Events, Business Rules         │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Infrastructure Layer                        │
│  Repositories, DbContext, External Services, Messaging          │
└─────────────────────────────────────────────────────────────────┘

11.2 Project Structure

src/
├── POS.Api/                      # ASP.NET Core Web API
│   ├── Controllers/
│   ├── Middleware/
│   └── Program.cs
│
├── POS.Application/              # Application Services
│   ├── Interfaces/
│   │   ├── IOrderService.cs
│   │   ├── IInventoryService.cs
│   │   ├── ICustomerService.cs
│   │   ├── IItemService.cs
│   │   └── IReportService.cs
│   ├── Services/
│   │   ├── OrderService.cs
│   │   ├── InventoryService.cs
│   │   └── ...
│   ├── DTOs/
│   └── Validators/
│
├── POS.Domain/                   # Domain Layer
│   ├── Entities/
│   ├── ValueObjects/
│   ├── Events/
│   └── Exceptions/
│
└── POS.Infrastructure/           # Infrastructure Layer
    ├── Persistence/
    │   ├── PosDbContext.cs
    │   ├── Repositories/
    │   └── Configurations/
    ├── External/
    └── Messaging/

11.3 Service Interfaces

11.3.1 IOrderService

// File: src/POS.Application/Interfaces/IOrderService.cs
using POS.Application.DTOs;
using POS.Domain.Common;

namespace POS.Application.Interfaces;

public interface IOrderService
{
    // Query operations
    Task<PagedResult<OrderSummaryDto>> GetOrdersAsync(
        OrderQueryParams query,
        CancellationToken ct = default);

    Task<OrderDto?> GetByIdAsync(string orderId, CancellationToken ct = default);

    Task<OrderDto?> GetByReceiptNumberAsync(
        string receiptNumber,
        CancellationToken ct = default);

    // Command operations
    Task<Result<OrderDto>> CreateOrderAsync(
        CreateOrderRequest request,
        CancellationToken ct = default);

    Task<Result<OrderDto>> ProcessReturnAsync(
        string orderId,
        ProcessReturnRequest request,
        CancellationToken ct = default);

    Task<Result<OrderDto>> VoidOrderAsync(
        string orderId,
        VoidOrderRequest request,
        CancellationToken ct = default);

    // Receipt operations
    Task<ReceiptDto> GetReceiptAsync(string orderId, CancellationToken ct = default);

    Task<Result> PrintReceiptAsync(
        string orderId,
        PrintReceiptRequest request,
        CancellationToken ct = default);

    // Held orders (park/recall)
    Task<Result<OrderDto>> HoldOrderAsync(
        HoldOrderRequest request,
        CancellationToken ct = default);

    Task<IReadOnlyList<HeldOrderDto>> GetHeldOrdersAsync(
        string locationId,
        CancellationToken ct = default);

    Task<Result<OrderDto>> RecallHeldOrderAsync(
        string heldOrderId,
        CancellationToken ct = default);
}

11.3.2 IInventoryService

// File: src/POS.Application/Interfaces/IInventoryService.cs
namespace POS.Application.Interfaces;

public interface IInventoryService
{
    // Query operations
    Task<InventoryLevelDto?> GetInventoryLevelAsync(
        string itemId,
        string locationId,
        CancellationToken ct = default);

    Task<IReadOnlyList<InventoryLevelDto>> GetInventoryByItemAsync(
        string itemId,
        CancellationToken ct = default);

    Task<PagedResult<InventoryLevelDto>> GetInventoryByLocationAsync(
        string locationId,
        InventoryQueryParams query,
        CancellationToken ct = default);

    // Adjustment operations
    Task<Result<AdjustmentDto>> AdjustInventoryAsync(
        AdjustInventoryRequest request,
        CancellationToken ct = default);

    Task<Result<TransferDto>> CreateTransferAsync(
        CreateTransferRequest request,
        CancellationToken ct = default);

    Task<Result<TransferDto>> ReceiveTransferAsync(
        string transferId,
        ReceiveTransferRequest request,
        CancellationToken ct = default);

    // Count operations
    Task<Result<CountDto>> StartCountAsync(
        StartCountRequest request,
        CancellationToken ct = default);

    Task<Result<CountDto>> UpdateCountAsync(
        string countId,
        UpdateCountRequest request,
        CancellationToken ct = default);

    Task<Result<CountDto>> FinalizeCountAsync(
        string countId,
        CancellationToken ct = default);

    // History
    Task<PagedResult<InventoryEventDto>> GetAdjustmentHistoryAsync(
        InventoryHistoryQuery query,
        CancellationToken ct = default);

    // Internal (called by other services)
    Task<Result> DeductInventoryAsync(
        DeductInventoryCommand command,
        CancellationToken ct = default);

    Task<Result> RestoreInventoryAsync(
        RestoreInventoryCommand command,
        CancellationToken ct = default);
}

11.3.3 ICustomerService

// File: src/POS.Application/Interfaces/ICustomerService.cs
namespace POS.Application.Interfaces;

public interface ICustomerService
{
    Task<PagedResult<CustomerSummaryDto>> GetCustomersAsync(
        CustomerQueryParams query,
        CancellationToken ct = default);

    Task<CustomerDto?> GetByIdAsync(string customerId, CancellationToken ct = default);

    Task<IReadOnlyList<CustomerSummaryDto>> SearchAsync(
        string searchTerm,
        int limit = 10,
        CancellationToken ct = default);

    Task<Result<CustomerDto>> CreateAsync(
        CreateCustomerRequest request,
        CancellationToken ct = default);

    Task<Result<CustomerDto>> UpdateAsync(
        string customerId,
        UpdateCustomerRequest request,
        CancellationToken ct = default);

    Task<PagedResult<OrderSummaryDto>> GetPurchaseHistoryAsync(
        string customerId,
        PurchaseHistoryQuery query,
        CancellationToken ct = default);

    Task<LoyaltyInfoDto> GetLoyaltyInfoAsync(
        string customerId,
        CancellationToken ct = default);

    Task<Result<LoyaltyInfoDto>> AddLoyaltyPointsAsync(
        string customerId,
        int points,
        string reason,
        CancellationToken ct = default);

    Task<Result<LoyaltyInfoDto>> RedeemLoyaltyPointsAsync(
        string customerId,
        int points,
        string orderId,
        CancellationToken ct = default);
}

11.3.4 IItemService

// File: src/POS.Application/Interfaces/IItemService.cs
namespace POS.Application.Interfaces;

public interface IItemService
{
    Task<PagedResult<ItemSummaryDto>> GetItemsAsync(
        ItemQueryParams query,
        CancellationToken ct = default);

    Task<ItemDto?> GetByIdAsync(string itemId, CancellationToken ct = default);
    Task<ItemDto?> GetBySkuAsync(string sku, CancellationToken ct = default);
    Task<ItemDto?> GetByBarcodeAsync(string barcode, CancellationToken ct = default);

    Task<Result<ItemDto>> CreateAsync(
        CreateItemRequest request,
        CancellationToken ct = default);

    Task<Result<ItemDto>> UpdateAsync(
        string itemId,
        UpdateItemRequest request,
        CancellationToken ct = default);

    Task<Result> DeleteAsync(string itemId, CancellationToken ct = default);

    Task<BulkImportResult> BulkImportAsync(
        BulkImportRequest request,
        CancellationToken ct = default);

    Task<IReadOnlyList<ItemDto>> GetByIdsAsync(
        IEnumerable<string> itemIds,
        CancellationToken ct = default);
}

11.4 Unit of Work Pattern

// File: src/POS.Application/Interfaces/IUnitOfWork.cs
namespace POS.Application.Interfaces;

public interface IUnitOfWork : IDisposable
{
    IItemRepository Items { get; }
    IOrderRepository Orders { get; }
    ICustomerRepository Customers { get; }
    IInventoryRepository Inventory { get; }
    IEmployeeRepository Employees { get; }
    ILocationRepository Locations { get; }

    Task<int> SaveChangesAsync(CancellationToken ct = default);
    Task BeginTransactionAsync(CancellationToken ct = default);
    Task CommitTransactionAsync(CancellationToken ct = default);
    Task RollbackTransactionAsync(CancellationToken ct = default);
}

// File: src/POS.Infrastructure/Persistence/UnitOfWork.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;

namespace POS.Infrastructure.Persistence;

public class UnitOfWork : IUnitOfWork
{
    private readonly PosDbContext _context;
    private IDbContextTransaction? _transaction;

    public IItemRepository Items { get; }
    public IOrderRepository Orders { get; }
    public ICustomerRepository Customers { get; }
    public IInventoryRepository Inventory { get; }
    public IEmployeeRepository Employees { get; }
    public ILocationRepository Locations { get; }

    public UnitOfWork(
        PosDbContext context,
        IItemRepository items,
        IOrderRepository orders,
        ICustomerRepository customers,
        IInventoryRepository inventory,
        IEmployeeRepository employees,
        ILocationRepository locations)
    {
        _context = context;
        Items = items;
        Orders = orders;
        Customers = customers;
        Inventory = inventory;
        Employees = employees;
        Locations = locations;
    }

    public async Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        return await _context.SaveChangesAsync(ct);
    }

    public async Task BeginTransactionAsync(CancellationToken ct = default)
    {
        _transaction = await _context.Database.BeginTransactionAsync(ct);
    }

    public async Task CommitTransactionAsync(CancellationToken ct = default)
    {
        if (_transaction is not null)
        {
            await _transaction.CommitAsync(ct);
            await _transaction.DisposeAsync();
            _transaction = null;
        }
    }

    public async Task RollbackTransactionAsync(CancellationToken ct = default)
    {
        if (_transaction is not null)
        {
            await _transaction.RollbackAsync(ct);
            await _transaction.DisposeAsync();
            _transaction = null;
        }
    }

    public void Dispose()
    {
        _transaction?.Dispose();
        _context.Dispose();
    }
}

11.5 Complete OrderService Implementation

// File: src/POS.Application/Services/OrderService.cs
using Microsoft.Extensions.Logging;
using POS.Application.DTOs;
using POS.Application.Interfaces;
using POS.Domain.Common;
using POS.Domain.Entities;
using POS.Domain.Events;
using POS.Domain.Exceptions;

namespace POS.Application.Services;

public class OrderService : IOrderService
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly IInventoryService _inventoryService;
    private readonly ICustomerService _customerService;
    private readonly IPaymentService _paymentService;
    private readonly IEventPublisher _eventPublisher;
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IUnitOfWork unitOfWork,
        IInventoryService inventoryService,
        ICustomerService customerService,
        IPaymentService paymentService,
        IEventPublisher eventPublisher,
        ITenantContext tenantContext,
        ILogger<OrderService> logger)
    {
        _unitOfWork = unitOfWork;
        _inventoryService = inventoryService;
        _customerService = customerService;
        _paymentService = paymentService;
        _eventPublisher = eventPublisher;
        _tenantContext = tenantContext;
        _logger = logger;
    }

    public async Task<Result<OrderDto>> CreateOrderAsync(
        CreateOrderRequest request,
        CancellationToken ct = default)
    {
        _logger.LogInformation(
            "Creating order for location {LocationId} with {ItemCount} items",
            request.LocationId,
            request.LineItems.Count);

        try
        {
            await _unitOfWork.BeginTransactionAsync(ct);

            // 1. Validate location and register
            var location = await _unitOfWork.Locations.GetByIdAsync(
                request.LocationId, ct);

            if (location is null)
                return Result<OrderDto>.Failure(
                    DomainError.NotFound("Location", request.LocationId));

            // 2. Validate employee
            var employee = await _unitOfWork.Employees.GetByIdAsync(
                request.EmployeeId, ct);

            if (employee is null)
                return Result<OrderDto>.Failure(
                    DomainError.NotFound("Employee", request.EmployeeId));

            // 3. Load items and validate inventory
            var itemIds = request.LineItems.Select(li => li.ItemId).ToList();
            var items = await _unitOfWork.Items.GetByIdsAsync(itemIds, ct);
            var itemLookup = items.ToDictionary(i => i.Id);

            foreach (var lineItem in request.LineItems)
            {
                if (!itemLookup.TryGetValue(lineItem.ItemId, out var item))
                {
                    return Result<OrderDto>.Failure(
                        DomainError.NotFound("Item", lineItem.ItemId));
                }

                // Check inventory if tracked
                if (item.TrackInventory)
                {
                    var inventory = await _inventoryService.GetInventoryLevelAsync(
                        item.Id, request.LocationId, ct);

                    if (inventory is null || inventory.QuantityOnHand < lineItem.Quantity)
                    {
                        return Result<OrderDto>.Failure(
                            DomainError.InsufficientInventory(
                                item.Sku,
                                lineItem.Quantity,
                                inventory?.QuantityOnHand ?? 0));
                    }
                }
            }

            // 4. Create order entity
            var order = new Order
            {
                Id = IdGenerator.NewId("order"),
                TenantId = _tenantContext.TenantId,
                LocationId = request.LocationId,
                RegisterId = request.RegisterId,
                EmployeeId = request.EmployeeId,
                CustomerId = request.CustomerId,
                ReceiptNumber = await GenerateReceiptNumberAsync(
                    request.LocationId, ct),
                Status = OrderStatus.Completed,
                CreatedAt = DateTime.UtcNow
            };

            // 5. Build line items
            decimal subtotal = 0;
            foreach (var li in request.LineItems)
            {
                var item = itemLookup[li.ItemId];
                var lineItem = new OrderLineItem
                {
                    Id = IdGenerator.NewId("li"),
                    OrderId = order.Id,
                    ItemId = item.Id,
                    Sku = item.Sku,
                    Name = item.Name,
                    Quantity = li.Quantity,
                    UnitPrice = li.UnitPrice ?? item.Price,
                    DiscountAmount = li.DiscountAmount,
                    Taxable = item.Taxable
                };

                lineItem.ExtendedPrice = lineItem.Quantity * lineItem.UnitPrice;
                lineItem.NetPrice = lineItem.ExtendedPrice - lineItem.DiscountAmount;
                subtotal += lineItem.NetPrice;

                order.LineItems.Add(lineItem);
            }

            // 6. Apply order-level discounts
            decimal discountTotal = 0;
            foreach (var discount in request.Discounts ?? [])
            {
                var discountAmount = discount.Type == DiscountType.Percentage
                    ? subtotal * (discount.Value / 100m)
                    : discount.Value;

                discountTotal += discountAmount;

                order.Discounts.Add(new OrderDiscount
                {
                    Id = IdGenerator.NewId("disc"),
                    OrderId = order.Id,
                    Type = discount.Type,
                    Value = discount.Value,
                    Amount = discountAmount,
                    Reason = discount.Reason
                });
            }

            // 7. Calculate tax
            decimal taxableAmount = order.LineItems
                .Where(li => li.Taxable)
                .Sum(li => li.NetPrice);

            // Apply discount proportionally to taxable amount
            if (subtotal > 0 && discountTotal > 0)
            {
                var taxableRatio = taxableAmount / subtotal;
                taxableAmount -= discountTotal * taxableRatio;
            }

            var taxRate = location.TaxRate;
            order.TaxAmount = Math.Round(taxableAmount * taxRate, 2);

            // 8. Set totals
            order.Subtotal = subtotal;
            order.DiscountTotal = discountTotal;
            order.GrandTotal = subtotal - discountTotal + order.TaxAmount;

            // 9. Process payments
            decimal paymentTotal = 0;
            foreach (var payment in request.Payments)
            {
                var paymentResult = await _paymentService.ProcessPaymentAsync(
                    new ProcessPaymentCommand
                    {
                        OrderId = order.Id,
                        Method = payment.Method,
                        Amount = payment.Amount,
                        Reference = payment.Reference
                    }, ct);

                if (!paymentResult.IsSuccess)
                {
                    await _unitOfWork.RollbackTransactionAsync(ct);
                    return Result<OrderDto>.Failure(paymentResult.Error!);
                }

                order.Payments.Add(new OrderPayment
                {
                    Id = IdGenerator.NewId("pmt"),
                    OrderId = order.Id,
                    Method = payment.Method,
                    Amount = payment.Amount,
                    Status = PaymentStatus.Captured,
                    Reference = paymentResult.Value!.TransactionId,
                    CardLast4 = payment.CardLast4,
                    CardBrand = payment.CardBrand
                });

                paymentTotal += payment.Amount;
            }

            // 10. Validate payment covers total
            if (paymentTotal < order.GrandTotal)
            {
                await _unitOfWork.RollbackTransactionAsync(ct);
                return Result<OrderDto>.Failure(
                    DomainError.InsufficientPayment(order.GrandTotal, paymentTotal));
            }

            order.ChangeGiven = paymentTotal - order.GrandTotal;

            // 11. Deduct inventory
            foreach (var lineItem in order.LineItems)
            {
                var item = itemLookup[lineItem.ItemId];
                if (item.TrackInventory)
                {
                    var deductResult = await _inventoryService.DeductInventoryAsync(
                        new DeductInventoryCommand
                        {
                            ItemId = lineItem.ItemId,
                            LocationId = request.LocationId,
                            Quantity = lineItem.Quantity,
                            Reason = InventoryChangeReason.Sale,
                            ReferenceId = order.Id,
                            ReferenceType = "Order"
                        }, ct);

                    if (!deductResult.IsSuccess)
                    {
                        await _unitOfWork.RollbackTransactionAsync(ct);
                        return Result<OrderDto>.Failure(deductResult.Error!);
                    }
                }
            }

            // 12. Award loyalty points
            if (request.CustomerId is not null)
            {
                var pointsToAward = CalculateLoyaltyPoints(order.GrandTotal);
                await _customerService.AddLoyaltyPointsAsync(
                    request.CustomerId,
                    pointsToAward,
                    $"Purchase: {order.ReceiptNumber}",
                    ct);
            }

            // 13. Save order
            await _unitOfWork.Orders.AddAsync(order, ct);
            await _unitOfWork.SaveChangesAsync(ct);
            await _unitOfWork.CommitTransactionAsync(ct);

            // 14. Publish domain events
            await _eventPublisher.PublishAsync(new OrderCompletedEvent
            {
                OrderId = order.Id,
                TenantId = order.TenantId,
                LocationId = order.LocationId,
                ReceiptNumber = order.ReceiptNumber,
                GrandTotal = order.GrandTotal,
                ItemCount = order.LineItems.Count,
                CustomerId = order.CustomerId,
                EmployeeId = order.EmployeeId,
                OccurredAt = DateTime.UtcNow
            }, ct);

            _logger.LogInformation(
                "Order {OrderId} created successfully. Receipt: {ReceiptNumber}, Total: {Total}",
                order.Id,
                order.ReceiptNumber,
                order.GrandTotal);

            return Result<OrderDto>.Success(MapToDto(order));
        }
        catch (Exception ex)
        {
            await _unitOfWork.RollbackTransactionAsync(ct);
            _logger.LogError(ex, "Failed to create order");
            throw;
        }
    }

    public async Task<Result<OrderDto>> ProcessReturnAsync(
        string orderId,
        ProcessReturnRequest request,
        CancellationToken ct = default)
    {
        _logger.LogInformation(
            "Processing return for order {OrderId}",
            orderId);

        try
        {
            await _unitOfWork.BeginTransactionAsync(ct);

            var originalOrder = await _unitOfWork.Orders.GetByIdAsync(orderId, ct);
            if (originalOrder is null)
                return Result<OrderDto>.Failure(
                    DomainError.NotFound("Order", orderId));

            if (originalOrder.Status == OrderStatus.Voided)
                return Result<OrderDto>.Failure(
                    DomainError.InvalidOperation("Cannot return a voided order"));

            // Create return order
            var returnOrder = new Order
            {
                Id = IdGenerator.NewId("order"),
                TenantId = _tenantContext.TenantId,
                LocationId = originalOrder.LocationId,
                RegisterId = request.RegisterId,
                EmployeeId = request.EmployeeId,
                CustomerId = originalOrder.CustomerId,
                ReceiptNumber = await GenerateReceiptNumberAsync(
                    originalOrder.LocationId, ct),
                Status = OrderStatus.Completed,
                OrderType = OrderType.Return,
                OriginalOrderId = orderId,
                CreatedAt = DateTime.UtcNow
            };

            decimal returnSubtotal = 0;

            foreach (var returnItem in request.LineItems)
            {
                var originalLineItem = originalOrder.LineItems
                    .FirstOrDefault(li => li.Id == returnItem.OriginalLineItemId);

                if (originalLineItem is null)
                    return Result<OrderDto>.Failure(
                        DomainError.NotFound("LineItem", returnItem.OriginalLineItemId));

                if (returnItem.Quantity > originalLineItem.Quantity)
                    return Result<OrderDto>.Failure(
                        DomainError.InvalidOperation(
                            $"Return quantity exceeds original quantity"));

                var returnLineItem = new OrderLineItem
                {
                    Id = IdGenerator.NewId("li"),
                    OrderId = returnOrder.Id,
                    ItemId = originalLineItem.ItemId,
                    Sku = originalLineItem.Sku,
                    Name = originalLineItem.Name,
                    Quantity = -returnItem.Quantity,
                    UnitPrice = originalLineItem.UnitPrice,
                    DiscountAmount = 0,
                    Taxable = originalLineItem.Taxable,
                    ReturnReason = returnItem.Reason
                };

                returnLineItem.ExtendedPrice = returnLineItem.Quantity *
                    returnLineItem.UnitPrice;
                returnLineItem.NetPrice = returnLineItem.ExtendedPrice;
                returnSubtotal += returnLineItem.NetPrice;

                returnOrder.LineItems.Add(returnLineItem);

                // Restore inventory
                var item = await _unitOfWork.Items.GetByIdAsync(
                    originalLineItem.ItemId, ct);

                if (item?.TrackInventory == true)
                {
                    await _inventoryService.RestoreInventoryAsync(
                        new RestoreInventoryCommand
                        {
                            ItemId = originalLineItem.ItemId,
                            LocationId = originalOrder.LocationId,
                            Quantity = returnItem.Quantity,
                            Reason = InventoryChangeReason.Return,
                            ReferenceId = returnOrder.Id,
                            ReferenceType = "Return"
                        }, ct);
                }
            }

            // Calculate return tax
            var location = await _unitOfWork.Locations.GetByIdAsync(
                originalOrder.LocationId, ct);
            decimal taxableReturnAmount = returnOrder.LineItems
                .Where(li => li.Taxable)
                .Sum(li => li.NetPrice);
            returnOrder.TaxAmount = Math.Round(
                Math.Abs(taxableReturnAmount) * location!.TaxRate, 2) * -1;

            returnOrder.Subtotal = returnSubtotal;
            returnOrder.GrandTotal = returnSubtotal + returnOrder.TaxAmount;

            // Process refund
            var refundResult = await _paymentService.ProcessRefundAsync(
                new ProcessRefundCommand
                {
                    OriginalOrderId = orderId,
                    RefundOrderId = returnOrder.Id,
                    Amount = Math.Abs(returnOrder.GrandTotal),
                    Method = request.RefundMethod
                }, ct);

            if (!refundResult.IsSuccess)
            {
                await _unitOfWork.RollbackTransactionAsync(ct);
                return Result<OrderDto>.Failure(refundResult.Error!);
            }

            returnOrder.Payments.Add(new OrderPayment
            {
                Id = IdGenerator.NewId("pmt"),
                OrderId = returnOrder.Id,
                Method = request.RefundMethod,
                Amount = returnOrder.GrandTotal,
                Status = PaymentStatus.Refunded,
                Reference = refundResult.Value!.TransactionId
            });

            await _unitOfWork.Orders.AddAsync(returnOrder, ct);
            await _unitOfWork.SaveChangesAsync(ct);
            await _unitOfWork.CommitTransactionAsync(ct);

            await _eventPublisher.PublishAsync(new OrderReturnedEvent
            {
                OrderId = returnOrder.Id,
                OriginalOrderId = orderId,
                TenantId = returnOrder.TenantId,
                RefundAmount = Math.Abs(returnOrder.GrandTotal),
                OccurredAt = DateTime.UtcNow
            }, ct);

            return Result<OrderDto>.Success(MapToDto(returnOrder));
        }
        catch (Exception ex)
        {
            await _unitOfWork.RollbackTransactionAsync(ct);
            _logger.LogError(ex, "Failed to process return for order {OrderId}", orderId);
            throw;
        }
    }

    public async Task<Result<OrderDto>> VoidOrderAsync(
        string orderId,
        VoidOrderRequest request,
        CancellationToken ct = default)
    {
        var order = await _unitOfWork.Orders.GetByIdAsync(orderId, ct);
        if (order is null)
            return Result<OrderDto>.Failure(DomainError.NotFound("Order", orderId));

        if (order.Status == OrderStatus.Voided)
            return Result<OrderDto>.Failure(
                DomainError.InvalidOperation("Order is already voided"));

        // Check void window (typically same day only)
        if (order.CreatedAt.Date != DateTime.UtcNow.Date)
            return Result<OrderDto>.Failure(
                DomainError.InvalidOperation("Orders can only be voided on the same day"));

        try
        {
            await _unitOfWork.BeginTransactionAsync(ct);

            // Void all payments
            foreach (var payment in order.Payments.Where(p =>
                p.Status == PaymentStatus.Captured))
            {
                var voidResult = await _paymentService.VoidPaymentAsync(
                    payment.Reference!, ct);

                if (!voidResult.IsSuccess)
                {
                    await _unitOfWork.RollbackTransactionAsync(ct);
                    return Result<OrderDto>.Failure(voidResult.Error!);
                }

                payment.Status = PaymentStatus.Voided;
            }

            // Restore inventory
            foreach (var lineItem in order.LineItems)
            {
                var item = await _unitOfWork.Items.GetByIdAsync(lineItem.ItemId, ct);
                if (item?.TrackInventory == true)
                {
                    await _inventoryService.RestoreInventoryAsync(
                        new RestoreInventoryCommand
                        {
                            ItemId = lineItem.ItemId,
                            LocationId = order.LocationId,
                            Quantity = lineItem.Quantity,
                            Reason = InventoryChangeReason.Void,
                            ReferenceId = order.Id,
                            ReferenceType = "VoidedOrder"
                        }, ct);
                }
            }

            // Reverse loyalty points
            if (order.CustomerId is not null)
            {
                var pointsToDeduct = CalculateLoyaltyPoints(order.GrandTotal);
                await _customerService.AddLoyaltyPointsAsync(
                    order.CustomerId,
                    -pointsToDeduct,
                    $"Voided: {order.ReceiptNumber}",
                    ct);
            }

            order.Status = OrderStatus.Voided;
            order.VoidedAt = DateTime.UtcNow;
            order.VoidedBy = request.EmployeeId;
            order.VoidReason = request.Reason;

            await _unitOfWork.SaveChangesAsync(ct);
            await _unitOfWork.CommitTransactionAsync(ct);

            await _eventPublisher.PublishAsync(new OrderVoidedEvent
            {
                OrderId = order.Id,
                TenantId = order.TenantId,
                Reason = request.Reason,
                VoidedBy = request.EmployeeId,
                OccurredAt = DateTime.UtcNow
            }, ct);

            return Result<OrderDto>.Success(MapToDto(order));
        }
        catch (Exception ex)
        {
            await _unitOfWork.RollbackTransactionAsync(ct);
            _logger.LogError(ex, "Failed to void order {OrderId}", orderId);
            throw;
        }
    }

    private async Task<string> GenerateReceiptNumberAsync(
        string locationId,
        CancellationToken ct)
    {
        var location = await _unitOfWork.Locations.GetByIdAsync(locationId, ct);
        var prefix = location?.Code ?? "XX";
        var date = DateTime.UtcNow.ToString("yyyyMMdd");
        var sequence = await _unitOfWork.Orders.GetNextSequenceAsync(locationId, ct);
        return $"{prefix}-{date}-{sequence:D4}";
    }

    private static int CalculateLoyaltyPoints(decimal amount)
    {
        return (int)Math.Floor(amount);
    }

    private static OrderDto MapToDto(Order order)
    {
        return new OrderDto
        {
            Id = order.Id,
            ReceiptNumber = order.ReceiptNumber,
            Status = order.Status.ToString(),
            // ... map all properties
        };
    }

    // ... other interface methods
}

11.6 Transactional Outbox Pattern

Domain events must be published reliably. Direct publishing (e.g., calling a message bus after SaveChanges) risks events being lost if the application crashes between the database commit and the publish call. The Transactional Outbox pattern solves this by writing events to an event_outbox table in the same database transaction as the business data.

Outbox Entity

// File: src/POS.Domain/Events/OutboxMessage.cs
namespace POS.Domain.Events;

public class OutboxMessage
{
    public Guid Id { get; set; }
    public string TenantId { get; set; } = null!;
    public string EventType { get; set; } = null!;
    public string Payload { get; set; } = null!;      // JSON-serialized event
    public DateTime CreatedAt { get; set; }
    public DateTime? ProcessedAt { get; set; }
    public int RetryCount { get; set; }
    public string? Error { get; set; }
}

Writing Events to the Outbox

The IEventPublisher writes to the outbox table within the current transaction instead of publishing directly to a message bus:

// File: src/POS.Infrastructure/Messaging/OutboxEventPublisher.cs
using System.Text.Json;

namespace POS.Infrastructure.Messaging;

public class OutboxEventPublisher : IEventPublisher
{
    private readonly PosDbContext _dbContext;
    private readonly ILogger<OutboxEventPublisher> _logger;

    public OutboxEventPublisher(
        PosDbContext dbContext,
        ILogger<OutboxEventPublisher> logger)
    {
        _dbContext = dbContext;
        _logger = logger;
    }

    public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default)
        where TEvent : IDomainEvent
    {
        var outboxMessage = new OutboxMessage
        {
            Id = Guid.NewGuid(),
            TenantId = @event.TenantId,
            EventType = typeof(TEvent).AssemblyQualifiedName!,
            Payload = JsonSerializer.Serialize(@event),
            CreatedAt = DateTime.UtcNow
        };

        _dbContext.OutboxMessages.Add(outboxMessage);
        // SaveChanges is called by the UnitOfWork in the same transaction

        _logger.LogDebug(
            "Queued event {EventType} to outbox for tenant {TenantId}",
            typeof(TEvent).Name,
            @event.TenantId);
    }

    public async Task PublishManyAsync<TEvent>(
        IEnumerable<TEvent> events, CancellationToken ct = default)
        where TEvent : IDomainEvent
    {
        foreach (var @event in events)
            await PublishAsync(@event, ct);
    }
}

Outbox Background Worker

A BackgroundService polls the outbox table and publishes pending events to the message bus (LISTEN/NOTIFY for v1.0, Kafka for v2.0) and SignalR for real-time UI updates:

// File: src/POS.Infrastructure/Messaging/OutboxWorker.cs
using System.Text.Json;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using POS.Api.Hubs;

namespace POS.Infrastructure.Messaging;

public class OutboxWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OutboxWorker> _logger;

    public OutboxWorker(
        IServiceScopeFactory scopeFactory,
        ILogger<OutboxWorker> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        _logger.LogInformation("Outbox worker started");

        while (!ct.IsCancellationRequested)
        {
            try
            {
                using var scope = _scopeFactory.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<PosDbContext>();
                var bus = scope.ServiceProvider.GetRequiredService<IPublishEndpoint>();
                var hub = scope.ServiceProvider.GetRequiredService<IHubContext<PosHub, IPosHubClient>>();

                var pending = await db.OutboxMessages
                    .Where(e => e.ProcessedAt == null && e.RetryCount < 5)
                    .OrderBy(e => e.CreatedAt)
                    .Take(100)
                    .ToListAsync(ct);

                foreach (var message in pending)
                {
                    try
                    {
                        // Deserialize and publish to message bus
                        var eventType = Type.GetType(message.EventType);
                        if (eventType is not null)
                        {
                            var @event = JsonSerializer.Deserialize(
                                message.Payload, eventType);
                            if (@event is not null)
                            {
                                await bus.Publish(@event, eventType, ct);
                                await PublishToSignalRAsync(hub, @event, message.TenantId, ct);
                            }
                        }

                        message.ProcessedAt = DateTime.UtcNow;
                    }
                    catch (Exception ex)
                    {
                        message.RetryCount++;
                        message.Error = ex.Message;
                        _logger.LogWarning(ex,
                            "Failed to publish outbox message {Id}. Retry {Count}",
                            message.Id, message.RetryCount);
                    }
                }

                await db.SaveChangesAsync(ct);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Outbox worker error");
            }

            await Task.Delay(TimeSpan.FromSeconds(5), ct);
        }
    }

    private static async Task PublishToSignalRAsync(
        IHubContext<PosHub, IPosHubClient> hub,
        object @event,
        string tenantId,
        CancellationToken ct)
    {
        var tenantGroup = $"tenant:{tenantId}";

        switch (@event)
        {
            case OrderCompletedEvent order:
                await hub.Clients.Group(tenantGroup)
                    .OrderCompleted(new OrderCompletedNotification
                    {
                        OrderId = order.OrderId,
                        ReceiptNumber = order.ReceiptNumber,
                        GrandTotal = order.GrandTotal
                    });
                break;

            case InventoryUpdatedEvent inv:
                await hub.Clients.Group(tenantGroup)
                    .InventoryUpdated(new InventoryUpdateNotification
                    {
                        ItemId = inv.ItemId,
                        LocationId = inv.LocationId,
                        NewQuantity = inv.NewQuantity
                    });
                break;
        }
    }
}

Key guarantee: Events are written to event_outbox in the same DB transaction as business data. If the transaction rolls back, the events are never published. The background worker provides at-least-once delivery with retry and dead-letter handling.


11.7 CQRS with MediatR (Sales Module)

The Sales module uses CQRS (Command Query Responsibility Segregation) with MediatR for explicit command/query separation. Other modules use the standard service layer pattern (e.g., IItemService, IInventoryService). Sales is the highest-throughput, most complex domain – CQRS gives it clear audit trails, separate read/write optimization, and pipeline behaviours.

MediatR Pipeline Behaviours

Pipeline behaviours wrap every request in a consistent cross-cutting chain: validation first, then logging, then transaction management, then the actual handler.

// File: src/POS.Api/Program.cs (MediatR registration)
services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(CreateSaleCommand).Assembly);
});

// Pipeline order: Validation -> Logging -> Transaction -> Handler
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));

Validation Behaviour

// File: src/POS.Application/Behaviours/ValidationBehavior.cs
using FluentValidation;
using MediatR;

namespace POS.Application.Behaviours;

public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);
        var failures = (await Task.WhenAll(
                _validators.Select(v => v.ValidateAsync(context, ct))))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await next();
    }
}

Transaction Behaviour

// File: src/POS.Application/Behaviours/TransactionBehavior.cs
using MediatR;

namespace POS.Application.Behaviours;

public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;

    public TransactionBehavior(
        IUnitOfWork unitOfWork,
        ILogger<TransactionBehavior<TRequest, TResponse>> logger)
    {
        _unitOfWork = unitOfWork;
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        // Only wrap commands (not queries) in transactions
        if (request is IQuery)
            return await next();

        await _unitOfWork.BeginTransactionAsync(ct);
        try
        {
            var response = await next();
            await _unitOfWork.CommitTransactionAsync(ct);
            return response;
        }
        catch
        {
            await _unitOfWork.RollbackTransactionAsync(ct);
            throw;
        }
    }
}

Sales Commands

// File: src/POS.Application/Sales/Commands/CreateSaleCommand.cs
using MediatR;

namespace POS.Application.Sales.Commands;

public record CreateSaleCommand(
    string LocationId,
    string RegisterId,
    string EmployeeId,
    string? CustomerId,
    List<SaleLineItemDto> LineItems,
    List<SaleDiscountDto>? Discounts,
    List<SalePaymentDto> Payments
) : IRequest<Result<OrderDto>>;

// File: src/POS.Application/Sales/Commands/VoidSaleCommand.cs
public record VoidSaleCommand(
    string OrderId,
    string EmployeeId,
    string Reason
) : IRequest<Result<OrderDto>>;

// File: src/POS.Application/Sales/Commands/ParkSaleCommand.cs
public record ParkSaleCommand(
    string LocationId,
    string RegisterId,
    string EmployeeId,
    List<SaleLineItemDto> LineItems,
    string? CustomerName,
    string? Notes
) : IRequest<Result<HeldOrderDto>>;

Sales Queries

// File: src/POS.Application/Sales/Queries/GetDailySalesReportQuery.cs
using MediatR;

namespace POS.Application.Sales.Queries;

public record GetDailySalesReportQuery(
    Guid TenantId,
    DateOnly Date,
    string? LocationId
) : IRequest<DailySalesReport>, IQuery;

// File: src/POS.Application/Sales/Queries/GetDailySalesReportHandler.cs
public class GetDailySalesReportHandler
    : IRequestHandler<GetDailySalesReportQuery, DailySalesReport>
{
    private readonly IReadOnlyDbContext _readDb;

    public GetDailySalesReportHandler(IReadOnlyDbContext readDb)
    {
        _readDb = readDb;
    }

    public async Task<DailySalesReport> Handle(
        GetDailySalesReportQuery request,
        CancellationToken ct)
    {
        // Reads from read-optimized materialized view
        // No business logic -- pure data retrieval
        var report = await _readDb.DailySalesReports
            .Where(r => r.ReportDate == request.Date)
            .Where(r => request.LocationId == null || r.LocationId == request.LocationId)
            .FirstOrDefaultAsync(ct);

        return report ?? DailySalesReport.Empty(request.Date);
    }
}

// File: src/POS.Application/Sales/Queries/GetSaleByIdQuery.cs
public record GetSaleByIdQuery(string OrderId) : IRequest<OrderDto?>, IQuery;

Controller Using MediatR

// File: src/POS.Api/Controllers/SalesController.cs (MediatR version)
[ApiController]
[Route("api/v1/sales")]
[Authorize]
public class SalesController : ControllerBase
{
    private readonly IMediator _mediator;

    public SalesController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    [Authorize(Policy = "pos.sale.create")]
    public async Task<ActionResult<OrderDto>> CreateSale(
        [FromBody] CreateSaleCommand command,
        CancellationToken ct)
    {
        var result = await _mediator.Send(command, ct);
        return result.Match<ActionResult<OrderDto>>(
            success => CreatedAtAction(nameof(GetSale), new { id = success.Id }, success),
            error => BadRequest(ProblemFactory.FromError(error))
        );
    }

    [HttpGet("{id}")]
    [Authorize(Policy = "pos.sale.create")]
    public async Task<ActionResult<OrderDto>> GetSale(string id, CancellationToken ct)
    {
        var result = await _mediator.Send(new GetSaleByIdQuery(id), ct);
        return result is not null ? Ok(result) : NotFound();
    }

    [HttpPost("{id}/void")]
    [Authorize(Policy = "pos.sale.void")]
    public async Task<ActionResult<OrderDto>> VoidSale(
        string id,
        [FromBody] VoidSaleRequest request,
        CancellationToken ct)
    {
        var result = await _mediator.Send(
            new VoidSaleCommand(id, request.EmployeeId, request.Reason), ct);
        return result.Match<ActionResult<OrderDto>>(
            success => Ok(success),
            error => BadRequest(ProblemFactory.FromError(error))
        );
    }
}

Scope note: Only the Sales module uses CQRS/MediatR. Catalog, Inventory, Customer, and Employee modules use the standard IXxxService pattern shown in Sections 11.3-11.5. Adding CQRS to all modules would be over-engineering at this scale.


11.8 DB-Driven State Machines

The POS system has 16 state machines governing entity lifecycles (orders, transfers, inventory counts, registers, employees, etc.). State transitions are DB-driven via a state_transitions table – NOT hardcoded switch/case or if/else chains. This allows tenants to customize workflows and enables audit-friendly transition logging.

State Transition Table

-- File: migrations/state_transitions.sql
CREATE TABLE state_transitions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    entity_type     VARCHAR(50) NOT NULL,     -- 'order', 'transfer', 'inventory_count', etc.
    from_state      VARCHAR(50) NOT NULL,     -- '*' means "any state"
    to_state        VARCHAR(50) NOT NULL,
    required_role   VARCHAR(50),              -- NULL = any role can trigger
    guard_condition VARCHAR(255),             -- Optional: evaluated at runtime
    side_effects    JSONB,                    -- Events to publish, actions to trigger
    is_active       BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(tenant_id, entity_type, from_state, to_state)
);

-- RLS policy
ALTER TABLE state_transitions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON state_transitions
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

-- Example: Order state transitions
INSERT INTO state_transitions (tenant_id, entity_type, from_state, to_state, required_role, guard_condition, side_effects) VALUES
('tenant-1', 'order', 'draft',     'held',      NULL,      NULL, '{"event":"OrderHeld"}'),
('tenant-1', 'order', 'draft',     'completed', NULL,      'payment_total >= grand_total', '{"event":"OrderCompleted"}'),
('tenant-1', 'order', 'held',      'draft',     NULL,      NULL, '{"event":"OrderRecalled"}'),
('tenant-1', 'order', 'held',      'voided',    'manager', NULL, '{"event":"OrderVoided"}'),
('tenant-1', 'order', 'completed', 'voided',    'manager', 'same_day_only', '{"event":"OrderVoided"}'),
('tenant-1', 'order', 'completed', 'returned',  'manager', NULL, '{"event":"OrderReturned"}');

-- Example: Transfer state transitions
INSERT INTO state_transitions (tenant_id, entity_type, from_state, to_state, required_role, guard_condition, side_effects) VALUES
('tenant-1', 'transfer', 'pending',    'in_transit', 'manager', NULL, '{"event":"TransferShipped"}'),
('tenant-1', 'transfer', 'in_transit', 'received',   'manager', NULL, '{"event":"TransferReceived"}'),
('tenant-1', 'transfer', 'pending',    'cancelled',  'manager', NULL, '{"event":"TransferCancelled"}'),
('tenant-1', 'transfer', 'in_transit', 'cancelled',  'admin',   NULL, '{"event":"TransferCancelled"}');

State Machine Engine

// File: src/POS.Application/StateMachine/IStateMachineEngine.cs
namespace POS.Application.StateMachine;

public interface IStateMachineEngine
{
    Task<Result> TransitionAsync(
        string entityType,
        string entityId,
        string fromState,
        string toState,
        string triggeredByRole,
        Dictionary<string, object>? context = null,
        CancellationToken ct = default);

    Task<IReadOnlyList<string>> GetAllowedTransitionsAsync(
        string entityType,
        string currentState,
        string userRole,
        CancellationToken ct = default);
}

// File: src/POS.Infrastructure/StateMachine/StateMachineEngine.cs
namespace POS.Infrastructure.StateMachine;

public class StateMachineEngine : IStateMachineEngine
{
    private readonly PosDbContext _db;
    private readonly IEventPublisher _eventPublisher;
    private readonly IGuardEvaluator _guardEvaluator;
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<StateMachineEngine> _logger;

    public StateMachineEngine(
        PosDbContext db,
        IEventPublisher eventPublisher,
        IGuardEvaluator guardEvaluator,
        ITenantContext tenantContext,
        ILogger<StateMachineEngine> logger)
    {
        _db = db;
        _eventPublisher = eventPublisher;
        _guardEvaluator = guardEvaluator;
        _tenantContext = tenantContext;
        _logger = logger;
    }

    public async Task<Result> TransitionAsync(
        string entityType,
        string entityId,
        string fromState,
        string toState,
        string triggeredByRole,
        Dictionary<string, object>? context = null,
        CancellationToken ct = default)
    {
        // Find valid transition from DB
        var transition = await _db.StateTransitions
            .Where(t => t.EntityType == entityType)
            .Where(t => t.FromState == fromState || t.FromState == "*")
            .Where(t => t.ToState == toState)
            .Where(t => t.IsActive)
            .FirstOrDefaultAsync(ct);

        if (transition is null)
        {
            _logger.LogWarning(
                "No valid transition from {From} to {To} for {EntityType}",
                fromState, toState, entityType);
            return Result.Failure(
                DomainError.InvalidStateTransition(entityType, fromState, toState));
        }

        // Check role requirement
        if (transition.RequiredRole is not null &&
            !IsRoleSufficient(triggeredByRole, transition.RequiredRole))
        {
            return Result.Failure(
                DomainError.InsufficientRole(transition.RequiredRole, triggeredByRole));
        }

        // Evaluate guard condition
        if (transition.GuardCondition is not null)
        {
            var guardResult = await _guardEvaluator.EvaluateAsync(
                transition.GuardCondition, context ?? new(), ct);

            if (!guardResult)
            {
                return Result.Failure(
                    DomainError.GuardConditionFailed(transition.GuardCondition));
            }
        }

        // Log the transition
        _db.StateTransitionLogs.Add(new StateTransitionLog
        {
            Id = Guid.NewGuid(),
            TenantId = Guid.Parse(_tenantContext.TenantId!),
            EntityType = entityType,
            EntityId = entityId,
            FromState = fromState,
            ToState = toState,
            TriggeredBy = triggeredByRole,
            TransitionId = transition.Id,
            OccurredAt = DateTime.UtcNow
        });

        // Publish side-effect events via outbox
        if (transition.SideEffects is not null)
        {
            var sideEffects = JsonSerializer.Deserialize<SideEffectConfig>(
                transition.SideEffects.ToString()!);

            if (sideEffects?.Event is not null)
            {
                await _eventPublisher.PublishAsync(new StateTransitionEvent
                {
                    TenantId = _tenantContext.TenantId!,
                    EntityType = entityType,
                    EntityId = entityId,
                    FromState = fromState,
                    ToState = toState,
                    EventName = sideEffects.Event,
                    OccurredAt = DateTime.UtcNow
                }, ct);
            }
        }

        _logger.LogInformation(
            "State transition: {EntityType} {EntityId} [{From}] -> [{To}]",
            entityType, entityId, fromState, toState);

        return Result.Success();
    }

    public async Task<IReadOnlyList<string>> GetAllowedTransitionsAsync(
        string entityType,
        string currentState,
        string userRole,
        CancellationToken ct = default)
    {
        var transitions = await _db.StateTransitions
            .Where(t => t.EntityType == entityType)
            .Where(t => t.FromState == currentState || t.FromState == "*")
            .Where(t => t.IsActive)
            .ToListAsync(ct);

        return transitions
            .Where(t => t.RequiredRole is null || IsRoleSufficient(userRole, t.RequiredRole))
            .Select(t => t.ToState)
            .Distinct()
            .ToList();
    }

    private static bool IsRoleSufficient(string userRole, string requiredRole)
    {
        var hierarchy = new[] { "staff", "buyer", "manager", "admin", "owner" };
        var userLevel = Array.IndexOf(hierarchy, userRole);
        var requiredLevel = Array.IndexOf(hierarchy, requiredRole);
        return userLevel >= requiredLevel;
    }
}

16 State Machines

#Entity TypeStatesKey Transitions
1orderdraft, held, completed, voided, returneddraft->completed (payment), completed->voided (same-day)
2transferpending, in_transit, received, cancelledpending->in_transit (ship), in_transit->received (confirm)
3inventory_countopen, in_progress, review, finalized, cancelledopen->in_progress (start scanning), review->finalized (approve)
4purchase_orderdraft, submitted, partial, received, closeddraft->submitted (send to vendor), partial->received
5registeractive, suspended, retiredactive->suspended (issue), suspended->retired (OWNER only)
6employeeactive, suspended, terminatedactive->suspended (disciplinary)
7customeractive, inactive, mergedactive->merged (dedup)
8gift_cardactive, redeemed, expired, voidedactive->redeemed (zero balance)
9discountactive, scheduled, expired, disabledscheduled->active (start date), active->expired (end date)
10paymentpending, captured, refunded, voided, failedpending->captured (processor confirms)
11rfid_sessioncreated, scanning, uploading, completed, cancelledscanning->uploading (end scan), uploading->completed (variance calc)
12integration_syncqueued, processing, completed, failed, retryingqueued->processing (worker picks up)
13tenanttrial, active, suspended, churnedtrial->active (payment), active->suspended (non-payment)
14locationactive, temporarily_closed, permanently_closedactive->temporarily_closed (renovation)
15price_changedraft, scheduled, active, expiredscheduled->active (effective date)
16return_authorizationpending, approved, rejected, completedpending->approved (manager), approved->completed (items received)

11.9 Tenant Context at Database Level

Multi-tenancy isolation is enforced at two levels: application middleware (see Chapter 12, Section 12.6) and PostgreSQL session variables for Row-Level Security. Every database connection must set app.current_tenant so RLS policies can filter rows automatically.

DbContext Tenant Interceptor

// File: src/POS.Infrastructure/Persistence/TenantConnectionInterceptor.cs
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Data.Common;

namespace POS.Infrastructure.Persistence;

public class TenantConnectionInterceptor : DbConnectionInterceptor
{
    private readonly ITenantContext _tenantContext;

    public TenantConnectionInterceptor(ITenantContext tenantContext)
    {
        _tenantContext = tenantContext;
    }

    public override async Task ConnectionOpenedAsync(
        DbConnection connection,
        ConnectionEndEventData eventData,
        CancellationToken ct = default)
    {
        if (_tenantContext.TenantId is not null)
        {
            await using var cmd = connection.CreateCommand();
            cmd.CommandText = $"SET app.current_tenant = '{_tenantContext.TenantId}'";
            await cmd.ExecuteNonQueryAsync(ct);
        }
    }

    public override void ConnectionOpened(
        DbConnection connection,
        ConnectionEndEventData eventData)
    {
        if (_tenantContext.TenantId is not null)
        {
            using var cmd = connection.CreateCommand();
            cmd.CommandText = $"SET app.current_tenant = '{_tenantContext.TenantId}'";
            cmd.ExecuteNonQuery();
        }
    }
}

Registration

// File: src/POS.Infrastructure/Persistence/PosDbContext.cs (partial)
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.AddInterceptors(
        _serviceProvider.GetRequiredService<TenantConnectionInterceptor>());
}

Important: This SET app.current_tenant call is what activates PostgreSQL RLS policies. Without it, queries would either return no rows (if current_setting('app.current_tenant') is empty) or fail. See Chapter 06 (Database Strategy) and Chapter 07 (Schema Design) for the RLS policy definitions.


11.10 Dependency Injection Configuration

// File: src/POS.Api/Program.cs (partial)
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services)
    {
        // Application services
        services.AddScoped<IOrderService, OrderService>();
        services.AddScoped<IInventoryService, InventoryService>();
        services.AddScoped<ICustomerService, CustomerService>();
        services.AddScoped<IItemService, ItemService>();
        services.AddScoped<IEmployeeService, EmployeeService>();
        services.AddScoped<IReportService, ReportService>();
        services.AddScoped<IPaymentService, PaymentService>();

        // CQRS / MediatR (Sales module only)
        services.AddMediatR(cfg =>
            cfg.RegisterServicesFromAssembly(typeof(CreateSaleCommand).Assembly));
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));

        // Infrastructure
        services.AddScoped<IUnitOfWork, UnitOfWork>();
        services.AddScoped<IEventPublisher, OutboxEventPublisher>();
        services.AddScoped<IStateMachineEngine, StateMachineEngine>();
        services.AddScoped<TenantConnectionInterceptor>();
        services.AddHostedService<OutboxWorker>();

        // Repositories
        services.AddScoped<IItemRepository, ItemRepository>();
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<ICustomerRepository, CustomerRepository>();
        services.AddScoped<IInventoryRepository, InventoryRepository>();
        services.AddScoped<IEmployeeRepository, EmployeeRepository>();
        services.AddScoped<ILocationRepository, LocationRepository>();

        return services;
    }
}

Summary

This chapter defined the complete service layer architecture:

  • Clean architecture with clear separation of concerns
  • Service interfaces for all major domains
  • Unit of Work pattern for transaction management
  • Complete OrderService implementation with full transaction flow
  • Transactional Outbox pattern for reliable at-least-once event delivery
  • CQRS with MediatR for Sales module (commands, queries, pipeline behaviours)
  • DB-driven state machines (16 entity types) via state_transitions table
  • Tenant context at DB level with SET app.current_tenant for RLS activation

Next: Chapter 12: Security & Authentication covers security and authentication patterns.


Document Information

AttributeValue
Version5.0.0
Created2025-12-29
Updated2026-02-22
AuthorClaude Code
StatusActive
PartIV - Backend
Chapter11 of 32

This chapter is part of the POS Blueprint Book. All content is self-contained.