Tracking Every Decision in Payments: The Context Pattern Approach
31 Jan 2025Designing a payment system is like any other software solution I've worked on. At first, it appears straightforward, but real-world factors like currency conversions, fees, payee validations and external APIs quickly add complexity.
When something goes wrong, you need to know exactly which step failed and why.
In this post, I'll explain how the Context Pattern helps solve this by storing all important data in a single object.
Each function returns new information rather than silently modifying the shared state, and the caller explicitly updates the context. This ensures clear data flow, easy debugging and a more maintainable system.
The Problem
-
Fragmented Logging: Logs end up scattered across multiple functions or services, making it difficult to reconstruct a single payment from start to finish.
-
Ambiguous State Changes: Without a single data store, you end up with countless parameters or rely on global variables—leading to confusion over which function changed what data and why.
-
Hard to Add Features: Introducing new requirements like compliance checks or multi-tier fees means revisiting multiple function calls and logs, increasing the chances of errors.
-
Limited Error Context: If a step fails, you might only see partial details. Recreating an issue becomes guesswork without a full view of the payment data.
A Real-World Scenario Without the Context Pattern
Below is an example of a typical payment flow that returns values from functions, but lacks a single, shared data structure and a proper correlation ID:
// (A) Fetch a conversion rate
async function getConversionRate(
clientId: string,
sourceCurrency: string,
targetCurrency: string
): Promise<number> {
console.log(
`[${clientId}] Fetching rate for ${sourceCurrency} to ${targetCurrency}.`
);
const rate = 1.23; // Mocked response
console.log(`[${clientId}] Retrieved rate: ${rate}`);
return rate;
}
// (B) Process the payment
async function processPayment(
clientId: string,
amount: number,
conversionRate: number
): Promise<{ status: string; transactionId: string }> {
console.log(
`[${clientId}] Processing payment of ${amount} at rate ${conversionRate}.`
);
if (amount <= 0) {
throw new Error(`Invalid amount: ${amount}. Cannot process payment.`);
}
const transactionId = 'tx-999';
console.log(
`[${clientId}] Payment processed with transaction ID ${transactionId}.`
);
return { status: 'SUCCESS', transactionId };
}
// (C) Main flow that orchestrates both steps
async function mainPaymentFlow(
clientId: string,
sourceCurrency: string,
targetCurrency: string,
amount: number
) {
try {
const rate = await getConversionRate(
clientId,
sourceCurrency,
targetCurrency
);
const paymentResult = await processPayment(clientId, amount, rate);
console.log(`[${clientId}] Flow complete. Result:`, paymentResult);
} catch (err) {
console.error(`[${clientId}] Payment flow error:`, err);
}
}
// Simulate usage
(async () => {
await mainPaymentFlow('client-123', 'GBP', 'USD', 200);
})();
What's lacking here?
-
No Single Source of Truth: Data is scattered and only passed around ad hoc.
-
Weak Correlation:
clientId
is a shared identifier, but logs can become tangled if multiple payments happen simultaneously for the same client. -
Harder to Extend: Adding extra steps requires new return values or extra parameters everywhere.
The Context Pattern
The Context Pattern centres on a single object (the context) that holds all important data throughout the payment lifecycle. Each helper function returns the data it computes, and the caller decides how to update the context. No mysterious global changes exist, and each log can attach a unique spanId
(or similar).
flowchart LR subgraph Context Flow Start([Payment Start]) --> Context[Create Context\nspanId, currencies, amount] Context --> Rate[Get Rate\nReturns rate] Rate --> Fees[Calculate Fees\nReturns fee structure] Fees --> Validate[Validate\nReturns validation result] Validate --> Process[Process Payment\nReturns result] Process --> Store[(Store Context)] end subgraph Error Handling Rate -- Error --> ErrorLog[Log Error + Full Context] Fees -- Error --> ErrorLog Validate -- Error --> ErrorLog Process -- Error --> ErrorLog end
import pino from 'pino';
// Base logger for the application
const baseLogger = pino({ level: 'info' });
// Payment result type
interface PaymentResult {
status: string;
transactionId: string;
}
// The context that holds all relevant fields in one place
interface PaymentContext {
spanId: string;
sourceCurrency: string;
targetCurrency: string;
amount: number;
conversionRate?: number;
paymentResult?: PaymentResult;
}
// Fetch a conversion rate
async function getConversionRate(context: PaymentContext): Promise<number> {
const logger = baseLogger.child({
spanId: context.spanId,
function: 'getConversionRate',
});
if (context.amount <= 0) {
throw new Error(
'Amount must be greater than zero before fetching a conversion rate.'
);
}
logger.info(
`Fetching rate for ${context.sourceCurrency} to ${context.targetCurrency}.`
);
const mockRate = 1.23;
logger.info({ mockRate }, 'Conversion rate retrieved.');
return mockRate;
}
// Process the payment
async function processPayment(context: PaymentContext): Promise<PaymentResult> {
const logger = baseLogger.child({
spanId: context.spanId,
function: 'processPayment',
});
logger.info(
`Processing payment of ${context.amount} ${context.sourceCurrency}.`
);
if (context.amount <= 0) {
throw new Error(
`Invalid amount: ${context.amount}. Cannot process payment.`
);
}
const result: PaymentResult = { status: 'SUCCESS', transactionId: 'tx-999' };
logger.info({ result }, 'Payment processed.');
return result;
}
// Main flow uses a single context
async function main() {
const paymentContext: PaymentContext = {
spanId: 'unique-trace-id-123',
sourceCurrency: 'GBP',
targetCurrency: 'USD',
amount: 200,
};
try {
// 1. Retrieve conversion rate
const rate = await getConversionRate(paymentContext);
paymentContext.conversionRate = rate;
// 2. Process payment
const paymentRes = await processPayment(paymentContext);
paymentContext.paymentResult = paymentRes;
baseLogger.info({ paymentContext }, 'Payment flow completed successfully.');
} catch (error) {
// Log both the error and context for better debugging
baseLogger.error(
{ error, paymentContext },
'Payment flow encountered an error.'
);
}
}
main().catch(console.error);
Advanced Features
Fee Calculation and Rounding
In the real world, payment flows often need multiple fees that must be rounded precisely. You might add a fees field to your context:
interface PaymentFees {
base: number;
foreign: number;
total: number;
}
interface PaymentContext {
// ...
fees?: PaymentFees;
}
async function calculateFees(context: PaymentContext): Promise<PaymentFees> {
const baseFee = Math.round(context.amount * 0.01 * 100) / 100;
const foreignFee =
context.sourceCurrency !== context.targetCurrency
? Math.round(context.amount * 0.005 * 100) / 100
: 0;
const totalFee = baseFee + foreignFee;
return { base: baseFee, foreign: foreignFee, total: totalFee };
}
You would then do:
context.fees = await calculateFees(context);
Multi-step Validations
If you have a compliance check or KYC rule, you can create a function that takes context and returns a result (or throws an error), so all data flows through the same pattern.
Testing and Persistence
Unit Testing with Mocks
Because each function returns data, testing them in isolation is simple. For example:
describe('calculateFees', () => {
it('calculates foreign fees correctly', async () => {
const context: PaymentContext = {
spanId: 'test',
sourceCurrency: 'GBP',
targetCurrency: 'USD',
amount: 200,
};
const fees = await calculateFees(context);
expect(fees.total).toBe(3); // 2 base + 1 foreign, as an example
});
});
Long-Running Transactions
If your process is asynchronous or spans multiple stages, you can save the context into a database at certain checkpoints:
async function checkpointContext(context: PaymentContext) {
await prisma.paymentFlow.upsert({
where: { spanId: context.spanId },
update: { metadata: context, lastUpdated: new Date() },
create: {
spanId: context.spanId,
metadata: context,
status: 'IN_PROGRESS',
},
});
}
This allows you to resume or replay from that checkpoint if a later step fails.
Migrating Existing Code
A common question is how to refactor older, scattered logic into this pattern. The most straightforward approach is usually incremental:
// Old approach
function oldProcess(amount: number, currency: string) {
// ... scattered logic and partial logs
}
// New approach using context
function newProcess(context: PaymentContext) {
// ... shared context with proper logs
}
You might begin by creating a PaymentContext and gradually modifying existing functions to accept context rather than many parameters. Log calls can be redirected through a child logger with spanId.
Over time, you reduce global variables or repeated parameters and end up with a single context that you can checkpoint, log and debug in one place.
Real-World Pitfalls
Context Object Size
Keep an eye on how large your context becomes. Use references or IDs if you only need to load extra data on demand. Redundant or overly large fields can affect performance.
Long-running Flows
Store partial context at each step if the transaction is split into multiple asynchronous stages. Use an event-driven architecture with an ID that references a stored context in Redis or a database.
Clearing Sensitive Data
Some fields (like card numbers or personal info) must not remain in logs. Use redaction features (for example, in Pino) or remove those fields from the context once you no longer need them.
Comparison with Other Patterns
Command Pattern
Ideal if you want each action to be a discrete command object. It can be more complex than a simple context approach and is not always necessary if you just need straightforward flows.
Event Sourcing
Allows you to replay state changes by storing events. It is powerful for auditing but requires a more elaborate design. The Context Pattern is typically more straightforward for most small to medium payment systems.
Conclusion
Using a context object, passing it through each stage of your payment flow and returning computed values lets you build a highly transparent, adaptable system.
If an error occurs, you can log the entire context, making it far easier to diagnose issues later. This pattern also scales well when you need to introduce fees, compliance checks or multi-step validations since each new function simply takes and returns context-related data.
Whether you stay with the Context Pattern or eventually transition to something like Event Sourcing, you'll find that starting with a single shared context and a correlation ID ensures your payment code remains both maintainable and easy to evolve.