DEV Community

Tom Oram
Tom Oram

Posted on • Originally published at cloudnative.ly on

Better Ways to Implement Single-Method Classes

/imagine a class with a single method rewritten as a higher-order function
/imagine a class with a single method rewritten as a higher-order function

Suppose you’re coming to a programming language which allows you to program in multiple paradigms, like Python or TypeScript, but you have come from an object-oriented programming (OOP) background. In that case, you might choose to continue building your software in the OOP way you love. Writing code this way is perfectly reasonable, but you might create unnecessary boilerplate code if you insist on using the class keyword for everything. I want to show how you can rewrite a common type of class differently, reducing the amount of typing you have to do and making the code more straightforward to understand.

Single Method Classes

The type of class I’m talking about is a single-method class. These classes represent a single action, but they might wrap up some state or dependencies which the method will use when invoked.

Let’s look at some examples:

Simple Action

The simplest form is a function with no dependencies wrapped in a class. The only reason these classes exist is that the language does not support functions outside of classes. Java introduced the Runnable interface for this exact reason.

class Adder:
    def calculate(self, a: int, b: int) -> int:
        return a + b
Enter fullscreen mode Exit fullscreen mode

Here we can move the class out of the function altogether.

def add(a: int, b: int) -> int:
    return a + b
Enter fullscreen mode Exit fullscreen mode

You might think you have lost the ability to define an interface that your Adder class implemented so that you supply alternative implementations to consumers — for example, a Calculator interface. You may not be able to create an interface (or a Protocol in the Python world) when you only have a function; however, you can still define an abstraction by creating an alias to a callable type.

Calculator = Callable[[int, int], int]
Enter fullscreen mode Exit fullscreen mode

Dependency Injection

Another example of a class with a single method is one where we use constructor injection to provide dependencies for the method. This enables us to pass only the instance to where we need it without having to pass the dependencies too. Here’s an example of such a class.

class OrderCreator:
    def __init__ (self, order_repository: OrderRepository, email_sender: EmailSender):
        self._order_repository = order_repository,
        self._email_sender = email_sender

    def create_order(self, customer: Customer, items: list[Items]) -> None:
        order_id = OrderID.generate()
        order = Order(order_id, items)
        self._order_repository.save(order)
        confirmation_email = Email(to=customer, subject=f"Thank you for your order ${order_id}")
        self._email_sender.send(confirmation_email)
Enter fullscreen mode Exit fullscreen mode

We would then use it like this:

order_creator = OrderCreator(some_order_repository, some_email_sender)

# at another code location
order_creator.create_order(logged_in_customer, cart.get_items())
Enter fullscreen mode Exit fullscreen mode

We can create this pattern by creating a constructor function which returns a new closure function with the dependencies baked in. We can again use a type alias to define a meaningful named type.

CreateOrder = Callable[[Customer, list[Items]], None]

def new_order_creator(order_repository: OrderRepository, email_sender: EmailSender) -> CreateOrder:
    def create_order(customer: Customer, items: list[Items]) -> None:
        order_id = OrderID.generate()
        order = Order(order_id, items)
        order_repository.save(order)
        confirmation_email = Email(to=customer, subject=f"We have received your order {order_id}")
        email_sender.send(confirmation_email)
    return create_order
Enter fullscreen mode Exit fullscreen mode

We would then use it like this:

create_order = new_order_creator(some_order_repository, some_email_sender)

# at another code location
create_order(logged_in_customer, cart.get_items())
Enter fullscreen mode Exit fullscreen mode

Deferred Dependencies

This pattern is the same as the previous example but used the other way around. In this case, we inject the values in the constructor but provide some dependencies when we invoke the method. Let’s see an example:

class Email:
    def __init__ (self, to: str, subject: str, body: str):
        self._to = to
        self._subject = subject
        self._body = body

    def send(self, sender: EmailSender) -> None:
        sender.send(self._to, self._subject, self._body)
Enter fullscreen mode Exit fullscreen mode

We would then use it like this:

email = Email(
      to=customer.email_address,
      subject=f"We have received your order {order_id}",
      body=f"""
      Dear {customer.name}
      Thank you for your order!
      """
  )

# at another code location
email.send(some_email_sender)
Enter fullscreen mode Exit fullscreen mode

Because the pattern is the same as the previous one, the classless version follows the same pattern:

SendEmail = Callable[[EmailSender], None]

def create_email(to: str, subject: str, body: str) -> SendEmail:
    def send(sender: EmailSender) -> None:
        sender.send(to, subject, body)
    return send
Enter fullscreen mode Exit fullscreen mode

We would then use it like this:

send_email = create_email(
      to=customer.email_address,
      subject=f"We have received your order {order_id}",
      body=f"""
      Dear {customer.name},
      Thank you for your order!
      """
  )

# at another code location
send_email(some_email_sender)
Enter fullscreen mode Exit fullscreen mode

Summary

If you create classes with a single method, you might be better off just creating a function. Using functions might seem like you are not doing OOP, but the model remains the same; an instance wraps a behaviour with a single way to invoke it. Also, you can use type aliases to give these functions named abstractions, which you can use for type signature, descriptiveness and communication. The command classes can be necessary for object-oriented languages because there is no better way to implement them.

Top comments (0)