Config-driven transaction pipeline (DI + fluent chains)
Goal Build a checkout pipeline where what runs and in what order comes from configuration, while the execution remains allocation-lean and testable.
This demo layers a small configuration model over the same primitives used in the mediated pipeline:
- Action chains for branchless discounts → tax and rounding
- Tender handling via a first-match router (see the mediated pipeline doc)
- DI registration that composes a single immutable
PatternKit.Examples.Chain.TransactionPipelineat startup
Quick start
- Add configuration (order matters):
// appsettings.json
{
"Payment": {
"Pipeline": {
"DiscountRules": [ "discount:cash-2pc", "discount:loyalty-5pc", "discount:bundle-1off" ],
"Rounding": [ "round:charity", "round:nickel-cash-only" ],
"TenderOrder": [ "tender:cash", "tender:card" ] // informational
}
}
}
- Register the pipeline in DI:
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using PatternKit.Examples.Chain.ConfigDriven;
var services = new ServiceCollection();
services.AddPaymentPipeline(configuration); // builds a TransactionPipeline from config
var provider = services.BuildServiceProvider();
var pipe = provider.GetRequiredService<ConfigDrivenPipelineDemo.PaymentPipeline>();
var (result, ctx) = pipe.Run(new TransactionContext {
Customer = new Customer(LoyaltyId: "LOYAL-123", AgeYears: 25),
Items = [ new LineItem("SKU-1", 22.97m) ],
Tenders = [ new Tender(PaymentKind.Cash, CashGiven: 20m),
new Tender(PaymentKind.Card, CardAuthType.Contactless, CardVendor.Visa) ]
});
- Done — the runtime pipeline is immutable and safe to reuse concurrently.
What’s in the box
Configuration model
PatternKit.Examples.Chain.ConfigDriven.PipelineOptions drives ordering:
DiscountRules: keys of discount rules to apply in orderRounding: keys of rounding strategies to apply in orderTenderOrder: optional, informational (e.g., control UI ordering)
Unknown keys are ignored (we map by key and skip missing entries).
Strategies provided
Discount rules (keys):
discount:cash-2pc→PatternKit.Examples.Chain.ConfigDriven.Cash2PctFirst tender is cash → 2% offSubtotaldiscount:loyalty-5pc→PatternKit.Examples.Chain.ConfigDriven.Loyalty5PctLoyalty present → 5% offSubtotaldiscount:bundle-1off→PatternKit.Examples.Chain.ConfigDriven.Bundle1OffEachAnyBundleKeywith totalQty ≥ 2→ $1 off per item in those bundles
Rounding (keys):
round:charity→PatternKit.Examples.Chain.ConfigDriven.CharityRoundUpIf anyCHARITY:*SKU is present → round up to next dollarround:nickel-cash-only→PatternKit.Examples.Chain.ConfigDriven.NickelCashOnlyCash-only transactions → round to nearest $0.05 (logs “skipped (not cash-only)” otherwise)
Tender handlers (DI-registered):
PatternKit.Examples.Chain.ConfigDriven.CashTender(tender:cash)PatternKit.Examples.Chain.ConfigDriven.CardTender(tender:card)
The router itself is assembled by the mediated pipeline pieces; we simply supply handlers via DI and the builder wires them into the tender stage.
How it composes
Discounts & tax (config-driven)
PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineBuilderExtensions.AddConfigDrivenDiscountsAndTax\*:
- Recomputes
Subtotal - Iterates
opts.Value.DiscountRulesand applies each rule that exists in the DI map - Computes tax at 8.75% of
(Subtotal − DiscountTotal)and logspre-round total
b.AddConfigDrivenDiscountsAndTax(opts, discountRules);
Rounding (config-driven)
PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineBuilderExtensions.AddConfigDrivenRounding\*:
- Iterates
opts.Value.Roundingand calls each strategy in order - Each strategy decides to apply or log “skipped”
- Logs final
total
b.AddConfigDrivenRounding(opts, rounding);
DI registration
PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.AddPaymentPipeline\*:
Binds
Payment:PipelinetoPatternKit.Examples.Chain.ConfigDriven.PipelineOptionsRegisters:
- Infra:
PatternKit.Examples.Chain.IDeviceBus,PatternKit.Examples.Chain.CardProcessors - Discounts:
Cash2Pct,Loyalty5Pct,Bundle1OffEach - Rounding:
CharityRoundUp,NickelCashOnly - Tenders:
CashTender,CardTender
- Infra:
Builds a shared
PatternKit.Examples.Chain.TransactionPipeline:
TransactionPipelineBuilder.New()
.WithDeviceBus(devices)
.AddPreauth()
.AddConfigDrivenDiscountsAndTax(opts, discountRules)
.AddConfigDrivenRounding(opts, rounding)
.WithTenderHandlers(tenderHandlers)
.AddTenderHandling()
.AddFinalize()
.Build();
Consumers receive a thin wrapper: PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.PaymentPipeline with Run(ctx).
Example scenarios (from tests)
Mixed tender (cash then card) — no nickel rounding
- Config:
Rounding = ["round:nickel-cash-only"] - Items:
Subtotal 22.97 → Tax 2.01 → Pre-round 24.98 - Tenders:
$20 cash, thenVisapays the remainder
Outcome
- Rounding skipped (not cash-only)
- Card captures
$4.98 - Result:
paid
See: TransactionPipelineDemoTests.MixedTender_NoNickelRounding.
Cash-only nickel rounding up to $25.00
- Config:
Rounding = ["round:nickel-cash-only"] - Pre-round total
24.98 - Rounding adds
+$0.02 - Single cash tender
$25.00→ paid
See: TransactionPipelineDemoTests.CashOnly_NickelRounding_Up.
Charity round-up
- Config:
Rounding = ["round:charity"] - Presence of
CHARITY:RedCrossSKU causes+$0.02to next whole dollar - Paid by card
See: TransactionPipelineDemoTests.Charity_RoundUp_Works.
Preauth block (age)
- Age-restricted item + underage customer →
TxResult.Fail("age", ...) - Pipeline stops early
See: TransactionPipelineDemoTests.Preauth_AgeBlock.
Extending with your own rules/strategies/handlers
- Implement the interface and choose a unique key:
public sealed class Employee10Pct : IDiscountRule
{
public string Key => "discount:employee-10pc";
public void Apply(TransactionContext ctx)
{
if (ctx.Customer.LoyaltyId == "EMP")
ctx.AddDiscount(Math.Round(ctx.Subtotal * 0.10m, 2), "employee 10%");
}
}
- Register it:
services.AddSingleton<IDiscountRule, Employee10Pct>();
- Enable it in config (order is important):
"Payment": { "Pipeline": { "DiscountRules": [
"discount:employee-10pc", "discount:bundle-1off"
]}}
The same pattern holds for IRoundingStrategy and ITenderHandler.
FAQ & tips
What happens if a key is listed but not registered? It’s skipped; we only apply rules found in the DI map.
Where’s the tax rate? Inside
AddConfigDrivenDiscountsAndTaxwe compute tax at 8.75%. Swap this with your own calculator if needed.Thread safety? The composed
PatternKit.Examples.Chain.TransactionPipelineis immutable and safe for concurrent use. Builders are not thread-safe.Observability Every rule/strategy logs its work to
ctx.Logusing concise, human-readable entries suitable for unit tests and diagnostics.Performance
- All composition happens once at startup.
- Execution uses arrays and
staticdelegates where possible to minimize allocations. - The config-driven action chains still short-circuit inside each component when appropriate.
Reference
Composition
PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineBuilderExtensionsPatternKit.Examples.Chain.TransactionPipelineBuilderPatternKit.Examples.Chain.TransactionPipeline
Config & DI
PatternKit.Examples.Chain.ConfigDriven.PipelineOptionsPatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.AddPaymentPipeline\*PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.PaymentPipeline
Strategies
- Discounts:
PatternKit.Examples.Chain.ConfigDriven.Cash2Pct,PatternKit.Examples.Chain.ConfigDriven.Loyalty5Pct,PatternKit.Examples.Chain.ConfigDriven.Bundle1OffEach - Rounding:
PatternKit.Examples.Chain.ConfigDriven.CharityRoundUp,PatternKit.Examples.Chain.ConfigDriven.NickelCashOnly - Tenders:
PatternKit.Examples.Chain.ConfigDriven.CashTender,PatternKit.Examples.Chain.ConfigDriven.CardTender
- Discounts:
Domain
PatternKit.Examples.Chain.TransactionContext,PatternKit.Examples.Chain.TxResult,PatternKit.Examples.Chain.Tender,PatternKit.Examples.Chain.LineItem,PatternKit.Examples.Chain.Customer