DEV Community

Federico Lochbaum
Federico Lochbaum

Posted on

Software design using OOP + FP — Part 1

Software design using OOP + FP — Part 1

Let’s do crazy solutions combining OOP and FP :)

The idea for this article is to summarize several patterns and provide examples of how to solve certain problem cases by mixing Object-Oriented Programming (OOP) and Functional Programming (FP) to get better solutions.

Throughout our professional lives, we can move between different programming paradigms. Each has its strengths and weaknesses and there are more than just two. We will explore OOP and FP because they are the most commonly used. The key is always knowing when and how to apply each to get better solutions.

Let’s get a quick review of the main ideas/concepts of each paradigm

Object-oriented programming features

  • Encapsulation: Hiding internal details and exposing only what’s necessary.

  • Inheritance: Creating new classes based on existing ones.

  • Polymorphism: Different classes can be treated through a common interface.

  • Abstraction: Simplifying complex systems by hiding unnecessary details.

  • Dynamic Binding: Determining which method to invoke at runtime rather than compile time.

  • Message Passing: Objects communicate with one another by sending and receiving messages.

Functional programming features

  • Immutability: Data doesn’t change once created.

  • Pure functions: Always return the same result for the same inputs.

  • Function composition: Combining simple functions to create more complex ones.

  • Recursion: Solving problems by breaking them down into smaller cases of the same problem.

  • High order: Functions that take other functions as arguments or return functions as results.

  • Referential transparency: An expression can be replaced with its value without changing the program’s behaviour.

Now that we have reviewed the principal paradigms topics, we are ready to check some common smell patterns but before that, I want to present my top premises when I am coding

  • Minimize ( as much as possible ) the amount of used syntax.

  • Avoid unnecessary parameter definitions.

  • Keep functions/methods small and focused on single-purpose ( inline if possible ).

  • Design using immutability and leave mutability just for special cases ( encapsulated ).

  • Use descriptive names for functions, methods, classes and constants.

  • Avoid using built-in iteration keywords ( unless using a high-order function makes the solution worse ).

  • Keep in mind the computational cost all the time.

  • The code must explain itself.

Note: I will use *NodeJs *for these examples because of facilities although these ideas should be easily implemented using any other programming language that allows us to use OOP and FP paradigms.

Complex if then else and switch cases

Several times I see code with many nested conditions or switch cases that make the logic hard to understand and maintain

const reactByAction = action => {
  switch (action.type) {
    case 'A':
      return calculateA(action)
    case 'B':
      return calculateB(action)
    case 'C':
      return calculateC(action)
    default:
      return // Do nothing
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we can choose between two approaches. We can decide to use the classic *Polymorphism *on OOP, delegating the responsibility of that computation to every action representation

const reactByAction = action => action.calculate()
Enter fullscreen mode Exit fullscreen mode

Where each action knows how to be calculated

` abstract class Action {
abstract calculate()
}

class ActionA extends Action {
    calculate() {
        ...
    }
}

class ActionB extends Action {
    calculate() {
        ...
    }
}

...`
Enter fullscreen mode Exit fullscreen mode

But, indeed, sometimes our solutions weren’t thought to be treated like instance objects. In this case, we can use classical object mapping and Referential transparency, which works like an indirect switch case, but allows us to omit complex matches

const calculateAction = {
A: action => ...,
B: action => ...,
C: action => ...,
}

const reactByAction = action => calculateAction?.[action.type](action)
Enter fullscreen mode Exit fullscreen mode

Of course, this has a limitation, each time you need to “identify” an action type, you will need to create a new constant object mapper.

Whatever the solution is chosen, you always want to keep it simple, delegating the responsibilities the best way possible, distributing the count of lines of your solution and using a good declarative name to simplify the understanding/scalability.

If you can’t provide an instance of every action, I suggest to implement a melted implementation

` class Handler {
constructor() {
this.actionMap = {
A: action => new ActionA(action),
B: action => new ActionB(action),
C: action => new ActionC(action),
};
}

  reactByAction = action => this.actionMap[action.type]?.(action).calculate()
}`
Enter fullscreen mode Exit fullscreen mode

Unnecessary parameter definitions

Sometimes, we implement functions/methods with many parameters that could be derived from a shared context, here is a simple example
`
const totalPrice = (price, taxRate, discountRate) => {
const taxAmount = price * taxRate
const discountAmount = price * discountRate
return price + taxAmount - discountAmount
}

const totalForRegularCustomer = price => totalPrice(price, 0.1, 0)
const totalForPremiumCustomer = price => totalPrice(price, 0.1, 0.1)`
Enter fullscreen mode Exit fullscreen mode

The definitions are right but we can simplify the number of responsibilities by decoupling the function, using the concept of currying and classes to encapsulate the details and create more specialized methods

Here is a simple suggestion using OOP

` class Customer {
taxAmount = price => price * this.taxRate()
discountAmount = price => price * this.discountRate()
totalPrice = price => price + this.taxAmount(price) - this.discountAmount(price)
}

class RegularCustomer extends Customer {
  taxRate = () => 0.1
  discountRate = () => 0
}

class PremiumCustomer extends Customer {
  taxRate = () => 0.05
  discountRate = () => 0.1
}`
Enter fullscreen mode Exit fullscreen mode

Note that I used the pattern template method :)

And here is another option using FP

` const tax = taxRate => price => price * taxRate
const discount = discountRate => price => price * discountRate

const regularTaxAmout = tax(0.1)
const premiumTaxAmout = tax(0.05)
const regularDiscount = discount(0)
const premiumDiscount = discount(0.1)

const calculationWithRates = (taxRateFunc, discountRateFunc) => price =>
  price + taxRateFunc(price) - discountRateFunc(price)

const totalForRegularCustomer = calculationWithRates(regularTaxAmout, regularDiscount)
const totalForPremiumCustomer = calculationWithRates(premiumTaxAmout, premiumDiscount)`
Enter fullscreen mode Exit fullscreen mode

Note that I am currying the functions to provide more abstractions increasing the declarativity

Again, we can mix both paradigms in a synthesized design :)

