Max VelDink

I like Ruby, portable architecture, type systems and mentoring.

Classes are Types

What to classify

When to create a class is a seemingly simple yet somehow complex part of Objected-Oriented programming. Developers spend years learning the nuances of when to spawn off a new class from an old and how to design reusable classes throughout a system, refining these skills through repetition. In Ruby, we love encapsulating behavior into classes, and we place our processes in classes, use classes to map database records and confine our controller actions in class methods. Sandi Metz even introduced some rules of thumb we should consider when working with classes, something that’s been highly influential to me on my OO journey.

One aspect of Ruby that many developers don’t consider is how our standard library primitive types, such as String, Integer, Class, are themselves implemented as objects that can receive messages, just like any of our programmer-defined classes. We can learn lessons from how these are implemented in our code when defining classes.

Further, suppose we start thinking about classes as types in our system, similar to String and Symbol. In that case, this opens the door to designing more useful, reusable classes we can use in more places across the codebase instead of one specific use case. I still would reach for a class for one-off behaviors; much easier to test and communicate the steps we’re taking to perform a particular task. However, I’d challenge us as programmers that, if we’re thinking about the types in our system correctly, we’ll naturally DRY up our code and reduce the number of single-use classes.

What’s in a type

Moving from abstract to concrete, let’s look at an example of some code begging for a type to be introduced. Here we have what I would call a Command or Process class.

class MakeDonation
  # @param amount [Integer]
  # @param currency [String]
  # @param processor [#process]
  def initialize(amount:, currency:, processor: StripeProcessor)
    raise ArgumentError unless amount > 0 && CurrencyValidator.new.valid?(currency)

    @amount = amount
    @currency = currency
    @processor = processor
  end

  # @return [Boolean]
  def call
    processor.process(amount, currency)
  end

  private

  attr_reader :amount, :currency, :processor
end

Here, we are encapsulating the behavior of donating a given amount and currency. We’re also allowing injection of a processor and defaulting to our StripeProcessor. On its face, I think this is reasonably good code. We’re indicating to the caller what we expect with YARD comments, utilizing dependency injection to not couple ourselves exclusively to the StripeProcessor and doing some validations upfront to prevent errors. Most codebases I’ve either written or jumped into have classes that look very similar to this.

One big issue here is that we’ve limited ourselves to the standard library primitives in Ruby (String, Integer, something that is truthy/falsey) instead of allowing ourselves to imagine relevant primitive types for our business. If we’re working on a donations platform, we probably need a first-class type for working with Currency and Money. Without these, we’ll have similar validation scattered throughout our app. We also need to remember what the valid String is for a given currency, and if we’re storing our money Integer’s in cents (you should), or dollars, perhaps. That’s unclear. Also, our initialize relies on both an amount and a currency; it would be lovely if we could package those together. We already have a duck-type for processors, but we might also consider introducing a type to encapsulate the result from processing instead of simply returning true or false.

Let’s introduce a few new types to our codebase to encapsulate some of these behaviors. First, there is already a fantastic money gem that I’d highly advise using for any simple implementation of money. Let’s introduce a Money and a Currency class for our purposes.

class Currency
  USD = "USD".freeze
  ALLOWED_CURRENCIES = [USD]

  # @return [Currency]
  def self.usd
    new(currency_string: USD)
  end

  # @param currency_string [String]
  # @raise [ArgumentError]
  def initialize(currency_string:)
    raise ArgumentError unless ALLOWED_CURRENCIES.include?(currency_string.upcase)

    @currency_string = currency_string
  end
end

class Money
  attr_reader :cents, :currency

  # @param cents [Integer]
  # @param currency [Currency]
  # @raise [ArgumentError]
  def initialize(cents:, currency:)
    raise ArgumentError unless cents > 0

    @cents = cents
    @currency = currency
  end
end

