ValueSemantics—A Gem for Making Value Classes
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 Struct
s,
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:
-
The objects are intended to be immutable. The
#with
method creates new objects based on existing objects, without changing the existing ones. -
Attributes can have defaults.
-
Attributes can be coerced. Nearly-correct attribute values can be automatically converted into actually-correct values. In the example above,
String
s are coerced intoDate
s. -
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:
-
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. -
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.
-
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.
-
Follow conventions
As much as possible, ValueSemantics should conform to existing Ruby standards.
-
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.
-
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 tonil
.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. -
The
values
gem works likeStruct
except that it is immutable, and provides non-destructive updates. It is a little bit more strict thatStruct
, in that it will raise an exception if any attributes are missing.It doesn’t provide defaults, validation, or coercion.
-
The
anima
gem is very similar to thevalues
gem, except thatvalues
builds a class, and Anima builds a module that you mix into your own class.It doesn’t provide defaults, validation, or coercion.
-
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_reader
s, or#initialize
, so implementing a value class requires you to write some boilerplate manually. Adamantium doesn’t provide validation or coercion. -
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. -
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 thedry-struct
anddry-types
gems.Virtus is larger and more complicated than ValueSemantics.
-
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, anddry-struct
classes can be used indry-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:
-
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.
-
It has zero dependencies. Updating the gem is easier, and you don’t have to worry about conflicts as much.
-
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.
-
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
- The Value of Values (mandatory watching)
- Good Value Object Conventions for Ruby
- Raise On Developer Mistake
- RailsConf 2017: How to Write Better Code Using Mutation Testing by John Backus
- The Ruby Module Builder Pattern
- A Review Of Immutability In Ruby
- Ruby Tapas Episode #035: Callable
- Ruby #call method
- The === (case equality) operator in Ruby
Got questions? Comments? Milk?
Shoot an email to [email protected] or hit me up on Twitter (@tom_dalling).