Project: MicroservicesBase
Target Domain: Point of Sale (POS) Systems
Date: October 7, 2025
Architecture Pattern: Clean Architecture + CQRS + Database-per-Tenant
New to this project? β See SETUP.md for complete setup instructions!
TL;DR:
- Install .NET 8, SQL Server, Docker
- Run
start-redis.bat(Windows) orstart-redis.sh(Mac/Linux) - Create databases (see SETUP.md for SQL scripts)
- Open in Visual Studio and press F5
| Category | Status | Details |
|---|---|---|
| Resilience | β DONE | Circuit breakers, retry, timeout, pool sizing |
| Observability | β DONE | Serilog, health checks, correlation IDs, request logging |
| API Features | β DONE | Versioning, rate limiting, exception handling |
| Audit Logging | β DONE | Background queue, compression, PII masking |
| Load Testing | β DONE | k6 stampede + rate limit tests |
| Cache Warmup | β DONE | Startup pre-warming, cold-start protection, dynamic tenant discovery |
| Mapping | β DONE | Mapperly compile-time mappers, zero-overhead |
| Read Operations | β DONE | GetSaleById with Polly resilience |
| Write Operations | β TODO | CreateSale, VoidSale, RefundSale |
| Authentication | β TODO | JWT, RBAC, tenant validation |
| Idempotency | DB constraints only, need middleware | |
| Migrations | Manual per-tenant, need orchestration |
- Feature Status
- Executive Summary
- Architecture Overview
- Implemented Enterprise Features
- Layer-by-Layer Analysis
- Multi-Tenancy Strategy
- Database Schema Analysis
- Strengths & Best Practices
- Areas for Improvement
- Testing & Load Testing
- Recommendations by Priority
- POS-Specific Considerations
- Conclusion
This is a well-architected multi-tenant microservice template specifically designed for POS systems. The codebase demonstrates strong architectural principles with clean separation of concerns, modern .NET 8 patterns, and appropriate technology choices for high-performance transaction processing.
Key Architectural Decisions:
- β Database-per-tenant isolation (optimal for POS compliance & security)
- β CQRS with MediatR (scalable query/command separation)
- β FastEndpoints (high-performance HTTP API)
- β Dapper + Stored Procedures (optimal read performance)
- β FluentResults (functional error handling)
- β Vertical Slice Architecture (cohesive feature organization)
Current State: Enterprise-grade multi-tenant foundation with comprehensive resilience, observability, and security features. Production-ready for read operations. Ready for write operations and authentication.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β HTTP Client (POS Terminal) β
β Header: X-Tenant: 7ELEVEN β
βββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ
β MicroservicesBase.API β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β TenantResolutionMiddleware β β
β β β’ Extracts tenant from X-Tenant header β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β FastEndpoints β β
β β β’ GET /api/sales/{id} β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ
β MicroservicesBase.Infrastructure β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β MediatR Query Handlers β β
β β β’ GetSaleById.Handler β β
β β β’ Inline FluentValidation β β
β βββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ β
β β
β βββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββ β
β β SalesReadDac (ISalesReadDac) β β
β β β’ Executes Dapper queries β β
β β β’ Calls stored procedures β β
β βββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ β
β β
β βββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββ β
β β MasterTenantConnectionFactory β β
β β β’ Resolves tenant β connection string β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββ΄βββββββββββββββββββββ
β β
ββββββββββββΌβββββββββββ ββββββββββββΌβββββββββββ
β TenantMaster DB β β Sales7Eleven DB β
β βββββββββββββββββ β β βββββββββββββββββ β
β β Tenants β β β β Sales β β
β βββββββββββββββββ β β β SaleItems β β
ββββββββββββββββββββββββ β βββββββββββββββββ β
ββββββββββββββββββββββββ
β
β
ββββββββββΌβββββββββ
β SalesBurgerKing β
β DB β
β ββββββββββββββ β
β β Sales β β
β β SaleItems β β
β ββββββββββββββ β
ββββββββββββββββββ
Purpose: HTTP API endpoint exposure and tenant context resolution
Technology Stack:
- FastEndpoints 7.0.1
- Swagger/OpenAPI
- ASP.NET Core 8.0
- k6 (load and rate limit testing)
Key Components:
// Clean and minimal startup
- FastEndpoints registration
- Infrastructure DI registration
- TenantResolutionMiddleware
- Swagger documentationObservations:
- β Minimal and focused
- β Authorization commented out (ready to implement)
β οΈ No global exception handling middlewareβ οΈ ProblemDetails/folder empty (RFC 7807 not implemented)
Responsibility: Extract tenant identifier from HTTP header
// Reads X-Tenant header
// Sets tenant context via HeaderTenantProviderStrengths:
- β Simple and effective
- β Non-invasive middleware approach
Improvements Needed:
β οΈ No validation if tenant existsβ οΈ No handling of missing/invalid tenantβ οΈ No logging of tenant resolutionβ οΈ Type cast to concreteHeaderTenantProvider(breaks ISP)
Pattern: FastEndpoints vertical slice
public sealed class Endpoint(IMediator mediator) : EndpointWithoutRequest
{
// Route: GET /api/sales/{id:guid}
// Returns: SaleResponse or 404/400
}Strengths:
- β Clean routing with type-safe parameters
- β Proper use of MediatR for query dispatch
- β Error handling with status codes
- β Anonymous access (ready for auth layer)
Improvements:
β οΈ Manual error code mapping (consider result pattern helper)β οΈ Hard-coded error strings ("SALE_NOT_FOUND")
Purpose: Business logic, domain entities, abstractions, and contracts
Dependencies: NONE (pure domain layer β )
public sealed class Sale
{
// Tenant context
- TenantId, StoreId, RegisterId
// Business data
- ReceiptNumber, CreatedAt
- NetTotal, TaxTotal, GrandTotal
- Items (SaleItem collection)
// Factory method
+ Sale.Create(...)
// Behavior
+ ApplyTax(decimal)
- RecomputeTotals() (private encapsulation)
}Strengths:
- β Encapsulation with private setters
- β Factory method prevents invalid construction
- β Computed properties (GrandTotal)
- β Private parameterless constructor for ORM
- β Multi-store, multi-register aware
Observations:
β οΈ Doesn't inherit fromEntitybase class (unused abstraction)β οΈ Moneyvalue object exists but not used (usingdecimaldirectly)β οΈ No discount support yet (commented in code)β οΈ No payment trackingβ οΈ Tax calculation is placeholder (needs strategy pattern)
public sealed class SaleItem
{
- Sku, Qty, UnitPrice
- Subtotal (computed)
+ SaleItem.Create(...)
}Strengths:
- β Immutable once created
- β Computed subtotal property
- β Factory method
Improvements:
β οΈ No product name/descriptionβ οΈ No discount per line itemβ οΈ No tax rate per item (for tax calculation)
public sealed record Money(decimal Amount, string Currency)
{
+ Zero(currency)
+ EnsureNonNegative()
+ Add(Money other)
}Observation:
- β Good value object implementation
- β NOT USED anywhere in domain (consider using or removing)
public abstract class Entity
{
- Id (Guid)
- Equality by Id
}Observation:
- β Proper identity equality
- β NOT USED by
Saleentity (consider using or removing)
public interface ITenantProvider
{
string? Id { get; }
}Purpose: Provide current tenant context (scoped per request)
public interface ITenantConnectionFactory
{
Task<string> GetSqlConnectionStringAsync(string tenantId, CancellationToken ct);
}Purpose: Resolve tenant ID β SQL connection string
public interface ISalesReadDac
{
Task<SaleReadModel?> GetByIdAsync(Guid saleId, CancellationToken ct);
}Purpose: Data access abstraction for read operations
Observation:
- β
Separate read models (
SaleReadModel) from domain entities - β Clean dependency inversion
- StoredProcedureNames.cs (dbo.GetSaleWithItems)
- ErrorMessage.cs (generic error messages)
- AuthIdentityErrorMessage.cs
- UserErrorMessage.cs
- RegexPattern.cs
- File.cs
Strengths:
- β Centralized constants (no magic strings)
- β Stored procedure names in one place
- DecimalPrecisionConverter.cs (critical for financial precision!)
- JsonDateTimeOffsetConverter.cs
- NullToDefaultConverter.cs
- TrimmingConverter.cs
Observation:
- β
DecimalPrecisionConverteris critical for POS (prevents rounding errors)
Purpose: Implementation of abstractions, persistence, queries, commands
Technology Stack:
- Dapper 2.1.66
- MediatR 13.0.0
- FluentValidation 12.0.0
- FluentResults 4.0.0
- Microsoft.Data.SqlClient 6.1.1
public sealed class HeaderTenantProvider : ITenantProvider
{
public string? Id { get; private set; }
public void SetTenant(string tenant) => Id = tenant;
}Registered as: Scoped (per-request)
Strengths:
- β Simple stateful service
- β Scoped lifetime ensures isolation
Improvements:
β οΈ Mutable state (consider making immutable)β οΈ No validation of tenant ID format
public sealed class MasterTenantConnectionFactory : ITenantConnectionFactory
{
// Queries TenantMaster.dbo.Tenants
// Returns connection string for given tenant
// Throws if tenant not found
}Registered as: Singleton
Strengths:
- β Central tenant registry
- β Dynamic tenant resolution (no hardcoded strings)
- β Console logging for debugging
Critical Improvements Needed:
- π΄ NO CACHING (queries master DB on every request!)
β οΈ No circuit breaker for master DB failuresβ οΈ Exception thrown on invalid tenant (should return error)β οΈ No support for inactive tenants
public sealed class SalesReadDac(ITenantConnectionFactory connFactory, ITenantProvider tenant)
: ISalesReadDac
{
// Uses stored procedure: dbo.GetSaleWithItems
// Returns head row + item rows via multi-result set
// Maps to SaleReadModel
}Strengths:
- β Proper dependency injection
- β Async/await throughout
- β Stored procedure for performance
- β
QueryMultipleAsyncfor efficient data retrieval - β
Private mapping records (
_Head,_Item)
Observations:
- β NOLOCK hints (acceptable for POS read queries)
β οΈ No error handling for connection failuresβ οΈ No retry policy for transient errors
public class GetSaleById
{
// 1. Query record
public sealed record Query(Guid SaleId) : IRequest<Result<SaleResponse>>;
// 2. Validator
public sealed class Validator : AbstractValidator<Query>
// 3. Handler
public sealed class Handler : IRequestHandler<Query, Result<SaleResponse>>
}Pattern: All query concerns in one file (vertical slice)
Strengths:
- β Self-contained feature (easy to navigate)
- β Inline validation (no separate pipeline)
- β FluentResults for error handling
- β Error codes for client consumption ("SALE_NOT_FOUND")
- β Mapping from read model β response contract
Observations:
- β Validation is simple (just NotEmpty on ID)
β οΈ Validation errors return array but only first is usedβ οΈ Manual mapping (consider AutoMapper or Mapperly)
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
// MediatR assembly scanning
// Singleton: ITenantConnectionFactory
// Scoped: ISalesReadDac, ITenantProvider
}Strengths:
- β Clean extension method
- β Proper service lifetimes
Status: Placeholder ("Hello, World!")
Future Use Cases:
- Scheduled reports
- Data archiving
- Tenant database migrations
- Batch synchronization
Architecture:
TenantMaster DB
βββ Tenants Table (Name β ConnectionString mapping)
Tenant-Specific DBs (identical schema)
βββ Sales7Eleven
βββ SalesBurgerKing
βββ [Future Tenants...]
β
Complete data isolation (security & compliance)
β
Per-tenant performance tuning (indexes, partitioning)
β
Easy backup/restore per tenant
β
Tenant-specific scaling (can move to different servers)
β
Regulatory compliance (GDPR, PCI-DSS data residency)
β
Schema customization possible per tenant
β Cross-tenant analytics require federation
β Schema migrations must run against all DBs
β Higher operational overhead
β More expensive (more databases to maintain)
- POS systems prioritize data isolation
- Regulatory compliance is critical
- Each store/franchise is fully isolated
CREATE TABLE dbo.Tenants(
Id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
Name NVARCHAR(100) NOT NULL UNIQUE, -- Tenant identifier
ConnectionString NVARCHAR(500) NOT NULL, -- Full SQL connection string
IsActive BIT NOT NULL DEFAULT(1) -- Soft disable
);Observations:
- β GUID primary key (good for distributed systems)
- β
Unique constraint on
Name(prevents duplicates) - β
IsActiveflag for soft deletion - β Connection string stored directly (simple approach)
Security Concerns:
- π΄ Connection strings in plain text (consider encryption at rest)
β οΈ No audit fields (CreatedAt, ModifiedAt)β οΈ No tenant metadata (contact info, billing tier, etc.)
Recommended Fields to Add:
- CreatedAt DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET()
- ModifiedAt DATETIMEOFFSET
- CreatedBy NVARCHAR(100)
- BillingTier NVARCHAR(50) -- for rate limiting/features
- MaxStores INT -- tenant limits
- MaxRegisters INTCREATE TABLE dbo.Sales (
Id UNIQUEIDENTIFIER PRIMARY KEY,
TenantId NVARCHAR(100) NOT NULL, -- Redundant but useful for validation
StoreId NVARCHAR(50) NOT NULL, -- Multi-store support
RegisterId NVARCHAR(50) NOT NULL, -- Multi-register support
ReceiptNumber NVARCHAR(50) NOT NULL, -- Human-readable identifier
CreatedAt DATETIMEOFFSET NOT NULL, -- Timestamp with timezone
NetTotal DECIMAL(18,2) NOT NULL, -- Subtotal before tax
TaxTotal DECIMAL(18,2) NOT NULL, -- Tax amount
GrandTotal DECIMAL(18,2) NOT NULL -- Final total
);
-- Prevent duplicate receipts per register
CREATE UNIQUE INDEX UX_Sales_Receipt ON dbo.Sales(StoreId, RegisterId, ReceiptNumber);Strengths:
- β GUID primary key (globally unique, good for sync)
- β
TenantIdredundancy (defense in depth) - β
StoreId+RegisterId(multi-location ready) - β Unique constraint on receipt number (idempotency)
- β
DATETIMEOFFSETfor timezone awareness - β
DECIMAL(18,2)for financial precision
Missing Fields (Typical POS Requirements):
- Status NVARCHAR(20) -- 'COMPLETED', 'VOIDED', 'REFUNDED'
- CashierId NVARCHAR(50) -- Who processed the sale
- CustomerId UNIQUEIDENTIFIER -- If loyalty program
- PaymentMethod NVARCHAR(50) -- 'CASH', 'CARD', 'MOBILE'
- DiscountTotal DECIMAL(18,2) -- Promotions/coupons
- ChangeDue DECIMAL(18,2) -- For cash transactions
- VoidedAt DATETIMEOFFSET -- Audit trail
- VoidedBy NVARCHAR(50)
- RefundedAt DATETIMEOFFSET
- Notes NVARCHAR(MAX) -- Manager override reasonsRecommended Indexes:
-- Performance indexes for typical POS queries
CREATE INDEX IX_Sales_CreatedAt ON dbo.Sales(CreatedAt DESC);
CREATE INDEX IX_Sales_Store_Created ON dbo.Sales(StoreId, CreatedAt DESC);
CREATE INDEX IX_Sales_Receipt ON dbo.Sales(ReceiptNumber);CREATE TABLE dbo.SaleItems (
Id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
SaleId UNIQUEIDENTIFIER NOT NULL REFERENCES dbo.Sales(Id),
Sku NVARCHAR(50) NOT NULL,
Qty INT NOT NULL,
UnitPrice DECIMAL(18,2) NOT NULL
);Strengths:
- β Foreign key constraint (referential integrity)
- β Simple line item structure
Missing Fields:
- ProductName NVARCHAR(200) -- Snapshot (product names change)
- DiscountAmount DECIMAL(18,2) -- Line-level discounts
- TaxRate DECIMAL(5,2) -- For tax calculation auditing
- TaxAmount DECIMAL(18,2) -- Line-level tax
- Category NVARCHAR(50) -- For reporting
- IsRefunded BIT -- Partial refund trackingRecommended Indexes:
CREATE INDEX IX_SaleItems_SaleId ON dbo.SaleItems(SaleId);
CREATE INDEX IX_SaleItems_Sku ON dbo.SaleItems(Sku); -- Product analyticsCREATE PROCEDURE dbo.GetSaleWithItems @SaleId UNIQUEIDENTIFIER AS
BEGIN
SET NOCOUNT ON;
-- Result set 1: Header
SELECT Id,TenantId,StoreId,RegisterId,ReceiptNumber,CreatedAt,NetTotal,TaxTotal,GrandTotal
FROM dbo.Sales WITH (NOLOCK) WHERE Id = @SaleId;
-- Result set 2: Items
SELECT Sku,Qty,UnitPrice
FROM dbo.SaleItems WITH (NOLOCK) WHERE SaleId = @SaleId;
ENDStrengths:
- β
SET NOCOUNT ON(performance best practice) - β Multiple result sets (efficient single round-trip)
- β
NOLOCKhints (acceptable for read-heavy POS queries)
Observations:
β οΈ NOLOCKcan cause dirty reads (acceptable for POS dashboard, not for financial reports)β οΈ No error handlingβ οΈ No existence check (returns empty set if not found)
Recommended Enhancement:
-- Add parameter validation
IF @SaleId IS NULL
THROW 50001, 'SaleId cannot be null', 1;
-- Consider READ COMMITTED SNAPSHOT isolation instead of NOLOCK-- Tenant: 7-Eleven
INSERT dbo.Sales(Id,TenantId,StoreId,RegisterId,ReceiptNumber,CreatedAt,NetTotal,TaxTotal,GrandTotal)
VALUES ('11111111-1111-1111-1111-111111111111','7ELEVEN','STORE001','REG01','RCP-7E-001',
SYSDATETIMEOFFSET(),20.00,1.20,21.20);
INSERT dbo.SaleItems(SaleId,Sku,Qty,UnitPrice)
VALUES ('11111111-1111-1111-1111-111111111111','SKU-COFFEE',1,10.00),
('11111111-1111-1111-1111-111111111111','SKU-SANDWICH',1,10.00);Observation:
- β Test data present (good for development)
β οΈ Only 7-Eleven has data (no BurgerKing seed data)
- β Clean Architecture - proper layer separation
- β CQRS - read/write separation ready
- β Vertical Slices - features organized cohesively
- β Dependency Inversion - abstractions in Core layer
- β Domain-Driven Design - rich domain models with behavior
- β FastEndpoints - high performance, type-safe routing
- β Dapper - optimal performance for data access
- β Stored Procedures - database-level optimization
- β FluentResults - functional error handling
- β MediatR - decoupled request/response handling
- β Database-per-tenant - correct choice for POS security/compliance
- β Dynamic tenant resolution - no hardcoded connection strings in code
- β Middleware-based tenant context - non-invasive
- β Nullable reference types enabled
- β Record types for DTOs (immutability)
- β Primary constructors (modern C# 12 syntax)
- β Factory methods prevent invalid domain state
- β Encapsulation with private setters and computed properties
- β Constants centralization - no magic strings
- β Decimal precision converters - critical for financial accuracy
- β Multi-store, multi-register support - enterprise ready
- β Receipt number uniqueness - idempotency enforced at DB level
- β Timezone-aware timestamps - DATETIMEOFFSET usage
- β Polly v8 Circuit Breakers - Failure-ratio based circuit breakers for MasterDb, TenantDb, Redis, AuditDb
- β Retry Policies - Exponential backoff with jitter, transient error classification
- β Timeout Enforcement - 700ms MasterDb, 1500ms TenantDb, 200ms Redis
- β Connection String Caching - IMemoryCache with 10-min TTL, reduces MasterDb load by 99%+
- β SQL Connection Pooling - 200 max connections per tenant (prevents exhaustion)
- β Stampede Protection - IMemoryCache.GetOrCreateAsync thread-safe factory
- β Cache Warmup on Startup - IHostedService pre-warms all active tenants, eliminates cold-start delays
- β Structured Logging (Serilog) - Correlation ID + Tenant ID enrichment, async/non-blocking
- β Health Checks - SQL Server + Redis endpoints (/health/ready, /health/live)
- β Request Logging - Duration tracking, tenant context, correlation tracking
- β Polly Logging Callbacks - Circuit breaker state changes, retry attempts, timeouts
- β Global Exception Handler - RFC 7807 ProblemDetails for all errors
- β Consistent Error Responses - 400/404/500 with correlation ID, tenant ID, trace ID
- β Route Constraint Validation - GUID validation, bad request handling
- β 404 β 400 Middleware - Aggressive invalid endpoint rejection
- β API Versioning - URL-based (v1) + header-based versioning
- β Rate Limiting - Per-tenant sliding window (1000 req/10s), custom headers (X-RateLimit-Limit, Retry-After)
- β Correlation ID Middleware - Request tracing across logs
- β Tenant Resolution Middleware - X-Tenant header extraction
- β Background Audit Logging - Bounded queue (10k capacity), batch writes (200 items)
- β Payload Compression - Gzip request/response data
- β PII Masking - Email, phone, cardNumber, password, token
- β Per-Tenant Storage - AuditLog table in each tenant DB
- β Configurable Sampling - Separate rates for reads/writes
- β Constants Refactoring - HttpConstants, SqlConstants, ResilienceConstants, CacheConstants, File.Mime
- β CommandTimeout Alignment - Dapper timeout < Polly timeout (prevents hanging)
- β Cancellation Token Propagation - All async operations support cancellation
- β Mapperly Integration - Compile-time source generators for zero-overhead mapping
- β Type-Safe Mappings - SaleReadModel β SaleResponse with compile-time verification
- β Enhanced Validation - Production-ready FluentValidation rules (GUID empty checks, error codes)
- β k6 Stampede Test - 200 req/s spike load, multi-tenant, custom POS-focused summary
- β k6 Rate Limit Test - Per-tenant isolation verification, multi-scenario
- β Beautiful Test Output - Automated pass/fail verdicts, troubleshooting tips
- π΄ No authentication/authorization - API is completely open (JWT, RBAC, tenant claim validation needed)
- π΄ No tenant validation - Accepts any tenant header value (should validate against TenantMaster)
- π΄ Plain-text connection strings - Security risk (consider Azure Key Vault or IDataProtection)
β οΈ No write operations (Commands) - Read-only system currently (CreateSale, VoidSale, RefundSale needed)β οΈ No idempotency middleware - Database has unique constraints (basic protection), need Idempotency-Key header handlingβ οΈ No payment tracking - Incomplete POS domain model (payments, refunds, voids needed)β οΈ Tax calculation is placeholder - No real business logic (need strategy pattern)β οΈ No multi-tenant migrations - Manual script execution per tenant (need DbUp or FluentMigrator orchestration)
β οΈ Unused abstractions -Entitybase class,Moneyvalue object (use or remove)β οΈ Manual error mapping - Repetitive code in endpoints (consider result pattern helper)β οΈ Empty folders - Commands/, Mapping/ not implemented (Observability is now done!)β οΈ LocalDB performance - 900ms queries under load (acceptable for dev, use SQL Server for production)
β οΈ No integration tests - only manual testing possibleβ οΈ No Docker support - deployment not containerizedβ οΈ No CI/CD configuration - manual deploymentβ οΈ Missing database migration strategy - how to update all tenant DBsβ οΈ Console.WriteLine logging - production-ready logging needed
// Add distributed cache (Redis) or in-memory cache
public async Task<string> GetSqlConnectionStringAsync(string tenantId, CancellationToken ct)
{
return await _cache.GetOrCreateAsync($"tenant:{tenantId}", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return await QueryMasterDbAsync(tenantId, ct);
});
}Impact: Reduces master DB load by 99%+
// JWT authentication with tenant claims
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { ... });
// Validate tenant in JWT matches X-Tenant header
[Authorize(Policy = "TenantAccess")]Features Needed:
- JWT token generation/validation
- Role-based access (Cashier, Manager, Admin)
- Tenant claim validation
- API key support for POS terminals
public async Task InvokeAsync(HttpContext context)
{
var tenant = context.Request.Headers["X-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
return Results.Problem("X-Tenant header required", statusCode: 400);
// Validate tenant exists and is active
if (!await _tenantValidator.IsValidAsync(tenant))
return Results.Problem("Invalid tenant", statusCode: 403);
// ... continue
}app.UseExceptionHandler(appBuilder =>
{
appBuilder.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
// Log exception with tenant context
logger.LogError(exception, "Unhandled exception for tenant {TenantId}", tenantId);
// Return RFC 7807 Problem Details
var problem = new ProblemDetails
{
Status = 500,
Title = "Internal Server Error",
Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1"
};
await context.Response.WriteAsJsonAsync(problem);
});
});-- Use SQL Server encryption or Azure Key Vault
ALTER TABLE dbo.Tenants ADD ConnectionStringEncrypted VARBINARY(MAX);
-- Application decrypts using IDataProtection
var decrypted = _dataProtector.Unprotect(encrypted);Commands to Implement:
// MicroservicesBase.Infrastructure/Commands/Sales/
- CreateSale.cs
- Command(TenantId, StoreId, RegisterId, Items[], PaymentMethod)
- Validator (validate items, prices, store exists)
- Handler (insert Sale + SaleItems in transaction)
- VoidSale.cs
- Command(SaleId, Reason, VoidedBy)
- Handler (soft delete, audit trail)
- RefundSale.cs
- Command(SaleId, Items[], RefundMethod)
- Handler (create negative sale, link to original)Database Changes:
-- Add to Sales table
ALTER TABLE dbo.Sales ADD
Status NVARCHAR(20) NOT NULL DEFAULT 'COMPLETED',
VoidedAt DATETIMEOFFSET NULL,
VoidedBy NVARCHAR(100) NULL,
VoidReason NVARCHAR(500) NULL;
-- Add stored procedure
CREATE PROCEDURE dbo.CreateSale
@Id UNIQUEIDENTIFIER,
@TenantId NVARCHAR(100),
-- ... parameters
AS BEGIN
BEGIN TRANSACTION;
-- Insert Sale
-- Insert SaleItems
COMMIT TRANSACTION;
ENDStructured Logging (Serilog):
builder.Host.UseSerilog((context, config) =>
{
config
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "MicroservicesBase")
.WriteTo.Console()
.WriteTo.ApplicationInsights();
});
// Log enrichment with tenant context
LogContext.PushProperty("TenantId", tenantProvider.Id);OpenTelemetry (Distributed Tracing):
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddSqlClientInstrumentation()
.AddSource("MicroservicesBase"));Metrics:
// Track tenant-specific metrics
var salesCounter = meter.CreateCounter<long>("sales.completed");
var salesRevenue = meter.CreateHistogram<decimal>("sales.revenue");builder.Services.AddHttpClient<ITenantConnectionFactory, MasterTenantConnectionFactory>()
.AddTransientHttpErrorPolicy(policy =>
policy.WaitAndRetryAsync(3, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))));
// SQL retry for transient errors
var retryPolicy = Policy
.Handle<SqlException>(ex => /* transient error codes */)
.WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));// Endpoint receives Idempotency-Key header
var idempotencyKey = context.Request.Headers["Idempotency-Key"].FirstOrDefault();
// Store in cache or DB
if (await _idempotencyStore.ExistsAsync(idempotencyKey))
return await _idempotencyStore.GetResponseAsync(idempotencyKey);
// Process request
var response = await ProcessAsync(...);
// Cache response
await _idempotencyStore.StoreAsync(idempotencyKey, response, TimeSpan.FromHours(24));Database Approach:
CREATE TABLE dbo.IdempotencyKeys (
Key NVARCHAR(100) PRIMARY KEY,
Response NVARCHAR(MAX),
CreatedAt DATETIMEOFFSET DEFAULT SYSDATETIMEOFFSET()
);
CREATE INDEX IX_IdempotencyKeys_CreatedAt ON dbo.IdempotencyKeys(CreatedAt);// Add Payment entity
public sealed class Payment
{
Guid Id, Guid SaleId, PaymentMethod Method,
decimal Amount, string TransactionId, DateTimeOffset ProcessedAt
}CREATE TABLE dbo.Payments (
Id UNIQUEIDENTIFIER PRIMARY KEY,
SaleId UNIQUEIDENTIFIER NOT NULL REFERENCES dbo.Sales(Id),
Method NVARCHAR(50) NOT NULL, -- CASH, CREDIT_CARD, DEBIT_CARD, MOBILE
Amount DECIMAL(18,2) NOT NULL,
TransactionId NVARCHAR(100), -- External payment processor ID
ProcessedAt DATETIMEOFFSET NOT NULL,
CardLastFour NCHAR(4), -- PCI compliance (never store full card)
Status NVARCHAR(20) NOT NULL -- AUTHORIZED, CAPTURED, REFUNDED
);// Add audit interceptor for all commands
public class AuditInterceptor<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
// Log request
await _auditLog.WriteAsync(new AuditEntry
{
TenantId = _tenantProvider.Id,
Action = typeof(TRequest).Name,
UserId = _userContext.UserId,
Timestamp = DateTimeOffset.UtcNow,
RequestData = JsonSerializer.Serialize(request)
});
var response = await next();
// Log response
return response;
}
}CREATE TABLE dbo.AuditLog (
Id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
TenantId NVARCHAR(100) NOT NULL,
UserId NVARCHAR(100) NOT NULL,
Action NVARCHAR(100) NOT NULL,
EntityType NVARCHAR(100),
EntityId UNIQUEIDENTIFIER,
OldValue NVARCHAR(MAX),
NewValue NVARCHAR(MAX),
Timestamp DATETIMEOFFSET NOT NULL,
IpAddress NVARCHAR(50)
);
CREATE INDEX IX_AuditLog_TenantId_Timestamp ON dbo.AuditLog(TenantId, Timestamp DESC);public interface ITaxCalculator
{
Task<decimal> CalculateTaxAsync(Sale sale, CancellationToken ct);
}
public class USTaxCalculator : ITaxCalculator
{
// Query tax rate by StoreId (location-based)
// Apply to eligible items
// Handle tax-exempt items
}
public class EUVATCalculator : ITaxCalculator
{
// VAT calculation logic
}
// Register based on tenant configuration
services.AddScoped<ITaxCalculator>(sp =>
{
var config = sp.GetRequiredService<ITenantConfigProvider>();
return config.TaxRegion switch
{
"US" => new USTaxCalculator(),
"EU" => new EUVATCalculator(),
_ => throw new NotSupportedException()
};
});Option A: Use them
// Make Sale inherit from Entity
public sealed class Sale : Entity
{
// Remove duplicate Id property
}
// Use Money value object
public Money NetTotal { get; private set; }
public Money TaxTotal { get; private set; }
public Money GrandTotal { get; private set; }Option B: Remove them
- Delete
Entity.csif not needed - Delete
Money.csif sticking with decimal
builder.Services.AddHealthChecks()
.AddCheck<TenantDatabaseHealthCheck>("tenant_databases")
.AddCheck<MasterDatabaseHealthCheck>("master_database");
app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready");public class TenantDatabaseHealthCheck : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct)
{
// Check all active tenant DBs
var tenants = await _tenantService.GetActiveTenants();
foreach (var tenant in tenants)
{
try
{
await using var conn = new SqlConnection(tenant.ConnectionString);
await conn.OpenAsync(ct);
}
catch (Exception ex)
{
return HealthCheckResult.Degraded($"Tenant {tenant.Name} database unavailable", ex);
}
}
return HealthCheckResult.Healthy();
}
}builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("per-tenant", context =>
{
var tenant = context.Request.Headers["X-Tenant"].FirstOrDefault();
return RateLimitPartition.GetTokenBucketLimiter(tenant ?? "anonymous", _ =>
new TokenBucketRateLimiterOptions
{
TokenLimit = 100,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
TokensPerPeriod = 100
});
});
});
app.UseRateLimiter();builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
});
// Endpoint
Get("/api/v1/sales/{id:guid}");public sealed class Request
{
public Guid Id { get; set; }
}
public sealed class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.Id).NotEmpty();
}
}
public sealed class Endpoint : Endpoint<Request, SaleResponse>
{
// FastEndpoints auto-validates before HandleAsync
}Commands: See Phase 2 #6
Observability: See Phase 2 #7
Mapping: Consider Mapperly (source generator, zero-overhead)
[Mapper]
public partial class SalesMapper
{
public partial SaleResponse ToResponse(SaleReadModel model);
}public class SalesEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task GetSaleById_ValidId_Returns200()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant", "7ELEVEN");
// Act
var response = await client.GetAsync("/api/sales/11111111-1111-1111-1111-111111111111");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
COPY . .
RUN dotnet restore
RUN dotnet build -c Release
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MicroservicesBase.API.dll"]# docker-compose.yml
version: '3.8'
services:
api:
build: .
ports:
- "5000:80"
environment:
ConnectionStrings__TenantMaster: "Server=sqlserver;Database=TenantMaster;..."
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: Y
SA_PASSWORD: YourStrong!Passw0rd# .github/workflows/build.yml
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.x'
- run: dotnet restore
- run: dotnet build --no-restore
- run: dotnet test --no-build --verbosity normalOption A: DbUp / Fluent Migrator
// On startup, apply migrations to all tenant DBs
var tenants = await _tenantService.GetAllAsync();
foreach (var tenant in tenants)
{
var upgrader = DeployChanges.To
.SqlDatabase(tenant.ConnectionString)
.WithScriptsFromFileSystem("./Migrations")
.Build();
upgrader.PerformUpgrade();
}Option B: Manual Script Runner
// API endpoint for admins
POST /api/admin/migrate-tenants
{
"scriptName": "V2_AddPaymentsTable.sql"
}POS terminals often lose internet connectivity. Consider:
- Local SQLite database for offline transactions
- Background sync service to push to central DB
- Conflict resolution strategy (last-write-wins vs. CRDT)
public interface IReceiptPrinter
{
Task<byte[]> GenerateReceiptAsync(Sale sale); // PDF/ESC-POS format
}- Cash Drawer - serial/USB commands to open
- Barcode Scanner - USB HID or serial input
- Card Reader - PCI-compliant payment terminal integration
- Receipt Printer - ESC/POS protocol
- Sub-second response times for sale creation
- Concurrent transactions from multiple registers
- High availability during peak hours (lunch rush)
public interface IInventoryService
{
Task<bool> ReserveStockAsync(string sku, int quantity, CancellationToken ct);
Task CommitReservationAsync(Guid reservationId, CancellationToken ct);
Task RollbackReservationAsync(Guid reservationId, CancellationToken ct);
}public interface IPromotionEngine
{
Task<IEnumerable<Discount>> ApplyPromotionsAsync(Sale sale, CancellationToken ct);
}
// Types of promotions
- Buy X Get Y Free
- Percentage discount
- Fixed amount discount
- Bulk discount (tiered pricing)
- Time-based (happy hour)
- Coupon codes
- Loyalty points redemption// End-of-day report per register
public record DailyRegisterReport(
string RegisterId,
DateOnly Date,
int TransactionCount,
decimal TotalSales,
decimal TotalTax,
decimal CashSales,
decimal CardSales,
decimal Voids,
decimal Refunds
);
// X-Report (mid-shift, no cash drawer reconciliation)
// Z-Report (end-of-shift, closes register)- PCI-DSS - never store full card numbers
- GDPR - customer data protection
- Tax authority integration - submit sales to government portals
- Receipt archival - legal requirement (5-7 years)
For franchises operating in multiple countries:
public record Money(decimal Amount, string Currency);
public interface ICurrencyConverter
{
Task<Money> ConvertAsync(Money source, string targetCurrency, CancellationToken ct);
}-- Add hierarchy
CREATE TABLE dbo.Stores (
Id NVARCHAR(50) PRIMARY KEY,
TenantId NVARCHAR(100) NOT NULL,
Name NVARCHAR(200) NOT NULL,
Address NVARCHAR(500),
TimeZone NVARCHAR(50),
IsActive BIT DEFAULT 1
);
CREATE TABLE dbo.Registers (
Id NVARCHAR(50) PRIMARY KEY,
StoreId NVARCHAR(50) NOT NULL REFERENCES dbo.Stores(Id),
Name NVARCHAR(100),
IsActive BIT DEFAULT 1
);- β Horizontal scaling: Stateless API (can add more instances)
- β Database isolation: Each tenant DB scales independently
β οΈ Master DB bottleneck: All requests query TenantMaster (mitigated by caching)
- Read Replicas - Route read queries to replicas
- CQRS with Event Sourcing - Append-only event store
- Message Queue - Decouple commands (RabbitMQ/Azure Service Bus)
- API Gateway - Centralized routing, rate limiting, auth
- CDN for Static Assets - Receipt templates, product images
- Designing Data-Intensive Applications (Martin Kleppmann) - Multi-tenancy patterns
- Domain-Driven Design (Eric Evans) - Rich domain models
- Building Microservices (Sam Newman) - Service boundaries
- Outbox Pattern - Reliable messaging
- Saga Pattern - Distributed transactions
- Circuit Breaker - Resilience
- Strangler Fig - Legacy migration
The project includes comprehensive k6 test scripts for rate limiting and load testing:
Test Scripts:
k6-Tests/k6-simple-rate-limit.js- Simple rate limit test (10 VUs, 10s)k6-Tests/k6-rate-limit-test.js- Advanced multi-tenant rate limit test with scenariosk6-Tests/stampede-test.js- Cache stampede protection test
Run Tests:
# Start the API first (F5 in Visual Studio or dotnet run)
dotnet run --project MicroservicesBase.API
# Run stampede test (cache auto-warmed at startup - no manual pre-warming needed!)
k6 run k6-Tests/stampede-test.js
# Run rate limit test
k6 run k6-Tests/k6-rate-limit-test.jsCache Warmup Strategy:
The API automatically pre-warms the tenant connection string cache at startup via CacheWarmupHostedService. This:
- β Eliminates cold-start delays - First request is fast (no 6.7s factory execution)
- β Prevents cache stampede - Under high concurrency, no requests block waiting for factory
- β
Dynamic tenant discovery - Queries
TenantMasterdatabase for all active tenants (NO hardcoding!) - β Production best practice - Follows Kubernetes init containers, health check patterns
- β Scalable - Works with 2 tenants or 2000 tenants automatically
Expected Startup Logs:
[12:34:56 INF] [] [] π₯ Starting cache warmup (dynamic tenant discovery)...
[12:34:56 INF] [] [] Found 2 active tenant(s) to warm: 7ELEVEN, BURGERKING
[12:34:59 INF] [] [] β
Pre-warmed cache for tenant: 7ELEVEN (1/2)
[12:35:02 INF] [] [] β
Pre-warmed cache for tenant: BURGERKING (2/2)
[12:35:02 INF] [] [] π₯ Cache warmup completed in 6234ms: 2 succeeded, 0 failed
Why This Matters:
Without warmup, the first request per tenant triggers:
- IMemoryCache miss β Factory starts (thread-safe, blocks concurrent requests)
- MasterDb query via Polly pipeline (700ms timeout + 3 retries = 3-6 seconds)
- Under 200 req/s load, hundreds of requests pile up waiting
- Result: High latency, potential timeouts, poor user experience
With warmup:
- Cache pre-populated at startup (~3-6s one-time cost during deployment)
- All requests hit cache (<10ms)
- No blocking, no stampede, no latency spikes
- Production-ready! β
Manual Pre-Warming (Optional):
If you disable the hosted service or want to warm specific tenants manually:
# Pre-warm specific tenants via API calls
curl -k -H "X-Tenant: 7ELEVEN" https://localhost:60304/api/v1/sales/11111111-1111-1111-1111-111111111111
curl -k -H "X-Tenant: BURGERKING" https://localhost:60304/api/v1/sales/11111111-1111-1111-1111-111111111111Key Metrics Tracked:
- Request success rate (200 vs 429 responses)
- Latency percentiles (p50, p90, p95, p99)
- Rate limit hit rate (per-tenant isolation)
- Multi-tenant fairness (no cross-tenant interference)
Current Results (Post-Optimization):
Stampede Test (200 req/s, 10s):
- β 100% success rate (522/522 completed)
β οΈ p95 latency: 3.26s (LocalDB bottleneck, SQL Server would be <50ms)- β IMemoryCache working (connection strings cached, 10 min TTL)
- β Circuit breaker operational (Polly v8)
- β Multi-tenant isolation (7ELEVEN + BURGERKING)
β οΈ 1594 dropped iterations (k6 VU limitation, not API fault)
Rate Limit Test (20 req/s, 35s):
- β 83% success rate under 2x overload
- β p95 latency: 318ms (target: <500ms)
- β p99 latency: ~1200ms (acceptable tail latency)
- β 17% rate limited (16.6% expected under 2x load)
- β Per-tenant rate limit isolation verified
Performance Notes:
- LocalDB: 900ms queries under 200 req/s (development bottleneck, expected)
- Production SQL Server: Expect <50ms p95 latency (10-20x faster)
- IMemoryCache reduces TenantMaster load by 99%+ (only 2 queries for 522 requests)
- SQL Connection Pool (200 max): Prevents connection exhaustion
- Polly resilience: Auto-retry, circuit breaker, timeout enforcement working
Output Format: Beautiful boxed summary with:
- π Request summary (total, success, failed, dropped with percentages)
- β‘ Latency distribution (all requests + success-only for cache analysis)
- πΎ Cache effectiveness estimation (based on p90/p50)
- π― POS performance verdict (automated pass/fail with β β indicators)
- π Tips & troubleshooting (actionable advice based on results)
This is an enterprise-grade multi-tenant POS system foundation with production-ready resilience, observability, and performance features. You've implemented:
β 32 Enterprise Features including:
- Polly v8 circuit breakers, retry policies, timeout enforcement
- Structured logging with Serilog (correlation + tenant context)
- Health checks, API versioning, rate limiting
- Background audit logging with compression & PII masking
- IMemoryCache connection string caching (99%+ TenantMaster load reduction)
- Cache warmup on startup (IHostedService with dynamic tenant discovery)
- Mapperly compile-time mappers (zero runtime overhead, type-safe)
- Global exception handling with RFC 7807 ProblemDetails
- Comprehensive k6 load testing with beautiful summaries
What's Left:
- Authentication/Authorization (JWT, RBAC, tenant claim validation) - 2-3 weeks
- Write Operations (CreateSale, VoidSale, RefundSale commands) - 2-3 weeks
- Idempotency Middleware (Idempotency-Key header handling) - 1 week
- Multi-Tenant Migrations (DbUp or FluentMigrator orchestration) - 1 week
- POS Domain Completion (payments, inventory, promotions) - 3-4 weeks
Estimated Timeline to Full Production:
- Phase 1 (Auth): 2-3 weeks
β οΈ (your self-identified weak area) - Phase 2 (Write Operations): 2-3 weeks
- Phase 3 (Domain Completion): 3-4 weeks
- Total: 2-3 months
Current Production-Readiness:
- β Read Operations: Production-ready (with SQL Server, not LocalDB)
- β Resilience: Circuit breakers, retries, timeouts operational
- β Observability: Structured logging, health checks, request tracking
- β Performance: Optimized for high-concurrency POS workloads
β οΈ Security: No auth yet (critical blocker for production)β οΈ Write Operations: Read-only system (major functionality gap)
The database-per-tenant strategy is perfect for POS systems where data isolation and compliance are paramount. Your use of FastEndpoints, Dapper, CQRS, and Polly v8 positions you well for high-performance, resilient transaction processing.
- Review this analysis
- Prioritize features based on business needs
- Create a detailed implementation roadmap
- Set up development/staging environments
- Begin Phase 1 (Security & Stability)
Questions to Consider:
- Which payment processors do you need to integrate?
- What hardware (printers, scanners) will you support?
- Do you need offline support?
- What compliance requirements apply (PCI-DSS, GDPR, etc.)?
- What reporting capabilities are essential?
Feel free to ask about any specific implementation details!