Our billing service started as one 900-line file that calculated prices, talked to two payment providers and sent receipts. Every new pricing rule meant another if branch, every new provider meant touching code that already worked, and the unit tests needed a live sandbox account to run. We knew "use design patterns" was the generic advice. What we needed was to find out which ones would actually reduce the pain.
Three did: strategy, adapter and dependency injection without a framework. This post shows each on the real shape of that code (simplified and renamed), why it helped, which SOLID principle it maps to, and the patterns we tried and removed. Examples use TypeScript 5.x on Node.js 22.
The starting point
// billing.ts (before), trimmed
export async function charge(order: Order, provider: 'stripe' | 'adyen') {
let total = order.subtotal;
if (order.customerType === 'student') total *= 0.8;
else if (order.customerType === 'enterprise' && order.subtotal > 10_000) total *= 0.9;
else if (order.coupon === 'LAUNCH25') total *= 0.75;
if (provider === 'stripe') {
const res = await fetch('https://api.stripe.com/v1/payment_intents', { /* ... */ });
// map stripe response
} else {
const res = await fetch('https://checkout-test.adyen.com/v71/payments', { /* ... */ });
// map adyen response, different field names, different error codes
}
await sendReceipt(order.email, total);
}
Three problems were tangled together: pricing rules growing as a chain of conditions, two incompatible external APIs inside business logic, and hard-wired dependencies that made testing impossible without the network.
Strategy: pricing rules as interchangeable objects
The strategy pattern gives each variant of an algorithm its own object behind a shared interface, and lets the caller pick one at runtime. In TypeScript the interface can be a single function type; it does not need a class hierarchy.
export interface PricingRule {
readonly name: string;
applies(order: Order): boolean;
apply(amountCents: number, order: Order): number;
}
export const studentDiscount: PricingRule = {
name: 'student-20',
applies: (o) => o.customerType === 'student',
apply: (amount) => Math.round(amount * 0.8),
};
export const enterpriseVolume: PricingRule = {
name: 'enterprise-volume-10',
applies: (o) => o.customerType === 'enterprise' && o.subtotalCents > 1_000_000,
apply: (amount) => Math.round(amount * 0.9),
};
export function couponRule(code: string, percentOff: number): PricingRule {
return {
name: `coupon-${code}`,
applies: (o) => o.coupon === code,
apply: (amount) => Math.round(amount * (1 - percentOff / 100)),
};
}
export class PriceCalculator {
constructor(private readonly rules: readonly PricingRule[]) {}
total(order: Order): { totalCents: number; applied: string[] } {
const rule = this.rules.find((r) => r.applies(order)); // first match wins, as before
return rule
? { totalCents: rule.apply(order.subtotalCents, order), applied: [rule.name] }
: { totalCents: order.subtotalCents, applied: [] };
}
}
What changed in practice:
- Adding a rule is a new object and one line in the list. Existing rules are not edited. That is the open/closed principle in its most useful form.
- Each rule is testable in isolation with a plain object, no mocks.
- The
appliedlist ended up in receipts and support tooling, which answered a whole category of "why was I charged this" tickets.
We also moved money to integer cents in the same change. Floating point totals had produced one-cent mismatches with the provider.
Adapter: one interface for two payment providers
An adapter wraps an external API so it matches the interface your code wants, instead of letting the external shape leak in. We defined the interface from our domain, not from either provider's SDK:
export type ChargeResult =
| { status: 'succeeded'; providerRef: string }
| { status: 'requires_action'; providerRef: string; redirectUrl: string }
| { status: 'declined'; reason: 'insufficient_funds' | 'card_declined' | 'other' };
export interface PaymentGateway {
charge(input: { amountCents: number; currency: string; idempotencyKey: string;
paymentMethodToken: string }): Promise<ChargeResult>;
}
Each provider gets an adapter that translates in both directions:
export class StripeGateway implements PaymentGateway {
constructor(private readonly client: Stripe) {}
async charge(input: Parameters<PaymentGateway['charge']>[0]): Promise<ChargeResult> {
try {
const intent = await this.client.paymentIntents.create(
{ amount: input.amountCents, currency: input.currency,
payment_method: input.paymentMethodToken, confirm: true,
return_url: 'https://shop.example.com/checkout/return' },
{ idempotencyKey: input.idempotencyKey },
);
if (intent.status === 'succeeded') return { status: 'succeeded', providerRef: intent.id };
if (intent.status === 'requires_action') {
return { status: 'requires_action', providerRef: intent.id,
redirectUrl: intent.next_action?.redirect_to_url?.url ?? '' };
}
return { status: 'declined', reason: 'other' };
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
return { status: 'declined',
reason: err.decline_code === 'insufficient_funds' ? 'insufficient_funds' : 'card_declined' };
}
throw err; // network and auth errors are not declines; let the caller retry
}
}
}
The second provider's adapter follows the same shape, with its own field names and result codes mapped into ChargeResult. The billing logic never sees a provider-specific type.
Benefits we measured rather than assumed: adding a third provider later touched zero lines of billing code, and a provider SDK major upgrade was contained to one file. The main design rule is that the adapter must translate errors too. Our first version passed provider error objects straight through, and the business code ended up checking provider-specific codes again.
This is the dependency inversion principle: the high-level billing module depends on PaymentGateway, an abstraction it owns, and the low-level provider code depends on that same abstraction.
Dependency injection without a container
Dependency injection just means an object receives its collaborators instead of creating them. We tried a decorator-based DI container first. It added reflection metadata, a build flag and a learning curve, and the dependency graph became harder to read. We replaced it with constructor injection and a single composition root.
export class BillingService {
constructor(
private readonly pricing: PriceCalculator,
private readonly gateway: PaymentGateway,
private readonly receipts: ReceiptSender,
private readonly clock: () => Date = () => new Date(),
) {}
async checkout(order: Order, paymentMethodToken: string) {
const { totalCents, applied } = this.pricing.total(order);
const result = await this.gateway.charge({
amountCents: totalCents, currency: order.currency,
idempotencyKey: `order-${order.id}`, paymentMethodToken,
});
if (result.status === 'succeeded') {
await this.receipts.send(order.email, { totalCents, applied, paidAt: this.clock() });
}
return result;
}
}
// main.ts, the only place that knows concrete classes
const rules = [studentDiscount, enterpriseVolume, couponRule('LAUNCH25', 25)];
const gateway = config.provider === 'stripe'
? new StripeGateway(new Stripe(config.stripeKey))
: new AdyenGateway(createAdyenClient(config.adyen));
export const billing = new BillingService(
new PriceCalculator(rules), gateway, new SesReceiptSender(sesClient),
);
Tests now build the service with fakes and run in milliseconds:
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('declined charge sends no receipt', async () => {
const sent: unknown[] = [];
const service = new BillingService(
new PriceCalculator([studentDiscount]),
{ charge: async () => ({ status: 'declined', reason: 'card_declined' }) },
{ send: async (...args) => { sent.push(args); } },
);
const result = await service.checkout(sampleOrder({ customerType: 'student' }), 'pm_card_visa');
assert.equal(result.status, 'declined');
assert.equal(sent.length, 0);
});
A container starts to pay for itself when you have request-scoped lifetimes across many modules, which frameworks like NestJS handle for you. In a plain Node.js service with a few dozen classes, a composition root is easier to read and debug.
How this maps to SOLID
| Principle | Where it showed up |
|---|---|
| Single responsibility | Pricing, charging and receipts are separate classes with one reason to change each |
| Open/closed | New pricing rules and providers are additions, not edits |
| Liskov substitution | Any PaymentGateway, including the test fake, can replace another without the service noticing |
| Interface segregation | ReceiptSender has one method; billing does not depend on the whole email module |
| Dependency inversion | Billing owns the PaymentGateway abstraction; adapters depend on it |
We treat SOLID as a review vocabulary, not as rules to satisfy for their own sake. If a change does not reduce a real cost, we do not make it.
Patterns we removed
- Abstract factory for gateways. A two-branch conditional in the composition root was clearer.
- Singleton config class. It made tests share state. Config is now a plain frozen object passed in.
- Observer for receipts. An event emitter between two classes in the same process hid the call flow. A direct call is easier to trace, and the real asynchronous boundary is the outbox we use for webhooks.
Common mistakes
| Mistake | Why it hurts | Do this instead |
|---|---|---|
| Designing the interface from the vendor SDK | Vendor concepts leak everywhere | Define the interface from your domain |
| Adapters that pass vendor errors through | Callers check vendor codes again | Map errors into your result type |
| Strategy classes for two branches | Indirection without benefit | Wait until the third variant appears |
| DI container by default | Hidden graph, extra tooling | Constructor injection plus composition root |
Key takeaways
- Use strategy when a growing chain of conditions selects between behaviours; in TypeScript a small interface or function type is enough.
- Use adapters to keep third-party shapes and errors out of business code, with the interface defined by you.
- Constructor injection and a single composition root give most of the DI benefit with none of the framework cost.
- SOLID is most useful as a shared vocabulary in review, applied where it removes a real cost.
- Remove patterns that add indirection without paying for it.
References
- TypeScript Handbook: https://www.typescriptlang.org/docs/handbook/intro.html
- Node.js test runner: https://nodejs.org/api/test.html
- Stripe API, PaymentIntents: https://docs.stripe.com/api/payment_intents
- Stripe idempotent requests: https://docs.stripe.com/api/idempotent_requests
- Adyen API explorer, Checkout API: https://docs.adyen.com/api-explorer/Checkout/latest/overview
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.