Contracts and Value objects in Ruby Fin Analytics

Contracts and Value objects in Ruby

Fin’s codebase is now a couple of years in development — 2+ years of fast-paced engineering and iteration as we evolve our product and scale. As you’d expect, software entropy is beginning to rear its ugly head. In the words of Professor Moody, there is only one real solution to this:

img

The tools and techniques that we can use to stave off software entropy are different for our JS applications and our Ruby backend¹. The JS codebase benefits from the better tooling and more opinionated libraries from that community — React, Redux, Flow, Prettier, ESLint, CommonJS modules etc.

On the other hand, our server-side Ruby on Rails codebase is more vulnerable for a few reasons:

  • ActiveRecord gives you amazing powers, but does not necessarily guide you to create clear seams between models — which can result in a big ball of mud application
  • Ruby’s incredible expressiveness and meta-programmability can be a double-edged sword
  • Ruby doesn’t yet have native support for type hinting or checking to help enforce type constraints and improve documentation.

There has been plenty of discussion around techniques to address these issues. In this post, I will discuss two measures that we have been deploying at Fin to combat these problems:

  1. Contracts for type hinting and documentation
  2. Value objects to extract domain structure out of ActiveRecord models

1: Contracts at Fin

The Contracts.ruby library developed by Adit Bhargava lets us take a baby step towards type hinting in Ruby. It does runtime type validation, and being pure Ruby, it is relatively easy to extend and debug. The contracts library has a great tutorial and RubyDoc, and the Github repo is active, so I won’t bother repeating that material; suffice it to say I strongly recommend using contracts if you are running Ruby in a medium sized codebase:

  • Adding contracts for your functions clarifies your thinking while writing code, so your interfaces are well-defined and not overly lenient in what they accept.
  • We only run contracts in dev and test environments so it has negligible cost in production, but it still ends up being helpful to highlight input interface changes. It also helps us avoid specs that are so stubbed or mocked that they no longer resemble the code they are testing.
  • It is great documentation that does not rot because it isn’t separate from the code.
  • Though contracts does not support type propagation or inference, it is simple and expressive, and it’s easy to imagine how one might migrate from contracts to a true type checker like ‘rdl’ as it gains more support for Rails.

Some other tips about how we use contracts:

  • Do not use the Method Overloading functionality in contracts! Ruby and Rails are magical enough without adding this to the mix.
  • Contracts don’t work in Rails controllers ☹️ If you know how to use contracts in controllers, let us know in the comments!
  • We haven’t settled on a good approach to share and reuse contracts across our codebase. If you have a preferred approach, let me know.
  • We alias Contracts to C for convenience:

C = Contracts

We have a small number of extensions to help us improve our dev experience with contracts.

  • to handle an occasional redefinition of a class (🤔 tell me if you know why this happens!)

# class is_a? seems to get confused sometimes because the class gets redefined somehow (?) Contract.override_validator(:class) do |klass| # We try hard not to do `constantize` because that is slow lambda do |arg| return true if arg.is_a?(klass) return true if arg.class.name == klass.name return true if arg.is_a?(klass.name.constantize) return false end end

view rawis_a.rb hosted with ❤ by GitHub

  • to have contracts accept RSpec doubles, so that we can pass mocks to functions that have contracts:
Contract.validator_strategies.map do |strategy, validator|
  Contract.override_validator(strategy) do |contract|
    lambda do |arg|
      (Object.const_defined?('RSpec::Mocks::Double') && arg.is_a?(RSpec::Mocks::Double)) ||
        (Object.const_defined?('RSpec::Mocks::TestDouble') && arg.is_a?(RSpec::Mocks::TestDouble)) ||
        validator.call(contract).call(arg)
    end
  end
end

view rawrspecs.rb hosted with ❤ by GitHub

  • to help simplify contract violation error messages:
# ActiveRecord classes will print out all the attributes when they error, which
# makes contract errors hard to parse. This is a simple fix to reduce the noise.

if Rails.env.test? || Rails.env.development?
  ActiveRecord::Base.class_eval do
    def inspect
      super.truncate(75, separator: ',', omission: '...>')
    end

    def self.inspect
      super.truncate(50, separator: ',', omission: '...)')
    end
  end