Now, on their face, these are pretty simple classes. However, we’ve accomplished two specific tasks here. First, we’ve encapsulated the error handling for invalid states for Currency and Money in one spot. Second, we’ve introduced a seam in our system to insert behavior appropriate to Currency and Money. For example, if we wanted the proper symbol for a Currency, we could introduce a new method on Currency to select the correct character based on the underlying string. If we needed to add two monies, we could implement the + method on money to handle that appropriately for different currencies. For the more curious, Sorbet has a T::Enum, which is a great candidate for our Currency class, and a T::Struct which is a great candidate for our Money class. I plan on writing a post about these super helpful types soon.

Now, we can clean up our MakeDonation class.

class MakeDonation
  # @param money [Money]
  # @param processor [#process]
  def initialize(money:, processor: StripeProcessor)
    @money = money
    @processor = processor
  end

  # @return [Boolean]
  def call
    processor.process(money)
  end

  private

  attr_reader :money, :processor
end

We’ve moved the complexity of determining the currency and amount values outside this class. The caller needs to instantiate a Currency and Money type before injecting it into this method. This is great; this class simply requires a Money on which to operate. How that money is constructed is not a concern of MakeDonation. Often, we try to introduce validations or error handling in an inappropriate class when we should be thinking about how the caller can instantiate those values for us. In our case, we could have a background job responsible for pulling some information from the database, transforming it into a Money, handling the case where we see a validation issue, and then giving it to our MakeDonation class here. We’ll also need to adjust the contract of our process method slightly to take a Money, but this is a more explicit interface for attempting to process.

Adding a monad type

Another improvement we can make here is the return type of the call method. Currently, we’re returning a truthy or falsey value if we were able to process the donation or not successfully. When we are successful, returning truthy is okay as our caller will likely continue. However, we might lose information about the processed donation, such as a transaction id.

Further, if we return a falsey value from here, we might leave the caller wondering what went wrong. Even worse, we’re often tempted as engineers to raise an exception if something goes wrong, incorrectly thinking this will inform the caller what went wrong. Knowing what exceptions could be potentially raised from a given method is very difficult in Ruby; we’d primarily be at the mercy of documentation comments to indicate what exceptions we should handle.

Fortunately, there’s another option for improving the return value of call. Enter the monad. Monads are widely used in functional programming circles, but don’t let the in-depth Wikipedia article or unique name fool you. At its simplest, a monad is a type with more structure around a result. Let’s consider this simple type we could add to our code.

class DonationResult
  attr_reader :error, :donation_id

  # @param success [Boolean]
  # @param error [String, nil]
  # @donation_id [Integer, nil]
  def initialize(success:, error: nil, donation_id: nil)
    @success = success
    @error = error
    @donation_id = donation_id
  end

  # @return [Boolean]
  def success?
    success
  end

  private

  attr_reader :success
end

Notice that we aren’t doing anything too sophisticated here. We’re introducing a new Ruby class that can be initialized with a success truthy or falsey value and optionally more information about an error message or a donation id on success. Any processor we implement will return a DonationResult instead of a Boolean. The caller can then determine how to proceed with the result and avoids having to rescue any potential exceptions.

We’ve implemented a very simple monad in this post. You can use different monads, such as Success, Failure, and Maybe. If you’re interested in using a gem with a few built-in monads, consider dry-monads.

In conclusion

Our ending MakeDonation class looks like so.

class MakeDonation
  # @param money [Money]
  # @param processor [#process]
  def initialize(money:, processor: StripeProcessor)
    @money = money
    @processor = processor
  end

  # @return [DonationResult]
  def call
    processor.process(money)
  end

  private

  attr_reader :money, :processor
end

Compared with the original version, we haven’t drastically reduced the lines of code relative complexity of the public interface of this class. We have consolidated similar logic inside of classes that represent primitives unique to our system. We’ve attempted to mirror built-in types like String and Symbol so other developers in our codebase have foundational building blocks. Finally, we’ve introduced a monad result to our call method, allowing the caller to decide what to do next and passing along pertinent information.