ValueSemantics—A Gem for Making Value Classes

??? words · ??? min read

Today I am announcing ValueSemantics — a gem for making value classes. This is something that I’ve been using in my personal projects for a while, and I think it’s now ready for public use as of v3.0.

In this article, I will give an overview of the gem, but I mainly want to talk about the thought process behind designing a library. We’ll look at:

  • features
  • design goals
  • comparisons to similar gems
  • the module builder pattern
  • case equality
  • callable objects
  • opinions about freezing
  • Integration Continuity™
  • designing DSLs
  • stability in cross-cutting concerns

What’s the Point?

Before getting into the details of this gem, you might be wondering: what are value objects, and why would I want to use them?

In my opinion, the best answer comes from The Value of Values, a talk by Rich Hickey, the creator of the Clojure programming language. The talk is about how software systems are designed, from the perspective of someone who has created a functional programming language. It has a functional programming slant, but the concepts apply to all languages.

I can’t recommend Rich’s talk enough, so if you’re not already familiar with value objects, I recommend watching it before reading the rest of this article.

ValueSemantics Overview

ValueSemantics provides a way to make value classes, with a few additional features. These value classes are like immutable Structs, but they can be strict and explicit about what attributes they allow, and they come with a little bit of additional functionality.

Basic usage looks like this:

class Person
  include ValueSemantics.for_attributes {
    name
    birthday
  }
end

p = Person.new(name: 'Tom', birthday: Date.new(2020, 4, 2))
p.name #=> "Tom"
p.birthday #=> #<Date: 2020-04-02 ((2458942j,0s,0n),+0s,2299161j)>

p2 = Person.new(name: 'Tom', birthday: Date.new(2020, 4, 2))
p == p2 #=> true

The functionality above looks like a Struct with extra steps. This is by design.

More advanced usage might look like this:

class Person
  include ValueSemantics.for_attributes {
    name String, default: 'Anonymous'
    birthday Either(Date, nil), coerce: true
  }

  def self.coerce_birthday(value)
    if value.is_a?(String)
      Date.parse(value)
    else
      value
    end
  end
end

# attribute defaults and coercion
p = Person.new(birthday: '2020-04-02')
p.name #=> 'Anonymous'
p.birthday #=> #<Date: 2020-04-02 ((2458942j,0s,0n),+0s,2299161j)>

# non-destructive updates (creates a new object)
p.with(name: "Dane")
#=> #<Person name="Dane" birthday=#<Date: 2020-04-02 ((2458942j,0s,0n),+0s,2299161j)>>

# attribute validation
p.with(name: nil)
#=> ArgumentError: Value for attribute 'name' is not valid: nil

This example shows the main features of the gem:

  1. The objects are intended to be immutable. The #with method creates new objects based on existing objects, without changing the existing ones.

  2. Attributes can have defaults.

  3. Attributes can be coerced. Nearly-correct attribute values can be automatically converted into actually-correct values. In the example above, Strings are coerced into Dates.

  4. Attributes can be validated. Validators ensure that attribute values are correct, and raise exceptions if they are not.

Check out the documentation for all of the details.

The main design goals for this gem, in order of importance, are:

  1. Raise on developer mistake

    If any attributes are missing, that’s an exception. If there is a typo in the attribute name, that’s an exception. If you said the attribute should be a String but it wasn’t, that’s an exception. ValueSemantics can be used to make reliable guarantees about attributes.

  2. Be extensible

    ValueSemantics is primarily concerned with data types, and those vary from project to project. You should be able to define your own custom validators and coercers easily, and they should be as powerful as the built-in ones.

  3. Be unobtrusive

    While ValueSemantics does have affordances that encourage you to use it as intended, it shouldn’t restrict your choices, or conflict with other code. Most features are optional.

  4. Follow conventions

    As much as possible, ValueSemantics should conform to existing Ruby standards.

  5. Be standalone

    The gem should be light-weight, with minimal (currently zero) dependencies.

Similar Gems