end

view rawinspect.rb hosted with ❤ by GitHub

Custom contracts are easy to define; here are some of the contracts we use:

  • To validate an ActiveRecord::Relation of a specific ActiveRecord model:
    class ActiveRecordRelation < Contracts::CallableClass
      def initialize(klass)
        @klass_contract = Contracts::Eq[klass]
      end

      def valid?(relation)
        Contract.valid?(relation, ActiveRecord::Relation) && Contract.valid?(relation.klass, @klass_contract)
      end
    end

view rawcontracts.rb hosted with ❤ by GitHub

You can declare this contract like ActiveRecordRelation[User] and it will fail for relations that return other models.

Contract None => ActiveRecordRelation[User]
def active_users
  # This will throw a Contract violation error!
  UserState.where(active: true)
end

view rawrelation.rb hosted with ❤ by GitHub

To declare that a method takes an ID of a certain ActiveRecordmodel:

    class ID < Contracts::CallableClass
      def initialize(_)
        # We are ignoring the argument here; but we write the argument as a
        # string instead of a class in practice to avoid circular reference issues
        @actual_contract = Contracts::Or[Contracts::Pos, -1]
      end

      def valid?(val)
        Contract.valid?(val, @actual_contract)
      end
    end

view rawcontracts.rb hosted with ❤ by GitHub

This contract can be declared like ID['User'] (this contract takes a string instead of a class to avoid circular dependencies). Note that ID does not actually validate that the ID is a user ID (because that would require a database call); it only serves as documentation.

  • To validate ActiveRecord enum keys or values:
    class ActiveRecordEnumValue < Contracts::CallableClass
      def initialize(enum)
        @enum_contract = Contracts::Enum[*enum.values]
      end

      def valid?(val)
        Contract.valid?(val, @enum_contract)
      end
    end

    class ActiveRecordEnumKey < Contracts::CallableClass
      def initialize(enum)
        @enum_contract = Contracts::Enum[*enum.keys]
      end

      def valid?(val)
        Contract.valid?(val, @enum_contract)
      end
    end

view rawcontracts.rb hosted with ❤ by GitHub

This contract can be used like ActiveRecordEnumValue[User.roles]

Including Contracts correctly while using ActiveSupport::Concern can sometimes be a process of trial-and-error. Here are some rules that we’ve found to work well:

  1. Always include C as the first include in classes, if you’re using contracts or if you’re including concerns that have contracts
  2. Do not include C in the included do block of ActiveSupport::Concern
  3. Do include C in module ClassMethods in ActiveSupport::Concern
  4. Do include C at the top level of ActiveSupport::Concern, and before extend ActiveSupport::Concern
  5. Do not explicitly include Contracts::Core etc

2 Value objects

Martin Fowler has a great introduction to Value objects here. At Fin, we tend to use Value objects to represent complex data that gets passed to and from Service classes.

For example, consider a service class that mutates Fin entries:

class MutateFinEntry
  include C

  Contract Entry, ArrayOf[AttributeData] => Any
  def initialize(entry, attribute_datas)
    @entry = entry
    @attribute_datas = attribute_datas
  end

  Contract None => Bool
  def run
    ...
  end
end

view rawmutate-fin-entry.rb hosted with ❤ by GitHub

We’re passing an array of data blobs into this service class, and the straightforward way to do so would be to represent each blob as a hash. In this example, the contract type AttributeData would be an alias for a Hash type, something like

AttributeData = {
  attribute_name: String,
  attribute_id: Maybe[Pos],
  attribute_value: Any
}

view rawvalue-struct.rb hosted with ❤ by GitHub

This has a few downsides:

  • Since we don’t enforce contracts in production, this input is completely unconstrained in that environment. The data blobs passed into this service class could have extra keys or missing keys, and it would be left to the application code to handle this.
  • We named the contract type AttributedData, but this name and its associated shape does not propagate to callsites where the data array is defined, or users of @attribute_datas. Contracts are unfortunately very local to the method that they decorate.
  • Our data hashes are mutable — you can imagine the potential bugs (or unmaintainable code) that might arise from working with these raw hashes.

