Arrange Act Assert

Jag Reehals thinking on things, mostly product development

Tracking Every Decision in Payments: The Context Pattern Approach

31 Jan 2025

Designing 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

  1. Fragmented Logging: Logs end up scattered across multiple functions or services, making it difficult to reconstruct a single payment from start to finish.

  2. 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.

  3. 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.

  4. 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?

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.

architecture