There are already gems for making value classes, or something similar.

  1. Struct

    Struct is built into the Ruby standard library. Struct objects are mutable, which means that they aren’t really value types, but they are similar. It doesn’t have any validation or coercion. Any attributes that aren’t supplied at instantiation will default to nil.

    Struct is specially designed to have good performance in terms of memory and speed. ValueSemantics doesn’t have particularly bad performance, but it’s not super optimised either.

  2. Values

    The values gem works like Struct except that it is immutable, and provides non-destructive updates. It is a little bit more strict that Struct, in that it will raise an exception if any attributes are missing.

    It doesn’t provide defaults, validation, or coercion.

  3. Anima

    The anima gem is very similar to the values gem, except that values builds a class, and Anima builds a module that you mix into your own class.

    It doesn’t provide defaults, validation, or coercion.

  4. Adamantium

    The adamantium gem provides a way to automatically freeze objects after they are initialized. It also has some functionality around memoization and non-destructive updates. You can make value classes using Adamantium, but that is not its primary purpose. It’s primary purpose is to freeze everything.

    Adamantium doesn’t implement equality, attr_readers, or #initialize, so implementing a value class requires you to write some boilerplate manually. Adamantium doesn’t provide validation or coercion.

  5. Eventide Schema

    Eventide Schema is a gem for making mutable, Struct-like classes. It is included as a mixin, and then attributes are defined with class methods. Attributes can have type-checking and default values, but there is no coercion functionality.

  6. Virtus

    Virtus is “attributes on steroids for plain old ruby objects”. It provides a lot of functionality, including validation and coercion. Virtus objects are mutable by default, but Virtus.value_object creates immutable objects. Virtus is a predecessor of the dry-struct and dry-types gems.

    Virtus is larger and more complicated than ValueSemantics.

  7. dry-struct

    The dry-struct gem is probably the most popular out of all of these options, and the most similar to ValueSemantics in terms of features. It has full-featured validation and coercion provided by dry-types, and dry-struct classes can be used in dry-types schemas. It has optional functionality for freezing objects.

    ValueSemantics is a simpler and has less features than dry-struct. dry-struct is integrated with the dry-rb ecosystem, whereas ValueSemantics is standalone with no dependencies.

See A Review Of Immutability In Ruby for more information about these gems.

It’s a Module Builder

ValueSemantics is an implementation of the module builder pattern, as opposed to a class builder or a base class with class methods.

Mixin modules are more flexible than forcing users to inherit from something. Maybe you are already forced to inherit from something else, and Ruby doesn’t have multiple inheritance. Using a mixin avoids this situation.

The module builder pattern allows any of the methods to be overridden. This is not always true of the other approaches, because they often define methods directly on the class, not on a superclass, which means that you would need hacks like prepend to override them.

Using a mixin also changes the feeling of ownership, in a subtle way. I want ValueSemantics to feel like something that could enhance my own classes. Classes that are generated by Struct or a gem don’t feel like my own classes — they feel like someone else’s classes that I’m choosing to use. I’m also not a fan of reopening classes, like the way that Struct requires if you want to add a method.

Validation is Case Equality

You could be excused for thinking that attribute validators like String, Integer, nil, etc., are special in ValueSemantics. Nope! There is no special logic for handling these basic Ruby types. Attribute validation works via standard case equality.

The validators in ValueSemantics can be any object that implements the #=== method. Plenty of things in Ruby already implement this method — Module, Regexp, Range, and Proc, just to name a few. Anything that works in a case expression will work as a validator.

With no special handling for built-in types, your own custom validators are first-class citizens. The small number of built-in validators are built on top of ValueSemantics, not integrated with it.

Gems like qo are already compatible with ValueSemantics, because they conform to the same case equality standard.

require 'qo'

class Person
  include ValueSemantics.for_attributes {
    # using Qo matcher as a validator
    age Qo[Integer, 1..120]
  }
end

Person.new(age: 150)
#=> ArgumentError: Value for attribute 'age' is not valid: 150

