Appendix E: Code Templates
Version: 1.0.0 Last Updated: December 29, 2025 Language: C# (.NET 8.0)
Overview
This appendix contains copy-paste code templates for common patterns in the POS Platform. All templates follow the established architecture and coding standards.
Table of Contents
- Entity Template
- Repository Interface Template
- Repository Implementation Template
- Service Interface Template
- Service Implementation Template
- Controller Template
- DTO Templates
- Validator Template
- Event Handler Template
- Integration Test Template
- Unit Test Template
- Domain Event Template
1. Entity Template
// File: src/POS.Core/Entities/Product.cs
using System;
using System.Collections.Generic;
namespace POS.Core.Entities;
/// <summary>
/// Represents a product in the catalog.
/// </summary>
public class Product : BaseEntity, IAuditableEntity, ITenantEntity
{
/// <summary>
/// Gets or sets the tenant identifier.
/// </summary>
public Guid TenantId { get; set; }
/// <summary>
/// Gets or sets the SKU (Stock Keeping Unit).
/// </summary>
public required string Sku { get; set; }
/// <summary>
/// Gets or sets the product name.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Gets or sets the product description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the category identifier.
/// </summary>
public Guid? CategoryId { get; set; }
/// <summary>
/// Gets or sets the vendor identifier.
/// </summary>
public Guid? VendorId { get; set; }
/// <summary>
/// Gets or sets the base price.
/// </summary>
public decimal BasePrice { get; set; }
/// <summary>
/// Gets or sets the cost price.
/// </summary>
public decimal Cost { get; set; }
/// <summary>
/// Gets or sets the product status.
/// </summary>
public ProductStatus Status { get; set; } = ProductStatus.Active;
/// <summary>
/// Gets or sets the Shopify product ID for integration.
/// </summary>
public string? ShopifyProductId { get; set; }
// Navigation properties
public virtual Category? Category { get; set; }
public virtual Vendor? Vendor { get; set; }
public virtual ICollection<ProductVariant> Variants { get; set; } = new List<ProductVariant>();
public virtual ICollection<ProductImage> Images { get; set; } = new List<ProductImage>();
// Audit properties
public DateTime CreatedAt { get; set; }
public Guid? CreatedBy { get; set; }
public DateTime? UpdatedAt { get; set; }
public Guid? UpdatedBy { get; set; }
}
/// <summary>
/// Product status enumeration.
/// </summary>
public enum ProductStatus
{
Draft,
Active,
Discontinued,
Archived
}
2. Repository Interface Template
// File: src/POS.Core/Interfaces/Repositories/IProductRepository.cs
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using POS.Core.Entities;
namespace POS.Core.Interfaces.Repositories;
/// <summary>
/// Repository interface for Product entity operations.
/// </summary>
public interface IProductRepository : IRepository<Product>
{
/// <summary>
/// Gets a product by SKU.
/// </summary>
/// <param name="sku">The SKU to search for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The product if found, null otherwise.</returns>
Task<Product?> GetBySkuAsync(string sku, CancellationToken cancellationToken = default);
/// <summary>
/// Gets products by category.
/// </summary>
/// <param name="categoryId">The category identifier.</param>
/// <param name="includeVariants">Whether to include variants.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of products in the category.</returns>
Task<IReadOnlyList<Product>> GetByCategoryAsync(
Guid categoryId,
bool includeVariants = false,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets products by vendor.
/// </summary>
/// <param name="vendorId">The vendor identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of products from the vendor.</returns>
Task<IReadOnlyList<Product>> GetByVendorAsync(
Guid vendorId,
CancellationToken cancellationToken = default);
/// <summary>
/// Searches products by name or SKU.
/// </summary>
/// <param name="searchTerm">The search term.</param>
/// <param name="page">Page number (1-based).</param>
/// <param name="pageSize">Items per page.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Paginated list of matching products.</returns>
Task<PagedResult<Product>> SearchAsync(
string searchTerm,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a SKU exists.
/// </summary>
/// <param name="sku">The SKU to check.</param>
/// <param name="excludeProductId">Product ID to exclude from check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if SKU exists, false otherwise.</returns>
Task<bool> SkuExistsAsync(
string sku,
Guid? excludeProductId = null,
CancellationToken cancellationToken = default);
}
3. Repository Implementation Template
// File: src/POS.Infrastructure/Repositories/ProductRepository.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using POS.Core.Entities;
using POS.Core.Interfaces.Repositories;
using POS.Infrastructure.Data;
namespace POS.Infrastructure.Repositories;
/// <summary>
/// Repository implementation for Product entity.
/// </summary>
public class ProductRepository : Repository<Product>, IProductRepository
{
public ProductRepository(ApplicationDbContext context) : base(context)
{
}
/// <inheritdoc />
public async Task<Product?> GetBySkuAsync(
string sku,
CancellationToken cancellationToken = default)
{
return await _dbSet
.Include(p => p.Variants)
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Sku == sku, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Product>> GetByCategoryAsync(
Guid categoryId,
bool includeVariants = false,
CancellationToken cancellationToken = default)
{
var query = _dbSet
.Where(p => p.CategoryId == categoryId)
.Where(p => p.Status == ProductStatus.Active);
if (includeVariants)
{
query = query.Include(p => p.Variants);
}
return await query
.OrderBy(p => p.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Product>> GetByVendorAsync(
Guid vendorId,
CancellationToken cancellationToken = default)
{
return await _dbSet
.Where(p => p.VendorId == vendorId)
.Include(p => p.Variants)
.OrderBy(p => p.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<PagedResult<Product>> SearchAsync(
string searchTerm,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
var query = _dbSet
.Where(p => p.Status == ProductStatus.Active)
.Where(p => EF.Functions.ILike(p.Name, $"%{searchTerm}%") ||
EF.Functions.ILike(p.Sku, $"%{searchTerm}%"));
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.Include(p => p.Variants)
.OrderBy(p => p.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return new PagedResult<Product>(items, totalCount, page, pageSize);
}
/// <inheritdoc />
public async Task<bool> SkuExistsAsync(
string sku,
Guid? excludeProductId = null,
CancellationToken cancellationToken = default)
{
var query = _dbSet.Where(p => p.Sku == sku);
if (excludeProductId.HasValue)
{
query = query.Where(p => p.Id != excludeProductId.Value);
}
return await query.AnyAsync(cancellationToken);
}
}
4. Service Interface Template
// File: src/POS.Core/Interfaces/Services/IProductService.cs
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using POS.Core.DTOs;
namespace POS.Core.Interfaces.Services;
/// <summary>
/// Service interface for product operations.
/// </summary>
public interface IProductService
{
/// <summary>
/// Gets a product by ID.
/// </summary>
Task<ProductDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a product by SKU.
/// </summary>
Task<ProductDto?> GetBySkuAsync(string sku, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all products with optional filtering.
/// </summary>
Task<PagedResult<ProductDto>> GetAllAsync(
ProductFilterDto filter,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new product.
/// </summary>
Task<ProductDto> CreateAsync(
CreateProductDto dto,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing product.
/// </summary>
Task<ProductDto> UpdateAsync(
Guid id,
UpdateProductDto dto,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a product (soft delete).
/// </summary>
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a variant to a product.
/// </summary>
Task<ProductVariantDto> AddVariantAsync(
Guid productId,
CreateVariantDto dto,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a product variant.
/// </summary>
Task<ProductVariantDto> UpdateVariantAsync(
Guid variantId,
UpdateVariantDto dto,
CancellationToken cancellationToken = default);
/// <summary>
/// Searches products.
/// </summary>
Task<PagedResult<ProductDto>> SearchAsync(
string searchTerm,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default);
}
5. Service Implementation Template
// File: src/POS.Application/Services/ProductService.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using FluentValidation;
using Microsoft.Extensions.Logging;
using POS.Core.DTOs;
using POS.Core.Entities;
using POS.Core.Exceptions;
using POS.Core.Interfaces.Repositories;
using POS.Core.Interfaces.Services;
namespace POS.Application.Services;
/// <summary>
/// Service implementation for product operations.
/// </summary>
public class ProductService : IProductService
{
private readonly IProductRepository _productRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly IValidator<CreateProductDto> _createValidator;
private readonly IValidator<UpdateProductDto> _updateValidator;
private readonly ILogger<ProductService> _logger;
private readonly IDomainEventDispatcher _eventDispatcher;
public ProductService(
IProductRepository productRepository,
IUnitOfWork unitOfWork,
IMapper mapper,
IValidator<CreateProductDto> createValidator,
IValidator<UpdateProductDto> updateValidator,
ILogger<ProductService> logger,
IDomainEventDispatcher eventDispatcher)
{
_productRepository = productRepository;
_unitOfWork = unitOfWork;
_mapper = mapper;
_createValidator = createValidator;
_updateValidator = updateValidator;
_logger = logger;
_eventDispatcher = eventDispatcher;
}
/// <inheritdoc />
public async Task<ProductDto?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
var product = await _productRepository.GetByIdAsync(id, cancellationToken);
return product is null ? null : _mapper.Map<ProductDto>(product);
}
/// <inheritdoc />
public async Task<ProductDto?> GetBySkuAsync(
string sku,
CancellationToken cancellationToken = default)
{
var product = await _productRepository.GetBySkuAsync(sku, cancellationToken);
return product is null ? null : _mapper.Map<ProductDto>(product);
}
/// <inheritdoc />
public async Task<PagedResult<ProductDto>> GetAllAsync(
ProductFilterDto filter,
CancellationToken cancellationToken = default)
{
var result = await _productRepository.SearchAsync(
filter.SearchTerm ?? "",
filter.Page,
filter.PageSize,
cancellationToken);
return new PagedResult<ProductDto>(
_mapper.Map<List<ProductDto>>(result.Items),
result.TotalCount,
result.Page,
result.PageSize);
}
/// <inheritdoc />
public async Task<ProductDto> CreateAsync(
CreateProductDto dto,
CancellationToken cancellationToken = default)
{
// Validate
var validationResult = await _createValidator.ValidateAsync(dto, cancellationToken);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
// Check SKU uniqueness
if (await _productRepository.SkuExistsAsync(dto.Sku, null, cancellationToken))
{
throw new BusinessException($"SKU '{dto.Sku}' already exists.");
}
// Create entity
var product = _mapper.Map<Product>(dto);
product.Status = ProductStatus.Active;
await _productRepository.AddAsync(product, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Product created: {ProductId} - {Sku}", product.Id, product.Sku);
// Dispatch domain event
await _eventDispatcher.DispatchAsync(new ProductCreatedEvent(product.Id, product.Sku));
return _mapper.Map<ProductDto>(product);
}
/// <inheritdoc />
public async Task<ProductDto> UpdateAsync(
Guid id,
UpdateProductDto dto,
CancellationToken cancellationToken = default)
{
// Validate
var validationResult = await _updateValidator.ValidateAsync(dto, cancellationToken);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
// Get existing product
var product = await _productRepository.GetByIdAsync(id, cancellationToken);
if (product is null)
{
throw new NotFoundException($"Product with ID {id} not found.");
}
// Check SKU uniqueness if changed
if (dto.Sku != product.Sku &&
await _productRepository.SkuExistsAsync(dto.Sku, id, cancellationToken))
{
throw new BusinessException($"SKU '{dto.Sku}' already exists.");
}
// Update entity
_mapper.Map(dto, product);
_productRepository.Update(product);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Product updated: {ProductId} - {Sku}", product.Id, product.Sku);
return _mapper.Map<ProductDto>(product);
}
/// <inheritdoc />
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
var product = await _productRepository.GetByIdAsync(id, cancellationToken);
if (product is null)
{
throw new NotFoundException($"Product with ID {id} not found.");
}
// Soft delete - change status
product.Status = ProductStatus.Archived;
_productRepository.Update(product);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Product archived: {ProductId} - {Sku}", product.Id, product.Sku);
}
/// <inheritdoc />
public async Task<ProductVariantDto> AddVariantAsync(
Guid productId,
CreateVariantDto dto,
CancellationToken cancellationToken = default)
{
var product = await _productRepository.GetByIdAsync(productId, cancellationToken);
if (product is null)
{
throw new NotFoundException($"Product with ID {productId} not found.");
}
var variant = _mapper.Map<ProductVariant>(dto);
variant.ProductId = productId;
product.Variants.Add(variant);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Variant added: {VariantId} to Product {ProductId}",
variant.Id, productId);
return _mapper.Map<ProductVariantDto>(variant);
}
/// <inheritdoc />
public async Task<ProductVariantDto> UpdateVariantAsync(
Guid variantId,
UpdateVariantDto dto,
CancellationToken cancellationToken = default)
{
// Implementation similar to UpdateAsync
throw new NotImplementedException();
}
/// <inheritdoc />
public async Task<PagedResult<ProductDto>> SearchAsync(
string searchTerm,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
var result = await _productRepository.SearchAsync(
searchTerm, page, pageSize, cancellationToken);
return new PagedResult<ProductDto>(
_mapper.Map<List<ProductDto>>(result.Items),
result.TotalCount,
result.Page,
result.PageSize);
}
}
6. Controller Template
// File: src/POS.API/Controllers/ProductsController.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using POS.Core.DTOs;
using POS.Core.Interfaces.Services;
namespace POS.API.Controllers;
/// <summary>
/// API controller for product operations.
/// </summary>
[ApiController]
[Route("api/v1/[controller]")]
[Authorize]
[Produces("application/json")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
private readonly ILogger<ProductsController> _logger;
public ProductsController(
IProductService productService,
ILogger<ProductsController> logger)
{
_productService = productService;
_logger = logger;
}
/// <summary>
/// Gets all products with optional filtering.
/// </summary>
/// <param name="filter">Filter parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Paginated list of products.</returns>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<ProductDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<ProductDto>>> GetAll(
[FromQuery] ProductFilterDto filter,
CancellationToken cancellationToken)
{
var result = await _productService.GetAllAsync(filter, cancellationToken);
return Ok(result);
}
/// <summary>
/// Gets a product by ID.
/// </summary>
/// <param name="id">The product ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The product if found.</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> GetById(
Guid id,
CancellationToken cancellationToken)
{
var product = await _productService.GetByIdAsync(id, cancellationToken);
if (product is null)
{
return NotFound();
}
return Ok(product);
}
/// <summary>
/// Gets a product by SKU.
/// </summary>
/// <param name="sku">The product SKU.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The product if found.</returns>
[HttpGet("sku/{sku}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> GetBySku(
string sku,
CancellationToken cancellationToken)
{
var product = await _productService.GetBySkuAsync(sku, cancellationToken);
if (product is null)
{
return NotFound();
}
return Ok(product);
}
/// <summary>
/// Creates a new product.
/// </summary>
/// <param name="dto">The product data.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created product.</returns>
[HttpPost]
[Authorize(Policy = "CanManageProducts")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<ActionResult<ProductDto>> Create(
[FromBody] CreateProductDto dto,
CancellationToken cancellationToken)
{
var product = await _productService.CreateAsync(dto, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
/// <summary>
/// Updates an existing product.
/// </summary>
/// <param name="id">The product ID.</param>
/// <param name="dto">The updated product data.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated product.</returns>
[HttpPut("{id:guid}")]
[Authorize(Policy = "CanManageProducts")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> Update(
Guid id,
[FromBody] UpdateProductDto dto,
CancellationToken cancellationToken)
{
var product = await _productService.UpdateAsync(id, dto, cancellationToken);
return Ok(product);
}
/// <summary>
/// Deletes a product (soft delete).
/// </summary>
/// <param name="id">The product ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>No content on success.</returns>
[HttpDelete("{id:guid}")]
[Authorize(Policy = "CanManageProducts")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(
Guid id,
CancellationToken cancellationToken)
{
await _productService.DeleteAsync(id, cancellationToken);
return NoContent();
}
/// <summary>
/// Adds a variant to a product.
/// </summary>
/// <param name="id">The product ID.</param>
/// <param name="dto">The variant data.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created variant.</returns>
[HttpPost("{id:guid}/variants")]
[Authorize(Policy = "CanManageProducts")]
[ProducesResponseType(typeof(ProductVariantDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductVariantDto>> AddVariant(
Guid id,
[FromBody] CreateVariantDto dto,
CancellationToken cancellationToken)
{
var variant = await _productService.AddVariantAsync(id, dto, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id }, variant);
}
/// <summary>
/// Searches products by name or SKU.
/// </summary>
/// <param name="q">Search query.</param>
/// <param name="page">Page number.</param>
/// <param name="pageSize">Page size.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Matching products.</returns>
[HttpGet("search")]
[ProducesResponseType(typeof(PagedResult<ProductDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<ProductDto>>> Search(
[FromQuery] string q,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var result = await _productService.SearchAsync(q, page, pageSize, cancellationToken);
return Ok(result);
}
}
7. DTO Templates
// File: src/POS.Core/DTOs/ProductDtos.cs
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace POS.Core.DTOs;
/// <summary>
/// Product data transfer object.
/// </summary>
public record ProductDto
{
public Guid Id { get; init; }
public required string Sku { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public Guid? CategoryId { get; init; }
public string? CategoryName { get; init; }
public Guid? VendorId { get; init; }
public string? VendorName { get; init; }
public decimal BasePrice { get; init; }
public decimal Cost { get; init; }
public string Status { get; init; } = "Active";
public List<ProductVariantDto> Variants { get; init; } = new();
public List<ProductImageDto> Images { get; init; } = new();
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
}
/// <summary>
/// Product variant data transfer object.
/// </summary>
public record ProductVariantDto
{
public Guid Id { get; init; }
public required string Sku { get; init; }
public string? Barcode { get; init; }
public Dictionary<string, string> Options { get; init; } = new();
public decimal Price { get; init; }
public decimal? CompareAtPrice { get; init; }
public decimal Cost { get; init; }
public bool IsActive { get; init; }
}
/// <summary>
/// Product image data transfer object.
/// </summary>
public record ProductImageDto
{
public Guid Id { get; init; }
public required string Url { get; init; }
public string? AltText { get; init; }
public int Position { get; init; }
}
/// <summary>
/// DTO for creating a new product.
/// </summary>
public record CreateProductDto
{
[Required]
[StringLength(50)]
public required string Sku { get; init; }
[Required]
[StringLength(255)]
public required string Name { get; init; }
[StringLength(2000)]
public string? Description { get; init; }
public Guid? CategoryId { get; init; }
public Guid? VendorId { get; init; }
[Range(0, 999999.99)]
public decimal BasePrice { get; init; }
[Range(0, 999999.99)]
public decimal Cost { get; init; }
public List<CreateVariantDto>? Variants { get; init; }
}
/// <summary>
/// DTO for updating an existing product.
/// </summary>
public record UpdateProductDto
{
[Required]
[StringLength(50)]
public required string Sku { get; init; }
[Required]
[StringLength(255)]
public required string Name { get; init; }
[StringLength(2000)]
public string? Description { get; init; }
public Guid? CategoryId { get; init; }
public Guid? VendorId { get; init; }
[Range(0, 999999.99)]
public decimal BasePrice { get; init; }
[Range(0, 999999.99)]
public decimal Cost { get; init; }
public string? Status { get; init; }
}
/// <summary>
/// DTO for creating a product variant.
/// </summary>
public record CreateVariantDto
{
[Required]
[StringLength(50)]
public required string Sku { get; init; }
[StringLength(50)]
public string? Barcode { get; init; }
public Dictionary<string, string> Options { get; init; } = new();
[Range(0, 999999.99)]
public decimal Price { get; init; }
[Range(0, 999999.99)]
public decimal? CompareAtPrice { get; init; }
[Range(0, 999999.99)]
public decimal Cost { get; init; }
}
/// <summary>
/// DTO for updating a product variant.
/// </summary>
public record UpdateVariantDto
{
[Required]
[StringLength(50)]
public required string Sku { get; init; }
[StringLength(50)]
public string? Barcode { get; init; }
public Dictionary<string, string>? Options { get; init; }
[Range(0, 999999.99)]
public decimal? Price { get; init; }
[Range(0, 999999.99)]
public decimal? CompareAtPrice { get; init; }
[Range(0, 999999.99)]
public decimal? Cost { get; init; }
public bool? IsActive { get; init; }
}
/// <summary>
/// Product filter DTO.
/// </summary>
public record ProductFilterDto
{
public string? SearchTerm { get; init; }
public Guid? CategoryId { get; init; }
public Guid? VendorId { get; init; }
public string? Status { get; init; }
[Range(1, int.MaxValue)]
public int Page { get; init; } = 1;
[Range(1, 100)]
public int PageSize { get; init; } = 20;
}
/// <summary>
/// Paginated result wrapper.
/// </summary>
public record PagedResult<T>
{
public IReadOnlyList<T> Items { get; init; }
public int TotalCount { get; init; }
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasNextPage => Page < TotalPages;
public bool HasPreviousPage => Page > 1;
public PagedResult(IReadOnlyList<T> items, int totalCount, int page, int pageSize)
{
Items = items;
TotalCount = totalCount;
Page = page;
PageSize = pageSize;
}
}
8. Validator Template
// File: src/POS.Application/Validators/CreateProductValidator.cs
using FluentValidation;
using POS.Core.DTOs;
using POS.Core.Interfaces.Repositories;
namespace POS.Application.Validators;
/// <summary>
/// Validator for CreateProductDto.
/// </summary>
public class CreateProductValidator : AbstractValidator<CreateProductDto>
{
private readonly IProductRepository _productRepository;
private readonly ICategoryRepository _categoryRepository;
public CreateProductValidator(
IProductRepository productRepository,
ICategoryRepository categoryRepository)
{
_productRepository = productRepository;
_categoryRepository = categoryRepository;
RuleFor(x => x.Sku)
.NotEmpty()
.WithMessage("SKU is required.")
.MaximumLength(50)
.WithMessage("SKU cannot exceed 50 characters.")
.Matches(@"^[A-Z0-9\-]+$")
.WithMessage("SKU must contain only uppercase letters, numbers, and hyphens.")
.MustAsync(BeUniqueSku)
.WithMessage("SKU already exists.");
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Product name is required.")
.MaximumLength(255)
.WithMessage("Product name cannot exceed 255 characters.");
RuleFor(x => x.Description)
.MaximumLength(2000)
.WithMessage("Description cannot exceed 2000 characters.");
RuleFor(x => x.BasePrice)
.GreaterThanOrEqualTo(0)
.WithMessage("Base price must be zero or greater.");
RuleFor(x => x.Cost)
.GreaterThanOrEqualTo(0)
.WithMessage("Cost must be zero or greater.")
.LessThanOrEqualTo(x => x.BasePrice)
.When(x => x.BasePrice > 0)
.WithMessage("Cost should not exceed the base price.");
RuleFor(x => x.CategoryId)
.MustAsync(CategoryExists)
.When(x => x.CategoryId.HasValue)
.WithMessage("Category does not exist.");
RuleForEach(x => x.Variants)
.SetValidator(new CreateVariantValidator());
}
private async Task<bool> BeUniqueSku(string sku, CancellationToken cancellationToken)
{
return !await _productRepository.SkuExistsAsync(sku, null, cancellationToken);
}
private async Task<bool> CategoryExists(Guid? categoryId, CancellationToken cancellationToken)
{
if (!categoryId.HasValue) return true;
return await _categoryRepository.ExistsAsync(categoryId.Value, cancellationToken);
}
}
/// <summary>
/// Validator for CreateVariantDto.
/// </summary>
public class CreateVariantValidator : AbstractValidator<CreateVariantDto>
{
public CreateVariantValidator()
{
RuleFor(x => x.Sku)
.NotEmpty()
.WithMessage("Variant SKU is required.")
.MaximumLength(50)
.WithMessage("Variant SKU cannot exceed 50 characters.");
RuleFor(x => x.Barcode)
.MaximumLength(50)
.WithMessage("Barcode cannot exceed 50 characters.")
.Matches(@"^[0-9]*$")
.When(x => !string.IsNullOrEmpty(x.Barcode))
.WithMessage("Barcode must contain only numbers.");
RuleFor(x => x.Price)
.GreaterThanOrEqualTo(0)
.WithMessage("Price must be zero or greater.");
RuleFor(x => x.CompareAtPrice)
.GreaterThan(x => x.Price)
.When(x => x.CompareAtPrice.HasValue)
.WithMessage("Compare at price must be greater than regular price.");
RuleFor(x => x.Cost)
.GreaterThanOrEqualTo(0)
.WithMessage("Cost must be zero or greater.");
}
}
9. Event Handler Template
// File: src/POS.Application/EventHandlers/OrderCompletedEventHandler.cs
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Microsoft.Extensions.Logging;
using POS.Core.Events;
using POS.Core.Interfaces.Services;
namespace POS.Application.EventHandlers;
/// <summary>
/// Handles the OrderCompleted domain event.
/// </summary>
public class OrderCompletedEventHandler : INotificationHandler<OrderCompletedEvent>
{
private readonly IInventoryService _inventoryService;
private readonly ILoyaltyService _loyaltyService;
private readonly IAnalyticsService _analyticsService;
private readonly INotificationService _notificationService;
private readonly ILogger<OrderCompletedEventHandler> _logger;
public OrderCompletedEventHandler(
IInventoryService inventoryService,
ILoyaltyService loyaltyService,
IAnalyticsService analyticsService,
INotificationService notificationService,
ILogger<OrderCompletedEventHandler> logger)
{
_inventoryService = inventoryService;
_loyaltyService = loyaltyService;
_analyticsService = analyticsService;
_notificationService = notificationService;
_logger = logger;
}
/// <summary>
/// Handles the OrderCompleted event.
/// </summary>
public async Task Handle(
OrderCompletedEvent notification,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Processing OrderCompleted event for Order {OrderId}",
notification.OrderId);
try
{
// Commit inventory reservations
await _inventoryService.CommitReservationsAsync(
notification.OrderId,
notification.LineItems,
cancellationToken);
// Award loyalty points if customer attached
if (notification.CustomerId.HasValue)
{
await _loyaltyService.AwardPointsAsync(
notification.CustomerId.Value,
notification.OrderId,
notification.Total,
cancellationToken);
}
// Record analytics
await _analyticsService.RecordSaleAsync(
notification.OrderId,
notification.LocationId,
notification.Total,
notification.LineItems.Count,
cancellationToken);
// Send receipt notification if requested
if (notification.SendReceipt)
{
await _notificationService.SendReceiptAsync(
notification.OrderId,
notification.CustomerEmail,
notification.ReceiptMethod,
cancellationToken);
}
_logger.LogInformation(
"Successfully processed OrderCompleted event for Order {OrderId}",
notification.OrderId);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error processing OrderCompleted event for Order {OrderId}",
notification.OrderId);
// Re-throw to trigger retry logic
throw;
}
}
}
10. Integration Test Template
// File: tests/POS.IntegrationTests/Controllers/ProductsControllerTests.cs
using System;
using System.Net;
using System.Net.Http.Json;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using POS.API;
using POS.Core.DTOs;
using POS.IntegrationTests.Fixtures;
using Xunit;
namespace POS.IntegrationTests.Controllers;
/// <summary>
/// Integration tests for ProductsController.
/// </summary>
[Collection("Database")]
public class ProductsControllerTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
private readonly DatabaseFixture _dbFixture;
public ProductsControllerTests(
WebApplicationFactory<Program> factory,
DatabaseFixture dbFixture)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Configure test database
dbFixture.ConfigureServices(services);
});
});
_client = _factory.CreateClient();
_dbFixture = dbFixture;
}
public async Task InitializeAsync()
{
await _dbFixture.ResetDatabaseAsync();
await AuthenticateAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
private async Task AuthenticateAsync()
{
var loginDto = new { Email = "test@example.com", Password = "Test123!" };
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", loginDto);
var result = await response.Content.ReadFromJsonAsync<LoginResult>();
_client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", result?.Token);
}
[Fact]
public async Task GetAll_ReturnsProducts()
{
// Arrange
await SeedProductsAsync();
// Act
var response = await _client.GetAsync("/api/v1/products");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
result.Should().NotBeNull();
result!.Items.Should().NotBeEmpty();
result.TotalCount.Should().BeGreaterThan(0);
}
[Fact]
public async Task GetById_ExistingProduct_ReturnsProduct()
{
// Arrange
var productId = await CreateTestProductAsync();
// Act
var response = await _client.GetAsync($"/api/v1/products/{productId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var product = await response.Content.ReadFromJsonAsync<ProductDto>();
product.Should().NotBeNull();
product!.Id.Should().Be(productId);
}
[Fact]
public async Task GetById_NonExistingProduct_ReturnsNotFound()
{
// Arrange
var nonExistingId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/v1/products/{nonExistingId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Create_ValidProduct_ReturnsCreated()
{
// Arrange
var createDto = new CreateProductDto
{
Sku = "TEST-001",
Name = "Test Product",
Description = "A test product",
BasePrice = 29.99m,
Cost = 12.50m
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/products", createDto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var product = await response.Content.ReadFromJsonAsync<ProductDto>();
product.Should().NotBeNull();
product!.Sku.Should().Be("TEST-001");
product.Name.Should().Be("Test Product");
// Verify location header
response.Headers.Location.Should().NotBeNull();
}
[Fact]
public async Task Create_DuplicateSku_ReturnsConflict()
{
// Arrange
var createDto = new CreateProductDto
{
Sku = "DUPLICATE-SKU",
Name = "First Product",
BasePrice = 29.99m,
Cost = 12.50m
};
await _client.PostAsJsonAsync("/api/v1/products", createDto);
var duplicateDto = new CreateProductDto
{
Sku = "DUPLICATE-SKU",
Name = "Second Product",
BasePrice = 39.99m,
Cost = 15.00m
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/products", duplicateDto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
}
[Fact]
public async Task Create_InvalidData_ReturnsBadRequest()
{
// Arrange
var createDto = new CreateProductDto
{
Sku = "", // Invalid - empty
Name = "", // Invalid - empty
BasePrice = -10m, // Invalid - negative
Cost = 12.50m
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/products", createDto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Update_ValidData_ReturnsUpdatedProduct()
{
// Arrange
var productId = await CreateTestProductAsync();
var updateDto = new UpdateProductDto
{
Sku = "UPDATED-SKU",
Name = "Updated Product Name",
BasePrice = 39.99m,
Cost = 15.00m
};
// Act
var response = await _client.PutAsJsonAsync(
$"/api/v1/products/{productId}",
updateDto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var product = await response.Content.ReadFromJsonAsync<ProductDto>();
product!.Name.Should().Be("Updated Product Name");
product.BasePrice.Should().Be(39.99m);
}
[Fact]
public async Task Delete_ExistingProduct_ReturnsNoContent()
{
// Arrange
var productId = await CreateTestProductAsync();
// Act
var response = await _client.DeleteAsync($"/api/v1/products/{productId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
// Verify product is soft-deleted
var getResponse = await _client.GetAsync($"/api/v1/products/{productId}");
var product = await getResponse.Content.ReadFromJsonAsync<ProductDto>();
product!.Status.Should().Be("Archived");
}
private async Task SeedProductsAsync()
{
for (int i = 1; i <= 5; i++)
{
var dto = new CreateProductDto
{
Sku = $"SEED-{i:D3}",
Name = $"Seeded Product {i}",
BasePrice = 29.99m,
Cost = 12.50m
};
await _client.PostAsJsonAsync("/api/v1/products", dto);
}
}
private async Task<Guid> CreateTestProductAsync()
{
var dto = new CreateProductDto
{
Sku = $"TEST-{Guid.NewGuid():N}".Substring(0, 20),
Name = "Test Product",
BasePrice = 29.99m,
Cost = 12.50m
};
var response = await _client.PostAsJsonAsync("/api/v1/products", dto);
var product = await response.Content.ReadFromJsonAsync<ProductDto>();
return product!.Id;
}
}
record LoginResult(string Token);
11. Unit Test Template
// File: tests/POS.UnitTests/Services/ProductServiceTests.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Logging;
using Moq;
using POS.Application.Services;
using POS.Core.DTOs;
using POS.Core.Entities;
using POS.Core.Exceptions;
using POS.Core.Interfaces.Repositories;
using POS.Core.Interfaces.Services;
using Xunit;
namespace POS.UnitTests.Services;
/// <summary>
/// Unit tests for ProductService.
/// </summary>
public class ProductServiceTests
{
private readonly Mock<IProductRepository> _productRepositoryMock;
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly Mock<IMapper> _mapperMock;
private readonly Mock<IValidator<CreateProductDto>> _createValidatorMock;
private readonly Mock<IValidator<UpdateProductDto>> _updateValidatorMock;
private readonly Mock<ILogger<ProductService>> _loggerMock;
private readonly Mock<IDomainEventDispatcher> _eventDispatcherMock;
private readonly ProductService _sut;
public ProductServiceTests()
{
_productRepositoryMock = new Mock<IProductRepository>();
_unitOfWorkMock = new Mock<IUnitOfWork>();
_mapperMock = new Mock<IMapper>();
_createValidatorMock = new Mock<IValidator<CreateProductDto>>();
_updateValidatorMock = new Mock<IValidator<UpdateProductDto>>();
_loggerMock = new Mock<ILogger<ProductService>>();
_eventDispatcherMock = new Mock<IDomainEventDispatcher>();
_sut = new ProductService(
_productRepositoryMock.Object,
_unitOfWorkMock.Object,
_mapperMock.Object,
_createValidatorMock.Object,
_updateValidatorMock.Object,
_loggerMock.Object,
_eventDispatcherMock.Object);
}
[Fact]
public async Task GetByIdAsync_ExistingProduct_ReturnsProductDto()
{
// Arrange
var productId = Guid.NewGuid();
var product = new Product
{
Id = productId,
Sku = "TEST-001",
Name = "Test Product"
};
var productDto = new ProductDto
{
Id = productId,
Sku = "TEST-001",
Name = "Test Product"
};
_productRepositoryMock
.Setup(x => x.GetByIdAsync(productId, It.IsAny<CancellationToken>()))
.ReturnsAsync(product);
_mapperMock
.Setup(x => x.Map<ProductDto>(product))
.Returns(productDto);
// Act
var result = await _sut.GetByIdAsync(productId);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(productId);
result.Sku.Should().Be("TEST-001");
}
[Fact]
public async Task GetByIdAsync_NonExistingProduct_ReturnsNull()
{
// Arrange
var productId = Guid.NewGuid();
_productRepositoryMock
.Setup(x => x.GetByIdAsync(productId, It.IsAny<CancellationToken>()))
.ReturnsAsync((Product?)null);
// Act
var result = await _sut.GetByIdAsync(productId);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task CreateAsync_ValidDto_CreatesAndReturnsProduct()
{
// Arrange
var createDto = new CreateProductDto
{
Sku = "NEW-001",
Name = "New Product",
BasePrice = 29.99m,
Cost = 12.50m
};
var product = new Product
{
Id = Guid.NewGuid(),
Sku = "NEW-001",
Name = "New Product"
};
var productDto = new ProductDto
{
Id = product.Id,
Sku = "NEW-001",
Name = "New Product"
};
_createValidatorMock
.Setup(x => x.ValidateAsync(createDto, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ValidationResult());
_productRepositoryMock
.Setup(x => x.SkuExistsAsync("NEW-001", null, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mapperMock
.Setup(x => x.Map<Product>(createDto))
.Returns(product);
_mapperMock
.Setup(x => x.Map<ProductDto>(product))
.Returns(productDto);
// Act
var result = await _sut.CreateAsync(createDto);
// Assert
result.Should().NotBeNull();
result.Sku.Should().Be("NEW-001");
_productRepositoryMock.Verify(
x => x.AddAsync(It.IsAny<Product>(), It.IsAny<CancellationToken>()),
Times.Once);
_unitOfWorkMock.Verify(
x => x.SaveChangesAsync(It.IsAny<CancellationToken>()),
Times.Once);
_eventDispatcherMock.Verify(
x => x.DispatchAsync(It.IsAny<ProductCreatedEvent>()),
Times.Once);
}
[Fact]
public async Task CreateAsync_DuplicateSku_ThrowsBusinessException()
{
// Arrange
var createDto = new CreateProductDto
{
Sku = "EXISTING-SKU",
Name = "New Product",
BasePrice = 29.99m,
Cost = 12.50m
};
_createValidatorMock
.Setup(x => x.ValidateAsync(createDto, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ValidationResult());
_productRepositoryMock
.Setup(x => x.SkuExistsAsync("EXISTING-SKU", null, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act
var act = () => _sut.CreateAsync(createDto);
// Assert
await act.Should().ThrowAsync<BusinessException>()
.WithMessage("*EXISTING-SKU*already exists*");
}
[Fact]
public async Task CreateAsync_InvalidDto_ThrowsValidationException()
{
// Arrange
var createDto = new CreateProductDto
{
Sku = "",
Name = "",
BasePrice = -10m,
Cost = 12.50m
};
var validationResult = new ValidationResult(new[]
{
new ValidationFailure("Sku", "SKU is required."),
new ValidationFailure("Name", "Name is required.")
});
_createValidatorMock
.Setup(x => x.ValidateAsync(createDto, It.IsAny<CancellationToken>()))
.ReturnsAsync(validationResult);
// Act
var act = () => _sut.CreateAsync(createDto);
// Assert
await act.Should().ThrowAsync<ValidationException>();
}
[Fact]
public async Task UpdateAsync_ExistingProduct_UpdatesAndReturnsProduct()
{
// Arrange
var productId = Guid.NewGuid();
var updateDto = new UpdateProductDto
{
Sku = "UPDATED-SKU",
Name = "Updated Name",
BasePrice = 39.99m,
Cost = 15.00m
};
var existingProduct = new Product
{
Id = productId,
Sku = "OLD-SKU",
Name = "Old Name"
};
var updatedProductDto = new ProductDto
{
Id = productId,
Sku = "UPDATED-SKU",
Name = "Updated Name"
};
_updateValidatorMock
.Setup(x => x.ValidateAsync(updateDto, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ValidationResult());
_productRepositoryMock
.Setup(x => x.GetByIdAsync(productId, It.IsAny<CancellationToken>()))
.ReturnsAsync(existingProduct);
_productRepositoryMock
.Setup(x => x.SkuExistsAsync("UPDATED-SKU", productId, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mapperMock
.Setup(x => x.Map<ProductDto>(existingProduct))
.Returns(updatedProductDto);
// Act
var result = await _sut.UpdateAsync(productId, updateDto);
// Assert
result.Should().NotBeNull();
result.Sku.Should().Be("UPDATED-SKU");
_productRepositoryMock.Verify(
x => x.Update(It.IsAny<Product>()),
Times.Once);
_unitOfWorkMock.Verify(
x => x.SaveChangesAsync(It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task UpdateAsync_NonExistingProduct_ThrowsNotFoundException()
{
// Arrange
var productId = Guid.NewGuid();
var updateDto = new UpdateProductDto
{
Sku = "UPDATED-SKU",
Name = "Updated Name",
BasePrice = 39.99m,
Cost = 15.00m
};
_updateValidatorMock
.Setup(x => x.ValidateAsync(updateDto, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ValidationResult());
_productRepositoryMock
.Setup(x => x.GetByIdAsync(productId, It.IsAny<CancellationToken>()))
.ReturnsAsync((Product?)null);
// Act
var act = () => _sut.UpdateAsync(productId, updateDto);
// Assert
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage($"*{productId}*not found*");
}
[Fact]
public async Task DeleteAsync_ExistingProduct_SoftDeletesProduct()
{
// Arrange
var productId = Guid.NewGuid();
var product = new Product
{
Id = productId,
Sku = "TO-DELETE",
Name = "Product to Delete",
Status = ProductStatus.Active
};
_productRepositoryMock
.Setup(x => x.GetByIdAsync(productId, It.IsAny<CancellationToken>()))
.ReturnsAsync(product);
// Act
await _sut.DeleteAsync(productId);
// Assert
product.Status.Should().Be(ProductStatus.Archived);
_productRepositoryMock.Verify(
x => x.Update(product),
Times.Once);
_unitOfWorkMock.Verify(
x => x.SaveChangesAsync(It.IsAny<CancellationToken>()),
Times.Once);
}
}
12. Domain Event Template
// File: src/POS.Core/Events/OrderCompletedEvent.cs
using System;
using System.Collections.Generic;
using MediatR;
namespace POS.Core.Events;
/// <summary>
/// Domain event raised when an order is completed.
/// </summary>
public record OrderCompletedEvent : INotification
{
/// <summary>
/// Gets the event ID.
/// </summary>
public Guid EventId { get; init; } = Guid.NewGuid();
/// <summary>
/// Gets the timestamp when the event occurred.
/// </summary>
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
/// <summary>
/// Gets the order ID.
/// </summary>
public required Guid OrderId { get; init; }
/// <summary>
/// Gets the order number.
/// </summary>
public required string OrderNumber { get; init; }
/// <summary>
/// Gets the receipt number.
/// </summary>
public required string ReceiptNumber { get; init; }
/// <summary>
/// Gets the location ID.
/// </summary>
public required Guid LocationId { get; init; }
/// <summary>
/// Gets the register ID.
/// </summary>
public Guid? RegisterId { get; init; }
/// <summary>
/// Gets the customer ID.
/// </summary>
public Guid? CustomerId { get; init; }
/// <summary>
/// Gets the customer email.
/// </summary>
public string? CustomerEmail { get; init; }
/// <summary>
/// Gets the line items.
/// </summary>
public required IReadOnlyList<OrderLineItemEvent> LineItems { get; init; }
/// <summary>
/// Gets the payment details.
/// </summary>
public required IReadOnlyList<PaymentEvent> Payments { get; init; }
/// <summary>
/// Gets the subtotal.
/// </summary>
public decimal Subtotal { get; init; }
/// <summary>
/// Gets the discount total.
/// </summary>
public decimal DiscountTotal { get; init; }
/// <summary>
/// Gets the tax total.
/// </summary>
public decimal TaxTotal { get; init; }
/// <summary>
/// Gets the order total.
/// </summary>
public required decimal Total { get; init; }
/// <summary>
/// Gets the loyalty points earned.
/// </summary>
public int LoyaltyPointsEarned { get; init; }
/// <summary>
/// Gets whether to send receipt.
/// </summary>
public bool SendReceipt { get; init; }
/// <summary>
/// Gets the receipt delivery method.
/// </summary>
public string? ReceiptMethod { get; init; }
/// <summary>
/// Gets the user who completed the order.
/// </summary>
public required Guid CompletedBy { get; init; }
/// <summary>
/// Gets the shift ID.
/// </summary>
public Guid? ShiftId { get; init; }
}
/// <summary>
/// Order line item event data.
/// </summary>
public record OrderLineItemEvent
{
public required Guid LineItemId { get; init; }
public required Guid VariantId { get; init; }
public required string Sku { get; init; }
public required string Name { get; init; }
public required int Quantity { get; init; }
public required decimal UnitPrice { get; init; }
public decimal DiscountAmount { get; init; }
public decimal TaxAmount { get; init; }
public required decimal LineTotal { get; init; }
public decimal Cost { get; init; }
}
/// <summary>
/// Payment event data.
/// </summary>
public record PaymentEvent
{
public required Guid PaymentId { get; init; }
public required string Method { get; init; }
public required decimal Amount { get; init; }
public string? AuthorizationCode { get; init; }
public string? LastFour { get; init; }
}
Usage Notes
-
Entity Template: Inherit from
BaseEntityand implement tenant/audit interfaces as needed. -
Repository Interface: Define only operations specific to the entity; generic CRUD is in
IRepository<T>. -
Repository Implementation: Use Entity Framework Core’s
DbSetand LINQ for queries. -
Service Interface: Keep it focused on business operations, not CRUD.
-
Service Implementation: Handle validation, business rules, and coordinate between repositories.
-
Controller Template: Use
[FromBody]for complex objects,[FromQuery]for filters. -
DTOs: Use records for immutability; separate Create/Update/Response DTOs.
-
Validators: Use FluentValidation with async rules for database checks.
-
Event Handlers: Handle one event type per handler; keep handlers focused.
-
Integration Tests: Use
WebApplicationFactoryand test against real database. -
Unit Tests: Use Moq for dependencies; test business logic in isolation.
-
Domain Events: Use MediatR
INotification; include all relevant data in the event.
These templates provide the foundation for consistent, maintainable code across the POS Platform.