What are singleton classes
Singleton is a software design pattern which restricts instances of the class to a set of one, “single” object. By having just a single named object we can provide ourselves easy access to things like shared database connection, application configuration, OS environment, etc.
There’s a fair amount of critique going on around singletons as such. Some radicals consider it to be an anti-pattern, stating that “singletons” are just a fancier name for “globals”. The others rightfully note that despite being super-handy, singletons cause a bunch of problems during creation of tests.
We know that in modern development tests are almost everything. This article offers a practical approach to coding Ruby singleton classes in a transparent and testable way.
TL;DR
You can skip lengthy step-by-step explanations and jump right to the solution.
Drafting it out
Suppose we’re designing a simple Internet client dealing with admin section of our website, deployed at multiple domains. In order to keep our code DRY, we need a “mode object”, accessible from anywhere, implementing the functionality as specified:
- Read domain name of our website from
DOMAIN=
environment variable. This value is required. - Read website language from
LANGUAGE=
environment variable. If omitted, default to"de"
. - Read HTTP security setting from
INSECURE=
environment variable. If omitted, default to secure (HTTPS). - Compute a ready-to-use admin page URL based on Items 1, 2 and 3.
- Be sane, raise meaningful errors on missing/incorrect environment variable values.
Implementation requirements
The “mode object” we’re building should:
- Be easily accessible from anywhere in the project code, be singleton object.
- Evaluate properties upon request and memoize their computed values. Contain no procedural initialization sequences.
- Be easy to test by RSpec without major pain or magic.
Just in case, here’s your TL;DR link again.
The “plain module” way
In Ruby all objects of type Module
are singletons by definition. So the easiest and the most straightforward way is just to have a module Mode
with a number of singleton (self.*
) methods in it. Let’s start by adding the language property to mode.rb
:
require "active_support/core_ext/object/blank"
module Mode
# @return [String]
def self.language
@language ||= if (s = ENV["LANGUAGE"]).present?
s
else
"de"
end
end
end
Now, anywhere in the project we can call Mode.language
and get a nice non-blank string defaulting to "de"
as specified. As we agreed to be test-driven, let’s back it with some RSpec examples (mode_spec.rb
):
RSpec.describe Mode do
describe ".language" do
subject { described_class.language }
context "when default" do
it do
expect(ENV).to receive(:[]).with("LANGUAGE").and_return(nil)
is_expected.to eq "de"
end
end
context "when set via ENV" do
it do
expect(ENV).to receive(:[]).with("LANGUAGE").and_return("fr")
is_expected.to eq "fr"
end
end
end
end
, and run our tests:
$ bundle exec rspec spec/lib/mode_spec.rb Mode .language when default is expected to eq "de" when set via ENV is expected to eq "fr" (FAILED - 1) Failures: 1) Mode.language when set via ENV is expected to eq "fr" Failure/Error: is_expected.to eq "fr" expected: "fr" got: "de" (compared using ==) Finished in 0.01787 seconds (files took 0.13165 seconds to load) 2 examples, 1 failure
No matter in which order the examples are run, one of them always fails. However, they’re okay when being run individually:
$ bundle exec rspec spec/lib/mode_spec.rb:5 Mode .language when default is expected to eq "de" Finished in 0.00138 seconds (files took 0.12877 seconds to load) 1 example, 0 failures $ bundle exec rspec spec/lib/mode_spec.rb:12 Mode .language when set via ENV is expected to eq "fr" Finished in 0.00589 seconds (files took 0.12801 seconds to load) 1 example, 0 failures
I think it’s obvious who the culprit is. @language ||=
at line 6 of mode.rb
is causing a “global state” side effect.
Intermediate conclusion:
- Such singleton module is easy to implement in plain Ruby with no dependencies.
- Requirement #3 (“be easy to test”) is not met due to side effect baked into the implementation.
The Singleton
way
There’s a Singleton
module in Ruby standard library, bundled since version 2.0.0. To be frank, the need of this module, as well as the motives of people using it, are total mystery to me. However, since it’s relevant to our topic, let’s consider it, too.
mode.rb
based on Singleton
might look like this (for brevity we yet support language
property only):
require "active_support/core_ext/object/blank"
require "singleton"
class Mode
include Singleton
# @return [String]
def language
@language ||= if (s = ENV["LANGUAGE"]).present?
s
else
"de"
end
end
end
Line 4 states that Mode
is now a class. Which we can’t instantiate though, Singleton
has taken it away:
>> Mode.new Traceback (most recent call last): … 1: from (irb):2 NoMethodError (private method `new' called for Mode:Class)
Let’s proceed with tests (mode_spec.rb
):
RSpec.describe Mode do
describe ".language" do
subject { described_class.instance.language } # (1)
context "when default" do
it do
expect(ENV).to receive(:[]).with("LANGUAGE").and_return(nil)
is_expected.to eq "de"
end
end
context "when set via ENV" do
it do
expect(ENV).to receive(:[]).with("LANGUAGE").and_return("fr")
is_expected.to eq "fr"
end
end
end
end
Running it we get a:
$ bundle exec rspec spec/lib/mode_spec.rb Mode .language when default is expected to eq "de" when set via ENV is expected to eq "fr" (FAILED - 1) … Finished in 0.0178 seconds (files took 0.1335 seconds to load) 2 examples, 1 failure
Intermediate conclusion:
- The side effect problem stays, Requirement #3 still not met.
Mode
is a “class”, just in case anyone cares.- We have to access object properties via
Mode.instance.property
(line 3, mark (1)), meaning that Requirement #1 is satisfied quite poorly.
The “compound module” way
Considering the yet unresolved side effect problem, let’s outline the solution that will meet our requirements, but be testable in the first place.
- The caller should access properties via
Mode.property
. Period.- Thus,
Mode
should be a plain module.
- Thus,
- In order to mitigate the side effect problem in tests, properties should be enclosed in a regular object, which we can create and discard as needed.
- Thus,
Mode
property container should be a plain instantiatable class.
- Thus,
- There should be a bridge between
Mode
and its property container instance, implementing the singleton pattern altogether.
Let’s begin with property container class:
require "active_support/core_ext/object/blank"
module Mode
class Instance
# @return [String]
def language
@language ||= if (s = ENV["LANGUAGE"]).present?
s
else
"de"
end
end
end # Instance
end
, and its testing counterpart (mode_spec.rb
):
RSpec.describe Mode::Instance do
describe "#language" do
subject { described_class.new.language }
context "when default" do
it do
expect(ENV).to receive(:[]).with("LANGUAGE").and_return(nil)
is_expected.to eq "de"
end
end
context "when set via ENV" do
it do
expect(ENV).to receive(:[]).with("LANGUAGE").and_return("fr")
is_expected.to eq "fr"
end
end
end
end
Let’s test the property container:
$ bundle exec rspec spec/lib/mode_spec.rb Mode::Instance #language when default is expected to eq "de" when set via ENV is expected to eq "fr" Finished in 0.00954 seconds (files took 0.12592 seconds to load) 2 examples, 0 failures
Finally! Property logic and tests are okay, now let’s delegate Mode.language
calls to an instance of the property container (mode.rb
):
require "active_support/core_ext/module/delegation"
require "active_support/core_ext/object/blank"
module Mode
class << self
delegate :language, to: :instance
def instance
@instance ||= Instance.new
end
end
class Instance
# …
end
end
Delegation test might look like this:
RSpec.describe Mode do
describe "delegation" do
[
:language,
].each do |m|
describe ".#{m}" do
it "is delegated" do
expect(described_class.instance).to respond_to(m) # (1)
expect(described_class.instance).to receive(m).and_return(:signature) # (1)
expect(described_class.public_send(m)).to eq :signature # (1)
end
end
end
end
end
Running it we get a:
$ bundle exec rspec spec/lib/mode_spec.rb:1 Mode delegation .language is delegated Finished in 0.00498 seconds (files took 0.25175 seconds to load) 1 example, 0 failures
Delegation testing listed above might seem a bit redundant, esp. considering that there’s a delegate_method
matcher in Shoulda. In fact, lines 8-10, mark (1) ensure that entire delegation chain is not broken. delegate_method
alone doesn’t give us that degree of control and often lets silly refactoring mistakes by.
Intermediate conclusion:
- We’re able to access object properties via
Mode.property
, as projected. - We can test property logic easily and without any side effect issues.
Comparing the three
Considering the implementation requirements formulated earlier, let’s compare our three solutions:
“plain module” | Singleton -based |
“compound module” | winner | |
---|---|---|---|---|
Easy access to properties | good | poor | good | “plain” and “compound” |
Property memoization | good | good | good | all |
Testability | poor | poor | good | “compound” |
Complete solution
Now that we’re confident that the “compound module” solution gives us what we want, let’s finalize it to make Mode
fully functional:
require "active_support/core_ext/module/delegation"
require "active_support/core_ext/object/blank"
module Mode
class << self
delegate *[
:admin_url,
:domain,
:insecure?,
:language,
:protocol,
], to: :instance
def instance
@instance ||= Instance.new
end
end
class Instance
# Admin page URL.
# @return [String]
def admin_url
@admin_url ||= "#{protocol}://#{domain}/#{language}/admin/"
end
# The domain part of the URL.
# @return [String]
def domain
@domain ||= if (s = ENV[vn = "DOMAIN"]).present?
s
else
raise "Environment variable must be set: #{vn}"
end
end
# HTTP protocol security setting.
# @return [Boolean] Default is <tt>false</tt>.
def insecure?
ENV["INSECURE"] == "!" # (1)
end
# Website language.
# @return [String] Default is <tt>"de"</tt>.
def language
@language ||= if (s = ENV["LANGUAGE"]).present?
s
else
"de"
end
end
# Request protocol string.
# @return [String] Default is <tt>"https"</tt> unless {#insecure?}.
def protocol
@protocol ||= if insecure?
"http"
else
"https"
end
end
end # Instance
end
A couple notes on insecure?
at line 39, mark (1). Environment variable INSECURE=
is treated as “flaggy”. Also property value is not memoized since it’s a boolean. There is a solution to memoize booleans in Ruby, but it’s a bit out of the scope of this article.
Here’s the complete test code:
RSpec.describe Mode do
describe "delegation" do
[
:admin_url,
:domain,
:insecure?,
:language,
:protocol,
].each do |m|
describe ".#{m}" do
it "is delegated" do
expect(described_class.instance).to respond_to(m) # (1)
expect(described_class.instance).to receive(m).and_return(:signature) # (2)
expect(described_class.public_send(m)).to eq :signature # (3)
end
end
end
end
end
RSpec.describe Mode::Instance do
def stub_env(var, value = nil)
expect(ENV).to receive(:[]).with(var).and_return(value)
end
describe "#admin_url" do
let(:instance) { described_class.new }
subject { instance.admin_url }
it do
expect(instance).to receive(:domain).and_return("site.com")
expect(instance).to receive(:insecure?).and_return(true)
expect(instance).to receive(:language).and_return("fr")
is_expected.to eq "http://site.com/fr/admin/"
end
end # describe "#admin_url"
describe "#domain" do
subject { described_class.new.domain }
context "when default" do
it do
stub_env("DOMAIN")
expect { subject }.to raise_error("Environment variable must be set: DOMAIN")
end
end
context "when blank" do
it do
stub_env("DOMAIN", " ")
expect { subject }.to raise_error("Environment variable must be set: DOMAIN")
end
end
context "when set via ENV" do
it do
stub_env("DOMAIN", "site.com")
is_expected.to eq "site.com"
end
end
end # describe "#domain"
describe "#insecure?" do
subject { described_class.new.insecure? }
context "when default" do
it do
stub_env("INSECURE")
is_expected.to eq false
end
end
context "when set via ENV" do
it do
stub_env("INSECURE", "!")
is_expected.to eq true
end
end
end # describe "#insecure?"
describe "#language" do
subject { described_class.new.language }
context "when default" do
it do
stub_env("LANGUAGE")
is_expected.to eq "de"
end
end
context "when set via ENV" do
it do
stub_env("LANGUAGE", "fr")
is_expected.to eq "fr"
end
end
end # describe "#language"
end
Conclusion
Implementing custom singleton classes using the “compound module” approach lets us benefit from easy access while not sacrificing testability.