Callable Objects

Coercers are just callable objects (a.k.a function objects). This is another Ruby standard, already implemented by Proc, Method, and many functional-style gems.

This gives us various ways to use existing Ruby functionality, without necessarily needing to write a custom coercer class.

class Whatever
  include ValueSemantics.for_attributes {
    # use existing class methods
    updated_at coerce: Date.method(:parse)

    # use a lambda
    some_json coerce: ->(x){ JSON.parse(x) }

    # use Symbol#to_proc
    some_string coerce: :to_s.to_proc

    # use Hash#to_proc
    dunno coerce: { a: 1, b: 2 }.to_proc
  }
end

Whatever.new(
  updated_at: '2018-12-25',
  some_json: '{ "hello": "world" }',
  some_string: [1, 2, 3],
  dunno: :b,
)
#=> #<Whatever
#     updated_at=#<Date: 2018-12-25 ((2458942j,0s,0n),+0s,2299161j)>
#     some_json={"hello"=>"world"}
#     some_string="[1, 2, 3]"
#     dunno=2
#     >

Default generators are also callable objects, allowing you to do similar things:

class Person
  include ValueSemantics.for_attributes {
    created_at default_generator: Time.method(:now)
  }
end

Person.new
#=> #<Person created_at=2018-12-24 12:21:55 +1000>

And again, since there is no special handling for built-ins, your custom coercers and default generators are first-class citizens.

To Freeze or not to Freeze

There is ongoing debate about the best way to do immutability in Ruby. A lot of this debate revolves around what things should be frozen, and how they should be frozen.

ValueSemantics takes an approach to immutability that I call “no setters”. The “no setters” approach does not freeze anything. Instead of enforcing immutability by freezing, you just don’t provide any methods that mutate the object. The objects could be frozen, but it’s completely optional.

ValueSemantics is designed with affordances that make immutability feel natural. If you use the object the way that it is intended to be used, through its public methods, then it is effectively immutable. However, the gem doesn’t restrict you from writing mutable methods if you are determined to do so. One of the design goals is to be unobtrusive, and freezing other people’s objects is obtrusive behaviour.

This is a “sharp knife” approach to immutability. The gem tries to help you make good choices, but it doesn’t restrict you from making bad choices. There might be legitimate reasons why you need to mutate the object, so I want to leave that avenue open to you.

My current opinion on this topic is that it’s fine to freeze your own internal objects, especially if they are small or private, but freezing external objects is a no no. If you’re not 100% sure about where an object came from, then don’t freeze it. Mutations on external objects should be picked up in code review, not enforced by the language or this gem. Some of the internals of ValueSemantics are frozen, but it should never freeze one of your objects.

The guilds feature of Ruby 3 will likely come with new functionality for deep freezing objects. When that happens, I may revisit this decision. I’m also open to adding freezing to ValueSemantics as an optional feature.

Integration Continuity

Sometimes you want to do some coercion that is a little bit complicated, but it’s specific to just one class, so you don’t want to write a separate, reusable coercer just yet. You could do this by overriding initialize, but I think this is a fairly common scenario, so I wanted to provide a nicer way to do it. This provides a step in between no coercion and reusable coercion objects.

class Person
  include ValueSemantics.for_attributes {
    birthday coerce: true
  }

  def self.coerce_birthday(value)
    if value.is_a?(String)
      DateTime.strptime(value, "%a, %d %b %Y %H:%M:%S %z")
    else
      value
    end
  end
end

I stole this idea of “integration continuity” from Casey Muratori’s talk Designing and Evaluating Reusable Components. See the section from 6:28 to 11:45.

This is a small example of what I call integration continuity. When we use a framework or a library, we usually start with a simple integration. As our software grows over time, we tend to integrate more and more features of the library. Sometimes we look at integrating new functionality, and discover that it’s unnecessarily difficult, and the resulting implementation is overkill compared to our requirements. This gem is too small for integration discontinuities to be a much of a problem, but nevertheless I wanted a smooth experience when adopting each feature.

