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.