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
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
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 (
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
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
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
Money in one spot. Second, we’ve introduced a seam in our system to insert behavior appropriate to
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
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
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
Maybe. If you’re interested in using a gem with a few built-in monads, consider
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
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.