This is why everything is optional in ValueSemantics. You can start using it with no defaults, no validators, and no coercers, as if it was a simple immutable Struct-like object. Then you can start using the other features as you need to. Every time you choose to use an additional feature, the transition should be easy.

DSL is Icing

In my opinion, a DSL should always be the icing on the cake. It should be a rich, but thin, layer on top of a stable, well-designed base. You don’t want pockets of icing in random locations throughout the cake.

Random pockets of icing in a cake actually sound delicious.

In that spirit, the DSL in ValueSemantics is completely optional. These two classes are exactly the same:

class Person1
  include ValueSemantics.for_attributes {
    name String
    age Integer
  }
end

class Person2
  include ValueSemantics.bake_module(
    ValueSemantics::Recipe.new(attributes: [
      ValueSemantics::Attribute.new(name: :name, validator: String),
      ValueSemantics::Attribute.new(name: :age, validator: Integer),
    ])
  )
end

In fact, ValueSemantics.for_attributes is just a shorthand way to write this:

recipe = ValueSemantics::DSL.run {
  name String
  age Integer
}

ValueSemantics.bake_module(recipe)

The DSL is not required, and is completely segregated from the rest of the gem. It is just there to make the attributes read more nicely, by removing unnecessary implementation details like ValueSemantics::Attribute.

This also gels well with the concept of integration continuity. It enables super advanced integrations with the gem, like automatically generating value classes based on database column information at run time. I don’t expect people to implement anything that complicated, but it is possible to do, as a side effect of good design.

Stability

Value classes are a something of a cross-cutting concern. There is no dedicated part of your app where they all live. You shouldn’t be making an /app/values directory in your Rails apps, for example. They can be used anywhere.

The term shotgun surgery means to implement something by making small changes in many different places. It is a code smell, indicating that code might have been copy-pasted many times, or that there might be design problems.

This is an important design consideration, as a gem author. If a bug is introduced, that bug could affect anywhere and everywhere in the app. If backwards-incompatible changes are introduced, then updating the gem requires shotgun surgery.

These considerations relate to the stable dependencies principle, which states that code should only depend upon things that are more stable than itself. If the Struct class had backwards-incompatible changes in every release of Ruby, it would be a nightmare to use. But in reality, if you wrote some code using Struct in Ruby 1.8, it would still work today in Ruby 2.6, more than 15 years later. That is the level of stability that I’m aiming for.

I’m addressing these considerations in the following ways:

  1. The gem is finished. You could copy and paste ValueSemantics into your project and never update it, if that’s what you want. I do expect to add a few small things, but if you don’t need them, there is no need to update.

  2. It has zero dependencies. Updating the gem is easier, and you don’t have to worry about conflicts as much.

  3. All future versions should be backwards compatible. Because the gem is “finished,” I don’t see any reason to introduce breaking changes. You don’t need to do shotgun surgery if the public API never changes.

  4. It is tested more thoroughly than the typical Ruby gem or application. ValueSemantics has 100% mutation coverage, and is tested across multiple Ruby versions. Mutation coverage is like line coverage on steriods.

Conclusion

ValueSemantics is a gem for making value classes, with attribute defaults, validation, and coercion. It builds modules that you include into your own classes, using an optional DSL. Most of the features are extensible, because they take any object that conforms to Ruby standards, like #=== and #call.

The design was informed by various ideas, such as using exceptions to catch developer mistakes, extensibility, being unobtrusive, integration continuity, stability, thin DSLs, and “no setters” immutability.

Give it a try, and let me know what you think.

Further Resources

Got questions? Comments? Milk?

Shoot an email to [email protected] or hit me up on Twitter (@tom_dalling).

← Previously: Forms—Comparing Django to Rails

Next up: Dream Code First →

Join The Pigeonhole

Don't miss the next post! Subscribe to Ruby Pigeon mailing list and get the next post sent straight to your inbox.