Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 16: 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.


16.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          │
└─────────────────────────────────────────────────────────────────┘

16.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/

16.3 Service Interfaces

16.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);
}

16.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);
}

16.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);
}

16.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);
}

16.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();
    }
}

16.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
}

16.6 Event Publishing Pattern

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

public interface IEventPublisher
{
    Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default)
        where TEvent : IDomainEvent;

    Task PublishManyAsync<TEvent>(IEnumerable<TEvent> events, CancellationToken ct = default)
        where TEvent : IDomainEvent;
}

// File: src/POS.Infrastructure/Messaging/EventPublisher.cs
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using POS.Api.Hubs;

namespace POS.Infrastructure.Messaging;

public class EventPublisher : IEventPublisher
{
    private readonly IPublishEndpoint _publishEndpoint;
    private readonly IHubContext<PosHub, IPosHubClient> _hubContext;
    private readonly ILogger<EventPublisher> _logger;

    public EventPublisher(
        IPublishEndpoint publishEndpoint,
        IHubContext<PosHub, IPosHubClient> hubContext,
        ILogger<EventPublisher> logger)
    {
        _publishEndpoint = publishEndpoint;
        _hubContext = hubContext;
        _logger = logger;
    }

    public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default)
        where TEvent : IDomainEvent
    {
        // Publish to message bus (for background processing)
        await _publishEndpoint.Publish(@event, ct);

        // Publish to SignalR (for real-time UI updates)
        await PublishToSignalRAsync(@event, ct);

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

    private async Task PublishToSignalRAsync<TEvent>(TEvent @event, CancellationToken ct)
        where TEvent : IDomainEvent
    {
        var tenantGroup = $"tenant:{@event.TenantId}";

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

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

16.7 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>();

        // Infrastructure
        services.AddScoped<IUnitOfWork, UnitOfWork>();
        services.AddScoped<IEventPublisher, EventPublisher>();

        // 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
  • Event publishing for real-time updates and background processing

Next: Chapter 17 covers security and authentication patterns.