ValueStruct serves as a better implementation for such data blobs, by taking advantage of the contracts library[4] to validate the members of a struct[2].

To use it in this example, we’d first define the AttributeDataValueStruct, as

AttributeData = ValueStruct.define(
  attribute_name: String,
  attribute_id: Optional[Maybe[Pos]],
  attribute_value: Any
)

view rawvalue-struct.rb hosted with ❤ by GitHub

This is analogous to the usual Ruby Struct definition like

Customer = Struct.new(:name, :address)

except, that ValueStruct.define takes the contract for its initializer, and returns a Struct that enforces that contract.

We can instantiate AttributeData as long as we conform to the contract:

a_data = AttributeData.new(attribute_name: "a_name", attribute_value: "123", attribute_id: 456)

b_data = AttributeData.new(attribute_name: "b_name", attribute_value: "789")

c_data = AttributeData.new(attribute_name: "b_name")
=> ArgumentError: Missing non-Optional member: attribute_value in ValueStruct AttributeData

view rawvalue-struct.rb hosted with ❤ by GitHub

We can work with it like a Struct:

[1]> a_data.attribute_value "123"

ValueStruct is dependent on the contracts library to validate its inputs; so it does not validate their types in production (at least the way we use contracts). However, it does have one trick up its sleeve — we built it to enforce the presence of non-optional keys, even though it doesn’t validate their types.

Further, ValueStruct is immutable:

[2]> a_data.attribute_value = "345" ValueStruct::ImmutabilityError: ValueStruct::ImmutabilityError

but we can clone instances and modify them during cloning:

new_a_data = a_data.clone do |params|
  params[:attribute_value] = "789"
end
=> #<struct AttributeData attribute_name="a_name", attribute_id=456, attribute_value="789">

view rawclone-value-struct.rb hosted with ❤ by GitHub

Neat, huh? What’s really cool, and a testament to Ruby’s metaprogramming abilities, is how simple this is to put together.

Let’s take a look under the hood of ValueStruct:

We begin with a recipe for a Struct that takes keyword arguments[3], pulled from SO:

Let’s implement cloning on KeywordStruct:

  class KeywordStruct < Struct
    def clone
      params = self.to_h
      params = params.tap { |f| yield(f) } if block_given?
      self.class.new(**params)
    end
  end

view rawvalue-struct.rb hosted with ❤ by GitHub

ValueStruct.define takes a contract and returns a descendant of KeywordStruct:

  Contract Hash => DescendantOf[KeywordStruct]
  def self.define(**args)
    KeywordStruct.new(*args.keys) do
      include C

      Contract KeywordArgs[**args] => Any
      def initialize(**params)
        super
      end
    end
  end

view rawvalue-struct.rb hosted with ❤ by GitHub

And finally, let’s add immutability:

  class ImmutabilityError < StandardError; end

  Contract Hash => DescendantOf[KeywordStruct]
  def self.define(**args)
    KeywordStruct.new(*args.keys) do
      include C

      Contract KeywordArgs[**args] => Any
      def initialize(**params)
        super
      end

      args.keys.each do |k|
        define_method "#{k}=" do |_val|
          raise ImmutabilityError.new
        end
      end
    end
  end

view rawvalue-struct.rb hosted with ❤ by GitHub

The full implementation is here; it is nearly identical to the snippets above except for an addition to patch a deficiency in the KeywordArgs contract.

Conclusion

We find that annotating functions with contracts makes them much easier to approach as a new engineer, and we can refactor them with more assurance that we understand what they are doing. Value objects are a neat pattern for creating lightweight objects that have shape but not a lot of behavior.

We are also introducing some other techniques around annotating method visibility, using Rubocop to influence developer behavior, and using Service objects to make “thinner” models. I hope to follow up with posts describing these in more detail.

Thanks for reading!

Footnotes

  1. we also have Python (devops and ML) code, and a Swift iOS application, but those are topics for a future post
  2. There is some discussion here on the Contracts repo about contracts-enabled value objects
  3. Ruby 2.5 just added support for keyword arguments to Struct — but this won’t let us set up contracts for the arguments
  4. The dry-types library is another nice candidate for this

– Venky Iyer