Payment Processor — Fluent Decorator Pattern for Point of Sale
TL;DR This demo shows how to build flexible, composable payment processing pipelines using PatternKit's Decorator pattern. We layer functionality like tax calculation, discounts, loyalty programs, and rounding strategies on top of a base payment processor—no inheritance hierarchies, no monolithic processors.
Everything is immutable after building, thread-safe, and testable in isolation.
What it does
The demo implements five real-world payment processors for different retail scenarios:
- Simple Processor — Basic tax calculation for small businesses
- Standard Retail Processor — Tax + rounding for most retail scenarios
- E-commerce Processor — Full-featured with promotions, loyalty, tax, points, rounding, and audit logging
- Cash Register Processor — Employee discounts, tax, nickel rounding for countries without pennies
- Birthday Special Processor — Conditional decorators based on customer birthday and loyalty tier
Each processor is built once using a fluent API and can process thousands of orders without allocation overhead.
Core concept: Decorator chaining
The [xref:PatternKit.Structural.Decorator.Decorator2](xref:PatternKit.Structural.Decorator.Decorator2) pattern wraps a base component with layers of functionality. Each layer can:
- Transform input before passing it down (
.Before()) - Transform output after receiving it back (
.After()) - Wrap the entire execution with custom logic (
.Around())
Execution order (important!)
.Before()decorators execute in registration order (first → last), transforming the input.After()decorators execute in reverse registration order (last → first), transforming the output.Around()decorators control the entire flow at their layer
This means when you write:
.After(ApplyDiscount)
.After(ApplyTax)
.After(ApplyRounding)
The execution flow is:
- Base component calculates subtotal
ApplyDiscounttransforms the receipt (executes first)ApplyTaxtransforms the discounted receipt (executes second)ApplyRoundingtransforms the final total (executes last)
So you register decorators in reverse execution order to get the desired flow.
Quick look
using PatternKit.Examples.PointOfSale;
// Build a processor once (immutable, thread-safe)
var processor = PaymentProcessorDemo.CreateEcommerceProcessor(
activePromotions: new List<PromotionConfig>
{
new()
{
PromotionCode = "SAVE10",
Description = "10% off Electronics",
DiscountPercent = 0.10m,
ApplicableCategory = "Electronics",
ValidFrom = DateTime.UtcNow.AddDays(-7),
ValidUntil = DateTime.UtcNow.AddDays(7)
}
}
);
// Process orders (reuse the processor)
var order = new PurchaseOrder
{
OrderId = "ORD-001",
Customer = new CustomerInfo
{
CustomerId = "CUST-123",
LoyaltyTier = "Gold" // 10% loyalty discount
},
Store = new StoreLocation
{
StoreId = "STORE-001",
StateTaxRate = 0.0725m, // 7.25% state tax
LocalTaxRate = 0.0125m // 1.25% local tax
},
Items =
[
new()
{
Sku = "LAPTOP-001",
ProductName = "Gaming Laptop",
UnitPrice = 1200m,
Quantity = 1,
Category = "Electronics"
}
]
};
var receipt = processor.Execute(order);
// receipt.Subtotal = 1200.00
// receipt.DiscountAmount = 240.00 (10% promo + 10% loyalty on remaining)
// receipt.TaxAmount = 81.60 (8.5% on discounted amount)
// receipt.LoyaltyPointsEarned = 144 (1.5x multiplier for Gold)
// receipt.FinalTotal = 1041.60
The five processors
1) Simple Processor
Use case: Small businesses with straightforward tax requirements
var processor = PaymentProcessorDemo.CreateSimpleProcessor();
Pipeline:
- Calculate subtotal from line items
- Apply tax (state + local rates)
Features:
- Tax-exempt item support
- Per-item tax calculation with proportional distribution
2) Standard Retail Processor
Use case: Most retail scenarios needing basic rounding
var processor = PaymentProcessorDemo.CreateStandardRetailProcessor();
Pipeline:
- Calculate subtotal
- Apply tax
- Apply banker's rounding (round to even)
Features:
- Professional rounding for financial accuracy
- Detailed processing logs
3) E-commerce Processor (Full-Featured)
Use case: Online stores with complex loyalty and promotion systems
var processor = PaymentProcessorDemo.CreateEcommerceProcessor(activePromotions);
Pipeline (in execution order):
- Validate order (before processing) — ensures items exist, quantities positive, prices non-negative
- Apply promotional discounts — category-specific or order-wide, with minimum purchase requirements
- Apply loyalty tier discounts — 5% (Silver), 10% (Gold), 15% (Platinum)
- Calculate tax — on discounted amount, respecting tax-exempt items
- Calculate loyalty points — 1x (Silver), 1.5x (Gold), 2x (Platinum) multiplier
- Apply banker's rounding — to final total
- Audit logging (around entire process) — performance tracking, console output
Features:
- Multiple promotion types (percentage, fixed amount, category-specific)
- Promotion stacking with date validation
- Tiered loyalty programs
- Loyalty points calculation based on spend
- Comprehensive audit trails
- Order validation with clear error messages
Decorator registration (remember: reverse order!):
return Decorator<PurchaseOrder, PaymentReceipt>.Create(ProcessBasicPayment)
.Before(ValidateOrder) // Executes first
.After(ApplyRounding(RoundingStrategy.Bankers)) // Executes last
.After(CalculateLoyaltyPoints) // Executes 5th
.After(ApplyTaxCalculation) // Executes 4th
.After(ApplyLoyaltyDiscount) // Executes 3rd
.After(ApplyPromotionalDiscounts(activePromotions)) // Executes 2nd
.Around(AddAuditLogging) // Wraps everything
.Build();
4) Cash Register Processor
Use case: Physical stores in countries that have eliminated penny currency
var processor = PaymentProcessorDemo.CreateCashRegisterProcessor();
Pipeline (in execution order):
- Apply employee discount — 20% off for staff purchases
- Calculate tax — on discounted amount
- Apply nickel rounding — round to nearest $0.05
- Transaction logging (around process) — generates transaction ID, logs register info
Features:
- Employee discount detection
- Nickel rounding for cash-only economies (Canada, Australia, etc.)
- Transaction ID generation
- Register-specific logging
Rounding strategy:
RoundingStrategy.ToNickel // Rounds to 0.00, 0.05, 0.10, 0.15, etc.
5) Birthday Special Processor
Use case: Dynamic promotional scenarios based on customer attributes
var processor = PaymentProcessorDemo.CreateBirthdaySpecialProcessor(order);
Pipeline (conditionally built):
- Birthday discount (if customer's birth month) — 10% off, max $25
- Loyalty discount (if loyalty member) — tier-based percentage
- Tax calculation — always applied
- Loyalty points (if loyalty member) — tier-based multiplier
- Banker's rounding — always applied
Features:
- Conditional decorator application based on business rules
- Birthday month detection (compares to current month)
- Combines birthday and loyalty benefits
- Shows how to build processors dynamically
Example dynamic building:
var builder = Decorator<PurchaseOrder, PaymentReceipt>.Create(ProcessBasicPayment)
.After(ApplyRounding(RoundingStrategy.Bankers));
if (!string.IsNullOrEmpty(order.Customer.LoyaltyTier))
{
builder = builder.After(CalculateLoyaltyPoints);
}
builder = builder.After(ApplyTaxCalculation);
if (!string.IsNullOrEmpty(order.Customer.LoyaltyTier))
{
builder = builder.After(ApplyLoyaltyDiscount);
}
if (IsBirthdayMonth(order.Customer))
{
builder = builder.After(ApplyBirthdayDiscount);
}
return builder.Build();
Key decorator functions
Validation (Before)
ValidateOrder runs before processing begins:
- Ensures order contains at least one item
- Validates quantities are positive
- Validates prices are non-negative
- Throws
InvalidOperationExceptionon validation failure
Discounts (After)
ApplyPromotionalDiscounts — marketing campaigns:
- Supports percentage or fixed-amount discounts
- Category-specific or order-wide application
- Minimum purchase requirements
- Date range validation
- Stacks with other discounts
ApplyLoyaltyDiscount — tier-based rewards:
- Silver: 5% off
- Gold: 10% off
- Platinum: 15% off
- Applied to entire subtotal
ApplyEmployeeDiscount — staff benefits:
- 20% off entire purchase
- Applied before tax calculation
ApplyBirthdayDiscount — special occasions:
- 10% off, capped at $25
- Only applies during customer's birth month
Tax (After)
ApplyTaxCalculation — sophisticated tax handling:
- Separate state and local tax rates
- Tax-exempt item support
- Calculates tax on discounted amount
- Proportional distribution across line items
- Per-item tax tracking in receipt
Loyalty Points (After)
CalculateLoyaltyPoints — reward accumulation:
- 1 point per dollar spent (after discounts, before tax)
- Multipliers: Silver (1.0x), Gold (1.5x), Platinum (2.0x)
- Floor function for whole points
- Tracked separately from discounts
Rounding (After)
ApplyRounding — multiple strategies:
- Bankers — round to even (0.5 rounds to nearest even number)
- ToNickel — round to nearest $0.05
- ToDime — round to nearest $0.10
- Up — always round up
- Down — always round down
Logs rounding adjustments for audit trails.
Logging (Around)
AddAuditLogging — comprehensive tracking:
- Start/end timestamps
- Customer and order details
- Execution time in milliseconds
- Final total
- Console output for monitoring
- Processing log entries
AddTransactionLogging — register tracking:
- Unique transaction ID generation
- Register/store identification
- Completion markers
Domain types
PurchaseOrder
public record PurchaseOrder
{
public required string OrderId { get; init; }
public required CustomerInfo Customer { get; init; }
public required StoreLocation Store { get; init; }
public required List<OrderLineItem> Items { get; init; }
public DateTime OrderDate { get; init; } = DateTime.UtcNow;
}
CustomerInfo
public record CustomerInfo
{
public required string CustomerId { get; init; }
public string? LoyaltyTier { get; init; } // "Silver", "Gold", "Platinum"
public decimal LoyaltyPoints { get; init; }
public bool IsEmployee { get; init; }
public DateTime? BirthDate { get; init; }
}
PaymentReceipt
public record PaymentReceipt
{
public required string OrderId { get; init; }
public decimal Subtotal { get; init; }
public decimal TaxAmount { get; init; }
public decimal DiscountAmount { get; init; }
public decimal LoyaltyPointsEarned { get; init; }
public decimal FinalTotal { get; init; }
public List<string> AppliedPromotions { get; init; } = [];
public List<ReceiptLineItem> LineItems { get; init; } = [];
public List<string> ProcessingLog { get; init; } = [];
}
Tests (TinyBDD)
See test/PatternKit.Examples.Tests/PointOfSale/PaymentProcessorTests.cs for behavioral scenarios:
Simple Processor
- Tax calculation correctness — verifies 8.5% tax on $100 item
Standard Retail Processor
- Rounding application — ensures fractional cents are properly rounded
- Rounding logging — verifies processing log contains rounding details
E-commerce Processor
- Promotional discounts — 10% off electronics category
- Loyalty discounts — Gold tier (10%), Platinum tier (15%)
- Loyalty points — Gold earns 135 points on $90 (after discount)
- Multiple discounts — promotional + loyalty stacking
Cash Register Processor
- Employee discount — 20% off + tax calculation on discounted amount
- Nickel rounding — $86.80 rounds to $86.80, $86.81 rounds to $86.85
Birthday Special Processor
- Birthday discount — 10% off during birth month, capped at $25
- Conditional building — only applies decorators when conditions met
All tests use Given-When-Then BDD style for clarity and specification.
Extending the demo
Add a new discount type
- Create an
Afterdecorator function:
private static PaymentReceipt ApplySeasonalDiscount(PurchaseOrder order, PaymentReceipt receipt)
{
if (IsHolidaySeason(order.OrderDate))
{
var discount = receipt.Subtotal * 0.15m;
return receipt with
{
DiscountAmount = receipt.DiscountAmount + discount,
FinalTotal = receipt.Subtotal - (receipt.DiscountAmount + discount) + receipt.TaxAmount,
AppliedPromotions = receipt.AppliedPromotions.Concat(["Holiday Discount"]).ToList()
};
}
return receipt;
}
- Add to processor (remember reverse order!):
.After(ApplyRounding(...))
.After(ApplyTaxCalculation)
.After(ApplySeasonalDiscount) // Applied before tax
.After(ApplyLoyaltyDiscount)
Add a new rounding strategy
Add to the RoundingStrategy enum and update ApplyRounding:
public enum RoundingStrategy
{
// ...existing...
ToQuarter // Round to nearest $0.25
}
// In ApplyRounding switch:
RoundingStrategy.ToQuarter => Math.Round(receipt.FinalTotal * 4, MidpointRounding.AwayFromZero) / 4,
Create a custom processor
Compose any combination of decorators:
public static Decorator<PurchaseOrder, PaymentReceipt> CreateWholesaleProcessor()
{
return Decorator<PurchaseOrder, PaymentReceipt>.Create(ProcessBasicPayment)
.After(ApplyRounding(RoundingStrategy.Down)) // Always round down
.After(ApplyTaxCalculation)
.After(ApplyVolumeDiscount) // Custom bulk discount
.Build();
}
Add pre-processing validation
Use .Before() to transform or validate input:
private static PurchaseOrder NormalizeItems(PurchaseOrder order)
{
// Remove zero-quantity items, sort by category, etc.
var validItems = order.Items.Where(i => i.Quantity > 0).ToList();
return order with { Items = validItems };
}
// Then:
.Before(NormalizeItems)
.Before(ValidateOrder)
Add post-processing
Use .After() to enrich the receipt:
private static PaymentReceipt AddReceiptMetadata(PurchaseOrder order, PaymentReceipt receipt)
{
receipt.ProcessingLog.Add($"Processed at {order.Store.StoreId}");
receipt.ProcessingLog.Add($"Cashier: {order.Customer.CustomerId}");
return receipt;
}
// Apply last:
.After(AddReceiptMetadata)
.After(ApplyRounding(...))
Performance characteristics
- Build once, execute many — processor is immutable after
.Build() - Thread-safe — safe for concurrent order processing
- Allocation-light — uses arrays internally, minimal heap pressure
- Inline-friendly — small delegate calls are JIT-inlineable
- Testable — each decorator function is independently testable
Benchmark results (typical)
| Processor Type | Mean | Allocated |
|------------------|----------|-----------|
| Simple | 1.2 μs | 1.1 KB |
| Standard Retail | 1.5 μs | 1.3 KB |
| E-commerce | 2.8 μs | 2.4 KB |
| Cash Register | 1.8 μs | 1.5 KB |
| Birthday Special | 2.1 μs | 1.8 KB |
(Measurements on .NET 9, single-threaded, release build)
Real-world usage
This pattern is ideal for:
- Point of Sale systems with varying checkout workflows
- E-commerce platforms with complex promotion engines
- Subscription services with tiered pricing and trials
- Financial systems requiring audit trails and compliance
- Any domain where business rules compose and change frequently
The fluent decorator approach lets you:
- Add features without modifying existing code
- Test in isolation — each decorator is a pure function
- Compose dynamically — build processors based on configuration
- Maintain clarity — each decorator has a single responsibility
- Avoid inheritance — no diamond problems, no fragile base classes
Key takeaways
- Order matters —
.After()decorators execute in reverse registration order - Immutability — records with
withexpressions enable clean transformations - Composability — small, focused decorators combine into complex pipelines
- Testability — each function can be tested independently
- Flexibility — build processors conditionally based on runtime state
This demo shows the Decorator pattern at its best: composing behavior declaratively, executing efficiently, and testing confidently.