DEV Community

Cover image for Strategy pattern is better when done with functions
Taha Shashtari
Taha Shashtari

Posted on • Originally published at tahazsh.com

Strategy pattern is better when done with functions

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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('test@example.com')
)
paymentProcessor2.processPayment(100)

const paymentProcessor3 = new PaymentProcessor(
  new CryptoPayment('public_key', 'private_key')
)
paymentProcessor3.processPayment(100)

Enter fullscreen mode Exit fullscreen mode

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('test@example.com'), 100)
processPayment(cryptoPayment('publicKey', 'privateKey'), 100)
Enter fullscreen mode Exit fullscreen mode

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


🔗 Let's stay connected:

𝕏 Twitter/X: https://twitter.com/tahazsh
🦋 Bluesky: https://bsky.app/profile/tahazsh.bsky.social
🐘 Mastodon: https://fosstodon.org/@tahazsh
🎥 YouTube: https://www.youtube.com/@tahazsh
🌐 Blog: https://tahazsh.com/

Top comments (0)