` class Customer {
constructor(taxRate, discountRate) {
this.taxRate = taxRate
this.discountRate = discountRate
}

  totalPrice = price => price + this.calculateTax(price) - this.calculateDiscount(price)

  calculateTax = price => price * this.taxRate
  calculateDiscount = price => price * this.discountRate
}

const regularCustomer = new Customer(0.1, 0)
const premiumCustomer = new Customer(0.05, 0.1)

const total = customer => customer.totalPrice

const totalForRegularCustomer = total(regularCustomer)
const totalForPremiumCustomer = total(premiumCustomer)`
Enter fullscreen mode Exit fullscreen mode

Excessive long method/functions

Another classic I’m used to seeing are methods and functions with a lot of responsibilities, too long, with hardcoded conventions and complex business rules

` const tokenize = code => {
let tokens = []
let currentToken = ''
let currentType = null

    for (let i = 0; i < code.length; i++) {
        let char = code[i]

        if (char === ' ' || char === '
Enter fullscreen mode Exit fullscreen mode

' || char === ' ') {
if (currentToken !== '') {
tokens.push({ type: currentType, value: currentToken })
currentToken = ''
currentType = null
}
} else if (char === '+' || char === '-' || char === '*' || char === '/') {
if (currentToken !== '') {
tokens.push({ type: currentType, value: currentToken })
currentToken = ''
}
tokens.push({ type: 'operator', value: char });
} else if (char >= '0' && char <= '9') {
if (currentType !== 'number' && currentToken !== '') {
tokens.push({ type: currentType, value: currentToken })
currentToken = ''
}
currentType = 'number'
currentToken += char
} else if ((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')) {
if (currentType !== 'identifier' && currentToken !== '') {
tokens.push({ type: currentType, value: currentToken })
currentToken = ''
}
currentType = 'identifier'
currentToken += char
}
}

    if (currentToken !== '') {
        tokens.push({ type: currentType, value: currentToken })
    }

    return tokens
}`
Enter fullscreen mode Exit fullscreen mode

Too complex, right? It is impossible to understand just by reading the code

Let’s see a reimplementation of this using OOP creating an abstraction and encapsulating the mutability of the context

` const binaryOperators = ['+', '-', '*', '/']

const SPECIAL_CHARS = {
    EMPTY_STRING: '',
    ...
}

class Context  {
    constructor(code) {
        this.code = code
        this.tokens = []
        this.currentToken = ''
        this.currentType = null
    }

    ...
}

class Tokenizer {
    constructor(code) { this.context = new Context(code) }

    getCurrentToken() { this.context.getCurrentToken() }

    tokenize() {
        this.context.getCode().forEach(this.processChar)
        this.finalizeToken()
        return this.context.getTokens()
    }

    processChar(char) {
        if (this.isWhitespace(char)) {
            this.finalizeToken()
        } else {
            ...
        }
    }

    isEmptyChar() {
        return this.getCurrentToken() == SPECIAL_CHARS.EMPTY_STRING
    }

    finalizeToken() {
        if (!isEmptyChar()) {
            this.addToken(this.context.currentType(), this.currentToken())
            this.resetCurrentToken()
        }
    }

    addToken(type, value) {
        this.context.addToken({ type, value })
    }

    resetCurrentToken() {
        this.context.resetCurrentToken()
    }

    processDigit(char) {
        if (!this.context.currentIsNumber()) {
            this.finalizeToken();
            this.context.setNumber()
        }

        this.context.nextChar()
    }

    processLetter(char) {
        ...
    }

    isWhitespace = char => char === SPECIAL_CHARS.SPACE || char === SPECIAL_CHARS.JUMP || char === SPECIAL_CHARS.TAB

    isOperator = binaryOperators.includes

    ...
}`
Enter fullscreen mode Exit fullscreen mode

