The typical example is you accept multiple payment methods—credit card, PayPal and crypto. And based on the user’s choice, you will handle the payment differently. If, for example, they chose PayPal, you need to work with the PayPal API to process the payment.
If you’re coming from an OOP language, the best way to implement it is using the Strategy pattern.
The OOP way
Here’s how the OOP version would look like:
interface IPayment {
processPayment(amount: number): void
}
class PaymentProcessor implements IPayment {
private paymentStrategy: IPayment
constructor(strategy: IPayment) {
this.paymentStrategy = strategy
}
processPayment(amount: number): void {
this.paymentStrategy.processPayment(amount)
}
setPaymentStrategy(strategy: IPayment) {
this.paymentStrategy = strategy
}
}
class CreditCardPayment implements IPayment {
processPayment(amount: number): void {
console.log(`$${amount} was processed with Credit card`)
}
}
class PayPalPayment implements IPayment {
processPayment(amount: number): void {
console.log(`$${amount} was processed with PayPal`)
}
}
class CryptoPayment implements IPayment {
processPayment(amount: number): void {
console.log(`$${amount} was processed with Crypto`)
}
}
// Usage
const paymentProcessor = new PaymentProcessor(new PayPalPayment())
paymentProcessor.processPayment(100)
// Output: $100 was processed with PayPal
Lots of typing for a single method. Very verbose!
If you’re working with a strictly OOP language, like Java, that might be your only option. But if your language supports functions as first-class citizens, like JavaScript, going with the functional approach is much cleaner.
The functional way
Here’s how it would look with functions:
type PaymentStrategy = (amount: number) => void
function processPayment(strategy: PaymentStrategy, amount: number) {
strategy(amount)
}
const creditCardPayment: PaymentStrategy = (amount: number) => {
console.log(`$${amount} was processed with Credit card`)
}
const paypalPayment: PaymentStrategy = (amount: number) => {
console.log(`$${amount} was processed with PayPal`)
}
const cryptoPayment: PaymentStrategy = (amount: number) => {
console.log(`$${amount} was processed with Crypto`)
}
processPayment(paypalPayment, 100)
// Output: $100 was processed with PayPal
No boilerplate and much cleaner!
Because functions are much smaller units than classes, reusing, replacing, and mocking them is much simpler. You don’t need to instantiate a class and set dependencies; you just deal with one function.
With functions, you pass what you just need
In OOP, we use ISP (Interface segregation principle) to ensure that classes don’t depend on methods they don’t use.
Using the same example, here’s how verbose your code will be without using ISP.
interface IPayment {
processPayment(amount: number): void
}
interface PaymentStrategy {
processPayment(amount: number): void
email(): string
creditCardNumber(): string
publicKey(): string
privateKey(): string
}
class PaymentProcessor implements IPayment {
// same
}
class CreditCardPayment implements PaymentStrategy {
private _creditCardNumber: string
constructor(creditCardNumber: string) {
this._creditCardNumber = creditCardNumber
}
processPayment(amount: number): void {
console.log(
`$${amount} was processed with Credit card using credit card number ${this.creditCardNumber()}`
)
}
creditCardNumber(): string {
return this._creditCardNumber
}
email(): string {
throw new Error('Method not implemented.')
}
publicKey(): string {
throw new Error('Method not implemented.')
}
privateKey(): string {
throw new Error('Method not implemented.')
}
}
class PayPalPayment implements PaymentStrategy {
private _email: string
constructor(email: string) {
this._email = email
}
processPayment(amount: number): void {
console.log(
`$${amount} was processed with PayPal using email ${this.email()}`
)
}
email(): string {
return this._email
}
creditCardNumber(): string {
throw new Error('Method not implemented.')
}
publicKey(): string {
throw new Error('Method not implemented.')
}
privateKey(): string {
throw new Error('Method not implemented.')
}
}
class CryptoPayment implements PaymentStrategy {
// ...
}
// Usage
const paymentProcessor = new PaymentProcessor(new CreditCardPayment('1234'))
paymentProcessor.processPayment(100)
const paymentProcessor2 = new PaymentProcessor(
new PayPalPayment('[email protected]')
)
paymentProcessor2.processPayment(100)
const paymentProcessor3 = new PaymentProcessor(
new CryptoPayment('public_key', 'private_key')
)
paymentProcessor3.processPayment(100)
In this code, we have a single interface for all payment strategies. But not all of the methods are used. For example, for PayPal we just need the email, no need for the rest.
To fix it, we can follow ISP and separate the interface into multiple smaller interfaces, each one for a specific payment method.
But why do that when we can avoid all of this with the functional approach.
type PaymentStrategy = (amount: number) => void
function processPayment(strategy: PaymentStrategy, amount: number) {
strategy(amount)
}
const creditCardPayment = (creditCardNumber: string): PaymentStrategy => {
return (amount: number) => {
console.log(
`$${amount} was processed with Credit card using credit card number ${creditCardNumber}`
)
}
}
const paypalPayment = (email: string): PaymentStrategy => {
return (amount: number) => {
console.log(`$${amount} was processed with PayPal using email ${email}`)
}
}
const cryptoPayment = (publicKey: string, privateKey: string): PaymentStrategy => {
return (amount: number) => {
console.log(
`$${amount} was processed with Crypto using public key ${publicKey} and private key ${privateKey}`
)
}
}
// Usage
processPayment(creditCardPayment('1234'), 100)
processPayment(paypalPayment('[email protected]'), 100)
processPayment(cryptoPayment('publicKey', 'privateKey'), 100)
With the power of closures and partial application, we converted each payment function into a single-input function after specifying the needed details (like email for PayPal).
For instance, calling paypalPayment('test@example')
returns another function that accepts the amount: (amount: number) => void
. Now passing that second function to processPayment
function will work, because that’s what it’s expecting.
That’s why functions are better for the Strategy pattern
It’s less boilerplate, easier to test and mock, and avoids bloated interfaces by providing only what’s needed (as we saw in the last example).