Or we can think of a solution using some other FP concepts

` const isWhitespace = [' ', '
', ' '].includes
const isOperator = ['+', '-', '*', '/'].includes
...

const createToken = (type, value) => ({ type, value })

const initialState = () => ({ tokens: [], currentToken: EMPTY_STRING, currentType: null })

const processChar = (state, char) => {
    if (isWhitespace(char)) return finalizeToken(state)
    if (isOperator(char)) return processOperator(state, char)
    if (isDigit(char)) return processDigit(state, char)
    if (isLetter(char)) return processLetter(state, char)

    return state
}

const finalizeToken = state => isEmptyString(currentToken) ? state : ({
    ...state,
    tokens: [ ...state.tokens, createToken(state.currentType, state.currentToken) ],
    currentToken: '',
    currentType: null
})

const processOperator = (state, char) => ({
    ...finalizeToken(state),
    tokens: [...state.tokens, createToken(TYPES.op, char)]
})

const processDigit = (state, char) => ({
    ...state,
    currentType: TYPES.Number,
    currentToken: isNumber(state.currentType) ? state.currentToken + char : char
})

...

const tokenize = code => finalizeToken(code.reduce(processChar, initialState())).tokens
Enter fullscreen mode Exit fullscreen mode

Again, we think in encapsulation, abstractions and declarativeness

And, of course, we can provide a mixed solution ;)

const SPECIAL_CHARS = {
  SPACE: ' ',
  NEWLINE: '
Enter fullscreen mode Exit fullscreen mode

',
TAB: ' '
}

const binaryOperation = ['+', '-', '*', '/']

class Tokenizer {
  constructor(code) {
    this.code = code
    this.tokens = []
    this.currentToken = EMPTY_STRING
    this.currentType = null
  }

  tokenize() {
    this.code.split('').forEach(this.processChar.bind(this))
    this.finalizeToken()
    return this.tokens
  }

  processChar = char => {
    const processors = [
      { predicate: this.isWhitespace, action: this.handleWhitespace },
      { predicate: this.isOperator, action: this.handleOperator },
      { predicate: this.isDigit, action: this.handleDigit },
      { predicate: this.isLetter, action: this.handleLetter }
    ]

    const { action } = processors.find(({ predicate }) => predicate(char)) || EMPTY_OBJECT
    action?.(this, char)
  }

  isWhitespace = Object.values(SPECIAL_CHARS).includes
  isOperator = binaryOperation.includes
  isDigit = /[0-9]/.test
  isLetter = /[a-zA-Z]/.test
  handleWhitespace = this.finalizeToken

  handleOperator = char => {
    this.finalizeToken()
    this.addToken(TYPES.operation, char)
  }

  handleDigit = char => {
    if (!this.isNumber()) { this.finalizeToken(); this.currentType = TYPES.number }
    this.currentToken += char
  }

  handleLetter = char => {
    if (!this.isIdentifier()) { this.finalizeToken(); this.currentType = TYPES.id }
    this.currentToken += char
  }

  finalizeToken() {
    if (!this.isEmpty()) {
      this.addToken(this.currentType, this.currentToken)
      this.currentToken = SPECIAL_CHARS.empty
      this.currentType = null
    }
  }

  addToken(type, value) {
    this.tokens.push({ type, value })
  }
}

const tokenize = code => new Tokenizer(code).tokenize()
Enter fullscreen mode Exit fullscreen mode

`
Let's finish this first article about OOP + FP software design, I would like to write more about this perspective reviewing much more complex examples and patterns, proposing melted solutions and exploring deeply the ideas of both paradigms.

Let me know if something is strange, or if you find something in this article that could be improved. Like our software, this is an incremental iterative process, improving over time 😃.

Thanks a lot for reading!

Top comments (0)