Sustainable Web Development With Ruby On Rails P2.0
Sustainable Web Development With Ruby On Rails P2.0
Acknowledgements 1
I Introduction
7 HTML Templates 93
7.1 Use Semantic HTML . . . . . . . . . . . . . . . . . . . . . . 93
7.1.1 Build Views by Applying Meaningful Tags to Content 94
7.1.2 Use <div> and <span> for Styling . . . . . . . . . . . 95
7.2 Ideally, Expose One Instance Variable Per Action . . . . . . . 100
7.2.1 Name the Instance Variable After the Resource . . . 101
7.2.2 Reference Data, Global Context, and UI State are
Exceptions . . . . . . . . . . . . . . . . . . . . . . . 106
7.3 Wrangling Partials for Simple View Re-use . . . . . . . . . . 107
7.3.1 Partials Allow Simple Code for Simple Re-use . . . . 107
7.3.2 Reference Only Locals in Partials . . . . . . . . . . . 108
7.3.3 Partials Should Use Strict Locals . . . . . . . . . . . . 109
7.3.4 Use Default Values for Strict Locals to Simplify Partial
APIs . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
7.4 Use the View Component Library for Complex UI Logic . . . 111
7.4.1 Creating a View Component . . . . . . . . . . . . . . 112
7.4.2 Testing Markup from a Unit Test . . . . . . . . . . . 115
7.4.3 Deciding Between a Partial or a View Component . . 117
7.5 Just Use ERB . . . . . . . . . . . . . . . . . . . . . . . . . . 117
8 Helpers 119
8.1 Don’t Conflate Helpers with Your Domain . . . . . . . . . . 120
8.2 Helpers are Best at Exposing Global UI State and Generating
Markup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123
8.2.1 Global UI Logic and State . . . . . . . . . . . . . . . 123
8.2.2 Small, Inline Components . . . . . . . . . . . . . . . 124
8.3 Configure Rails based on Your Strategy for Helpers . . . . . 126
8.3.1 Consolidating Helpers in One File . . . . . . . . . . . 126
8.3.2 Configure Helpers to Be Actually Modular . . . . . . 127
8.3.3 Use helper_method to Share Logic Between Views and
Controllers . . . . . . . . . . . . . . . . . . . . . . . 128
8.4 Use Rails’ APIs to Generate Markup . . . . . . . . . . . . . . 128
8.5 Helpers Should Be Tested and Thus Testable . . . . . . . . . 130
8.6 Tackle Complex View Logic with Better Resource Design or
View Components . . . . . . . . . . . . . . . . . . . . . . . . 133
8.6.1 Presenters Obscure Reality and Breed Inconsistency . 133
8.6.2 Custom Resources and Active Model Create More Con-
sistent Code . . . . . . . . . . . . . . . . . . . . . . . 134
8.6.3 View Components can Render Entire Pages When
Logic is Complex . . . . . . . . . . . . . . . . . . . . 136
9 CSS 139
9.1 Adopt a Design System . . . . . . . . . . . . . . . . . . . . . 140
9.2 Adopt a CSS Strategy . . . . . . . . . . . . . . . . . . . . . . 141
9.2.1 A CSS Framework . . . . . . . . . . . . . . . . . . . 142
9.2.2 Object-Oriented CSS . . . . . . . . . . . . . . . . . . 142
9.2.3 Functional CSS . . . . . . . . . . . . . . . . . . . . . 144
9.3 Create a Living Style Guide to Document Your Design System
and CSS Strategy . . . . . . . . . . . . . . . . . . . . . . . . 147
18 Controllers 313
18.1 Controller Code is Configuration . . . . . . . . . . . . . . . 313
18.2 Don’t Over-use Callbacks . . . . . . . . . . . . . . . . . . . . 314
18.3 Controllers Should Convert Parameters to Richer Types . . . 316
18.4 Don’t Over Test . . . . . . . . . . . . . . . . . . . . . . . . . 318
18.4.1 Writing a Controller Test . . . . . . . . . . . . . . . . 318
18.4.2 Implementing a Basic Confidence-checking System . 320
18.4.3 Avoiding Duplicative Tests . . . . . . . . . . . . . . . 323
19 Jobs 325
19.1 Use Jobs To Defer Execution or Increase Fault-Tolerance . . 325
19.1.1 Web Workers, Worker Pools, Memory, and Compute
Power . . . . . . . . . . . . . . . . . . . . . . . . . . 326
19.1.2 Network Calls and Third Parties are Slow . . . . . . 326
19.1.3 Network Calls and Third Parties are Flaky . . . . . . 328
19.1.4 Use Background Jobs Only When Needed . . . . . . 328
19.2 Understand How Your Job Backend Works . . . . . . . . . . 329
19.2.1 Understand Where and How Jobs (and their Argu-
ments) are Queued . . . . . . . . . . . . . . . . . . . 329
19.2.2 Understand What Happens When a Job Fails . . . . . 330
19.2.3 Observe the Behavior of Your Job Backend . . . . . . 331
19.3 Sidekiq is The Best Job Backend for Most Teams . . . . . . . 331
19.4 Queue Jobs Directly, and Have Them Defer to Your Business
Logic Code . . . . . . . . . . . . . . . . . . . . . . . . . . . 335
19.4.1 Do Not Use Active Job - Use the Job Backend Directly 335
19.4.2 Job Code Should Defer to Your Service Layer . . . . 337
19.5 Job Testing Strategies . . . . . . . . . . . . . . . . . . . . . 339
19.6 Jobs Will Get Retried and Must Be Idempotent . . . . . . . . 342
24 Operations 425
24.1 Why Observability Matters . . . . . . . . . . . . . . . . . . . 425
24.2 Monitor Business Outcomes . . . . . . . . . . . . . . . . . . 427
24.3 Logging is Powerful . . . . . . . . . . . . . . . . . . . . . . . 428
24.3.1 Include a Request ID in All Logs . . . . . . . . . . . . 430
24.3.2 Log What Something is and Where it Came From . . 433
24.3.3 Use Current to Include User IDs . . . . . . . . . . . . 436
24.4 Manage Unhandled Exceptions . . . . . . . . . . . . . . . . 436
24.5 Measure Performance . . . . . . . . . . . . . . . . . . . . . 438
24.6 Managing Secrets, Keys, and Passwords . . . . . . . . . . . . 440
IV Appendices
Colophon 467
Acknowledgements
If there were no such thing as Rails, this book would be, well, pretty strange.
So I must acknowledge and deeply thank DHH and the Rails core team for
building and maintaining such a wonderful framework for all of us to use.
I have to thank my wife, Amy, who gave me the space and encouragement
to work on this. During a global pandemic. When both of us were briefly
out of work. And we realized our aging parents require more care than we
thought. And when we got two kittens named Carlos Rosario and Zoni. And
when we bought a freaking car. And when I joined a pre-seed startup. It’s
been quite a time.
I also want to thank the technical reviewers, Noel Rappin, Chris Gibson,
Zach Campbell, Lisa Sheridan, Raul Murciano, Geoff The, and Sean Miller.
Also special thanks to Brigham Johnson for identifying an embarrassing
number of typos.
1
Changes from Previous
Versions
This book is intended to be somewhat timeless, and able to be used as a
reference. Much of what’s in here hasn’t changed and I wouldn’t expect it
to. That said, some things have changed, and this section captures them.
Dec 4, 2023
This is a more substantial update that previous updates. Chapter numbers
refer to the PDF or printed book’s numbering. e-book numbering continues
to be a byzantine nightmare.
3
• General Changes
– Updated for Rails 7.1.
– Updated for Ruby 3.2
– Added explicit language in each section about where to find the
sample code for that section.
– New cover
• Chapter 1
– Update my experience, given the passage of time.
• Chapter 4
– Remove mention of Spring and Listen, since they aren’t included
and haven’t been in a few versions.
– Remove mention of having to add the rexml gem, since selenium-
webdriver brings it in.
– Change bin/run to bin/dev, since this matches what Rails does
(sometimes).
– Remove mention of having to bundle update Thor.
– Added help flags to the various bin/ scripts.
• Chapter 5
– Added a new section that references “Patterns of Enterprise Ap-
plication Architecture”, since this where the active record pattern
originated.
• Chapter 7
– Recommend the use of View Components
– Recommend strict locals for partials
• Chapter 8
– Clarify that helpers can be made to be modular, and discuss
configuring Rails to either treat them that way or to not generate
falsely-modular helpers.
– More strongly discourage presenter-like libraries, and remove a
lot of content around managing them.
– In place of presenters, discuss how using Active Model or View
Components can manage complexity instead of gobs of helpers.
• Chapter 9
– Clear warning about Tailwind’s lack of built-in design system and
what you should consider if adopting it.
• Chapter 11
– Qualify the recommendation for Hotwire given that 37 Signals
have made it clear they will change it however they like whenever
they like.
4
• Chapter 15
– Reference “Patterns of Enterprise Application Architecture” and
its definition of a service layer, which is what this chapter de-
scribes.
– Make it clear that the term “Service Objects” is not a service layer
and is actually just another name for the command pattern (and
that you should not use this pattern).
• Chapter 16
– Replace use of before_validation callback with the new
normalizes macro
– Make a stronger case for not using callbacks by clarifying exactly
what they do and are for.
• Chapter 17
– Replace the re-usable partial with a View Component in the
example.
• Chapter 23
– Show code to monkey-patch Thor to make it useful for Rails
generators.
– Discourage the use of app templates in favor of template reposi-
tories.
• Chapter 24
– Use CurrentAttributes to store information for the log instead
of thread local storage.
– Discuss the need to revisit security practices, along with an anec-
dote from a previous job.
• Appendix A
– Re-work Docker stuff based on updated learnings and code.
– Explainer on getting your own shell aliases or software into the
dev container.
5
PART
I
introduction
1
9
So this defines sustainability, but why is it important?
10
to ask you to create a design system to facilitate building user interfaces
more quickly. And no one is going to require that your database have
referential integrity.
The features of the software are merely one input into what software gets
built. They are a significant one just not the only one. To make better
technical decisions, you need access to more information than simply what
someone wants the software to do.
The more information you can get access to the better, because all of this
feeds into your technical decision-making and can tell you just how sustain-
able your app needs to be. If there will be an influx of less experienced
developers, you might make different decisions than if the team is only
hiring one or two experienced specialists.
Armed with this sort of information, you can make technical decisions as
part of an overall strategy. For example, you may want to spend several
days setting up a more sustainable development environment. By pointing
to the company’s growth projections and your team’s hiring plans, that work
can be easily justified (see the sidebar “Understanding Growth At Stitch Fix”
on the next page for a specific example of this).
If you don’t have the information about the business, the team, or anything
other than what some user wants the software to do, you aren’t set up to do
sustainable development. But it doesn’t mean you shouldn’t ask anyway.
People who don’t have experience writing software won’t necessarily intuit
that such information is relevant, so they might not be forthcoming. But
you’d be surprised just how much information you can get from someone by
asking.
Whatever the answers are, you can use this as part of an overall technical
strategy, of which sustainability is a part. As you read this book, I’ll talk about
the considerations around the various recommendations and techniques.
They might not all apply to your situation, but many of them will.
Which brings us to the set of assumptions that this book is based on. In
other words, what is the situation in which sustainability is important and
in which this book’s recommendations apply?
11
Understanding Growth At Stitch Fix
During my first few months at Stitch Fix, I was asked to help improve the
operations of our warehouse. There were many different processes and we
had a good sense of which ones to start automating. At the time, there was
only one application—called H ELLBLAZER—and it served up stitchfix.com.
If I hadn’t been told anything else, the simplest thing to do would’ve
been to make a /warehouse route in H ELLBLAZER and slowly add features
for the associates there. But I had been told something else.
Like almost everyone at the company, the engineering team was told—
very transparently—what the growth plans for the business were. It needed
to grow in a certain way or the business would fail. It was easy to extrapolate
from there what that would mean for the size of the engineering team, and
for the significance of the warehouse’s efficiency. It was clear that a single
codebase everyone worked in would be a nightmare, and migrating away
from it later would be difficult and expensive.
So, we created a new application that shared H ELLBLAZER’s database.
It would’ve certainly been faster to add code to H ELLBLAZER directly, but
we knew doing so would burn us long-term. As the company grew, the
developers working on warehouse software were fairly isolated since they
worked in a totally different codebase. We replicated this pattern and, after
six years of growth, it was clearly the right decision, even accounting for
problems that happen when you share a database between apps.
We never could’ve known that without a full understanding of the com-
pany’s growth plans, and long-term vision for the problems we were there
to solve.
1.4 Assumptions
This book is prescriptive, but each prescription comes with an explanation,
and all of the book’s recommendations are based on some key assumptions
that I would like to state explicitly. If your situation differs wildly from the
one described below, you might not get that much out of this book. My
hope—and belief—is that the assumptions below are common, and that the
situation of writing software that you find yourself in is similar to situations
I have faced. Thus, this book will help you.
In case it’s not, I want to state my assumptions up front, right here in this
free chapter.
12
Perhaps some venture capitalist has given us some money, but we don’t
yet know the exact market for our solution. Maybe we’re prototyping a
potentially complex UI to do user testing. In these cases we need to be
nimble and try to figure out what the software should do.
The assumption in this book is that that has already happened. We know
generally what problem we are solving, and we aren’t going to have to pivot
from selling shoes to providing AI-powered podiatrist back-office enterprise
software.
A lot of software falls into this category. If you are automating a business
process, building a customer experience, or integrating some back-end
systems, it’s likely that software will continue to be needed for quite a while.
I believe this is more common than not. Software is notoriously hard to get
right the first time, so it’s common to change it iteratively over a long period
to arrive at optimal functionality. Software that exists for years also tends to
need to change to keep up with the world around it.
13
Sustainability
If you don’t value sustainability as I’ve defined it, you likely didn’t pick up
this book or have stopped reading by now. You’re here because you think
sustainability is important, thus you value it.
Consistency
Valuing consistency is hugely important as well. Consistency means that
designs, systems, processes, components (etc.), should not be arbitrarily
different. Same problems should have same solutions, and there should not
be many ways to do something. It also means being explicit that personal
preferences are not critical inputs to decision-making.
A team that values consistency is a sustainable team and will produce sus-
tainable software. When code is consistent, it can be confidently abstracted
into shared libraries. When processes are consistent, they can be confidently
automated to make everyone more productive.
When architecture and design are consistent, knowledge can be transferred,
and the team, the systems, and even the business itself can survive poten-
tially radical change (see the sidebar “Our Uneventful Migration to AWS”
on the next page for how Stitch Fix capitalized on consistency to migrate
from Heroku to AWS with no downtime or outages).
Quality
Quality is a vague notion, but it’s important to both understand it and to
value it. In a sense, valuing quality means doing things right the first time.
But “doing things right” doesn’t mean over-engineering, gold-plating, or
doing something fancy that’s not called for.
Valuing quality is to acknowledge the reality that we aren’t going to be able
to go back and clean things up after they have been shipped. There is this
fantasy developers engage in that they can simply “acquire technical debt”
and someday “pay it down”.
I have never seen this happen, at least not in the way developers think it
might. It is extremely difficult to make a business case to modify working
software simply to make it “higher quality”. Usually, there must be some
catastrophic failure to get the resources to clean up a previously-made mess.
It’s simpler and easier to manage a process by which messes don’t get made
as a matter of course.
Quality should be part of the everyday process. Doing this consistently will
result in predictable output, which is what managers really want to see.
On the occasion when a date must be hit, cut scope, not corners. Only
the developers know what scope to cut in order to get meaningfully faster
delivery, but this requires having as much information about the business
strategy as possible.
14
When you value sustainability, consistency, and quality, you will be unlikely
to find yourself in a situation where you must undo a technical decision
you made at the cost of shipping more features. Business people may want
software delivered as fast as possible, but they really don’t want to go an
extended period without any features so that the engineering team can “pay
down” technical debt.
We know what sustainability is, how to value it, what assumptions I’m
making going in, and the values that drive the tactics and strategy for the
rest of the book. But there are two concepts I want to discuss that allow us
to attempt to quantify just how sustainable our decisions are: opportunity
costs and carrying costs.
15
pay all the time every time. If it’s difficult to run your app in development,
reading the documentation about how to do so and running all the various
commands is a cost you pay frequently.
Carrying costs affect sustainability more than anything. Each line of code is
a carrying cost. Each new feature has a carrying cost. Each thing we have
to remember to do is a carrying cost. This is the true value provided by
Rails: it reduces the carrying costs of a lot of pretty common patterns when
building a web app.
To sustainably write software requires carefully balancing your carrying
costs, and strategically incurring opportunity costs that can reduce, or at
least maintain, your carrying costs.
If there are two concepts most useful to engineers, it is these two.
The last bit of information I want to share is about me. This book amounts
to my advice based on my experience, and you need to know about that,
because, let’s face it, the field of computer programming is pretty far away
from science, and most of the advice we get is nicely-formatted survivorship
bias.
16
Java-based, much of what I learned about sustainability applies to the
Rails world as well.
• I spent the next year and half at an e-commerce company that had
reached what would be the peak of its success. I joined a team of
almost 200 engineers, many of whom were working in a huge Rails
monolith that contained thousands of lines of code, all done “The Rails
Way”. The team had experienced massive growth and this growth was
not managed. The primary application we all worked in was wholly
unsustainable and had a massive carrying cost simply existing.
• I then spent the next six and half years at Stitch Fix, where I was the
third engineer and helped set the technical direction for the team. By
the time I left, the team was 200 engineers, collectively managing a
microservices-based architecture of over 50 Rails applications, many
of which I contributed to. At that time I was responsible for the
overall technical strategy for the team and was able to observe which
decisions we made in 2013 ended up being good (or bad) by 2019.
• I was CTO of a healthcare startup, having written literally the first line
of code, navigating the tumultuous world of finding product/market
fit, becoming HIPAA1 -compliant, and trying to never be a bottleneck
for what the company needed to do.
Up Next
This chapter should’ve given you a sense of what you’re in for and whether
or not this book is for you. I hope it is!
So, let’s move on. Because this book is about Ruby on Rails, I want to give
an overview of the application architecture Rails provides by default, and
how those pieces relate to each other. From that basis, we can then deep
dive into each part of Rails and learn how to use it sustainably.
1 HIPAA is the Health Insurance Portability and Accountability Act, a curious law in the
United States related to how healthcare information is managed. Like all compliance-related
frameworks, it thwarts sustainability, but it’s a fact of life in the U.S.
17
2
Rails doesn’t talk about the parts this way, but we will, since it allows us to
group similar parts together when talking about how they work. The figure
“Rails’ Default Application Architecture” on the next page shows all the parts
of Rails and which of the four categories they fall into. The diagram shows
that:
• The boundaries of your Rails app are the controllers, jobs, mailers,
mailboxes, channels, and rake tasks, as well as Active Storage.
19
Figure 2.1: Rails’ Default Application Architecture
Let’s now go through each layer and talk about the parts of Rails in that
layer and what they are all generally for. I’ll stay as close as I can to what
I believe the intent of the Rails core team is and try not to embellish or
assume too much.
First, we’ll start with Boundaries, which broker input and output.
2.1 Boundaries
The Rails Guide1 says that controllers are
When you look at Jobs, Channels, Mailers, Mailboxes, Active Storage, and
Rake Tasks, they perform similar functions. In a general sense, no matter
what else goes in these areas, they have to:
20
• examine the output of that business logic and provide some sort of
output or effect.
Of course, not all use cases require reading explicit input or generating
explicit output, but the overall structure of the innards of any of these
classes, at least at a high level, is the same, as shown in the figure below.
For now, we’re not going to talk about the business logic, specifically if it
should be directly in the boundary classes or not. The point is that, no matter
where the business logic is, these boundary classes are always responsible
for looking at the input, initiating the logic, and assembling the output.
We’ll talk about these boundary classes in more detail in “Controllers” on
page 313, “Jobs” on page 325, and “Other Boundary Classes” on page 349.
Because Rails is for building web applications, the output of many of our
boundary classes is a web view or some other dynamic output. And creating
the view layer of a web application—even if it’s just JSON—can be complex,
which is why a big chunk of Rails is involved in these views.
2.2 Views
Rails support for rendering HTML web views is quite sophisticated and
powerful. In particular, the coupling between Active Model and Rails’ form
21
helpers is very tight (a great example of the power in tightly-coupling
components). Actions performed by boundary classes that result in dynamic
output (usually controllers and mailers) will initiate the rendering of the
view from a template, and that template may pull in JavaScript, CSS, or
other templates (partials).
Often the templates are HTML, but they can be pretty much anything,
including JSON, text, or XML. Templates also have access to helpers, which
are free functions in the global namespace. Rails provides many helpers by
default, and you can make your own.
View code tends to feel messy, because while a particular template can be
isolated pretty well, including decomposing it into re-usable partials, CSS
and JavaScript by their nature aren’t organized the same way. Often CSS
and JavaScript are globally available and taking care to keep them isolated
can be tricky.
Rails 7 includes what DHH and the core team believe to be the best ways to
manage JavaScript, and most of them boil down to getting JavaScript (and
CSS) to the venerable Asset Pipeline.
Rails is also designed for server-rendered views, and this is where the tight-
coupling comes into play. Take this pretty standard ERB for rendering an
edit form for a widget:
22
2.3 Models
Models are almost always about interacting with the database. Any database
table you need access to will assuredly require a model for you to do it, and
you likely have one or more database migrations to manage that table’s
schema.
This isn’t to say that everything we call a “model” has to be about a database,
but the history of Rails is such that the two are used synonymously. It
wasn’t until Rails 4 that it become straightforward to make a model that
worked with the view layer that was not an Active Record. The result of this
historical baggage is that developers almost always use “model” to mean
“thing that accesses the database”.
Even non-database-table-accessing models (powered by Active Model) still
bear a similar mark to the Active Records. They are both essentially data
structures whose members are public and can be modified directly. Of
course code like widget.name = "Stembolt" is actually a method call, but
the overall design of Active Records and Active Models is one in which
public data can be manipulated and there is no encapsulation.
In addition to providing access to structured data, models also tend to be
where all the business logic is placed, mostly because Rails doesn’t prescribe
any other place for it to go. We’ll talk about the problems with this approach
in the chapter “Business Logic (Does Not Go in Active Records)” on page 57.
The model layer also includes the database migrations, which create the
schema for the database being used. These are often the only artifact
in a Rails app other than the database schema itself that tells you what
attributes are defined on Active Records, since Rails dynamically creates
those attributes based on what it finds in the database.
We’ll cover models in “Models, Part 1” on page 203, “The Database” on
page 213, and “Models, Part 2 on page 255. We’ll discuss business logic
specifically in “Business Logic (Does Not Go in Active Records)” on page 57
and “Business Logic Code is a Seam” on page 241.
There are a few other bits of your Rails app that you’re less likely to think
about, but are still important.
23
page 130, “Ensure System Tests Fail When JavaScript is Broken” on page
178, “Testing the View” on page 181, “Writing Tests for Database Constraints”
on page 237, “Don’t Over Test” in the “Controllers” chapter on page 318,
and in other parts throughout the book.
There are, of course, your application dependencies as declared in Gemfile
and either package.json or config/importmap.rb, as well as the Rails con-
figuration files in config/ that you might need to modify.
There is also db/seeds.rb, which contains data that Rails describes both as
useful for production but also for development. We’ll talk about that in more
detail later, but I don’t consider it part of the model layer since it’s more of a
thing used for development or operations and isn’t used in production by
default.
Lastly, there is bin/setup, which sets up your app. Rails provides a version
of this that provides installation of gems and basic database setup. We’ll talk
about this in detail in “Start Your App Off Right” on page 33.
With our tour of Rails done, let’s talk about the pros and cons of what Rails
gives you.
24
Rails developer I’ve ever met has a slightly different take on it, though those
same developers also have had a bad experience with a variety of strategies.
We’ll talk about this specific problem in several chapters, notably “Business
Logic (Does Not Go in Active Records)” on page 57. It’s important to
understand that while DHH, the creator of Rails, might put business logic
in models, the Rails documentation doesn’t explicitly say this—developers
used to put business logic in their controllers before the “fat model, skinny
controller” aphorism became popular.
25
3
# app/controllers/widgets_controller.rb
def create
@widget = Widget.create(widget_params)
if @widget.valid?
× # puts "debug: #{widget_params}"
→ Rails.logger.info(@widget.inspect)
→ redirect_to widget_path(@widget)
else
render :new
end
end
For shell commands, the command you need to type is preceded by a greater-
than sign (>), and the output of that command is shown without any prefix,
like so:
1 For reasons beyond my understanding, the code listings in the book are difficult to copy
and paste. You can always download the code if you don’t want to type it in.
27
> ls app
controllers models views
On occasion, the output will be very long or otherwise too verbose to include.
In that case, I’ll use guillemets around a message indicating the output was
elided, like so:
Sometimes the output is useful but is too wide to fit on the page. In that
case, the lines will be truncated with an ellipsis (. . . ) like so:
If you are using a UNIX shell, these backslashes will work and you can type
the command in just like it is.
Unless otherwise stated, all shell commands are assumed to be running
in your development environment. Sometimes, however, we need to run
commands inside the Rails console or inside the database. In those cases,
I’ll show the command to start the console/connect to the database, and
then a change in prompt.
Here is how you would start a Rails console and then count the number of
Widgets with a quantity greater than 1:
> bin/rails c
console> Widget.where("quantity > 1").count
99
28
> bin/rails dbconsole
db> select count(*) from widgets where quantity > 1;
+-------+
| count |
|-------|
| 99 |
+-------+
Finally, note that when Rails console or SQL statements require more space
than can fit on one line I won’t be using the backslash notation, because that
notation won’t work in those environments. Sometimes the output will be
formatted to fit this medium and won’t match exactly, but hopefully it’ll all
make sense.
Next you need to make sure you have the same versions of the software I
do.
• Postgres 15
• Redis 6.2.6
• Bundler 2.4.13
• RubyGems 3.4.13
29
> lsb_release -a
No LSB modules are available.
Distributor ID: Debian
Description: Debian GNU/Linux 12 (bookworm)
Release: 12
Codename: bookworm
In Setting Up Docker for Local Development on page 445, I’ll walk you
through setting up an environment identical to mine, but if you already
have a setup you prefer, by all means use that. Try to match versions as
much as possible so if you run into any problems, it’ll eliminate at least a
few sources of errors.
In each section where there is sample code available, there will be a note at
the start of the section that indicates where to find the code, like so:
When you unzip sample-code.zip, you’ll see a bunch of folders with nu-
meric names. The message above says that the code in the section you are
about to read is in the folder named 03-02. In almost every case, each folder
contains the folder widgets/, which contains the entire state or the sample
app after that section is completed.
The folders have numeric names that are ordered in the same way the book
is. Thus, all changes would be against the version of the app in 03-01 in the
example above.
If you are reading this as a PDF or a print book, the numbers should match
the chapters and sections. If you are reading an ePub version on an eReader,
the chapter numbers are unfortunately out of my control, so please pay
attention to the notes at the start of each section.
Note that some sample code is just shown in the book as an example. The
code in the downloadable .zip file is only the code for the example app
you’ll build in this book. But, that’s a significant portion of the code!
2 https://sustainable-rails.com/assets/sample-code.zip
30
Up Next
Now that you’re oriented on the book and ready to write code, let’s start
where everyone has to start with Rails, which is setting up a new app.
There’s more than just running rails new if you want to get set up for
sustainable development.
31
4
• Other developers will work on it, and need to be able to set it up, run
its tests, and run it locally.
• It will eventually have security vulnerabilities (in our code and in our
dependencies).
• It will be deployed into production via a continuous integration
pipeline and require operational observability.
Given the assumptions we listed in the first chapter, we are also quite
confident that the app will get more complex over time and more and more
developers will work on it.
Before we start writing code, we’re going to take a few minutes to consider
how we create our app, how developers will set it up and work with it, and
how we’ll manage it in production. In other words, we need to consider
developer workflow, which starts with setup and ends with maintaining the
app in production.
The figure “Developer Workflow” on the next page shows this workflow
and the parts of it that we’ll create in this chapter.
The diagram shows:
• bin/setup will set up our app after we’ve pulled it down from version
control.
• bin/dev will be used to run our app locally, with the dotenv gem
providing runtime configuration for development and testing.
• bin/ci will run all of our quality checks, suitable for running in CI,
which will include both tests and security analysis via Brakeman and
bundle audit.
33
Figure 4.1: Developer Workflow
• In production, we’ll get all runtime configuration from the UNIX envi-
ronment, and we’ll use the lograge gem to configure more production-
friendly log output.
This won’t take a lot of code or configuration, and we’ll end up with automa-
tion, which is far more effective and easier to maintain than documentation
(see the sidebar “Automating Alert Setup” on the next page to learn how
powerful automation can be).
Before any of this, however, we need an app to work in.
We aren’t yet ready to run our app or its tests because Rails needs to know
how to connect to Postgres. This leads us nicely to our next topic on
managing runtime configuration.
34
Automating Alert Setup
When Stitch Fix was deploying to Heroku, we had a battery of monitors
and alerts that each application needed to have. Setting all of these up was
critical to understanding the behavior of our apps, but the setup was lengthy
and complex.
Almost everyone that had to do this setup messed up some part of it.
Some developers would skip it entirely. But the documentation was updated,
correct, and made a strong case for why the steps had to be followed. It was
just too complex to do well, and too important to leave to documentation
alone.
Eventually, we implemented automation in our deployment pipeline that
detected an app’s structure and automatically set up all the monitoring and
alerting it would need. This “documentation” was always up to date, and
was always followed because we automated it.
35
Rails already uses this mechanism for database credentials (looking at the
key DATABASE_URL) as well as the general secret key used for encrypting
cookies (under the key SECRET_KEY_BASE).
When we deploy, we’ll need to make sure that both DATABASE_URL and
SECRET_KEY_BASE have values in the production UNIX environment (see the
section “Managing Secrets, Keys, and Passwords” on page 440 for some
production and deployment considerations).
This does lead to the question of how to manage this in our local de-
velopment environment. We don’t want to set these values in our UNIX
environments for two reasons: 1) it is hard to automate across the team,
and 2) we may work on multiple apps which will have different runtime
configuration values.
To manage the UNIX environment for our local development, we’ll use a
tool called “dotenv”.
dotenv2 merges the existing UNIX environment with a set of key/value pairs
stored in files. These files are named for the Rails environment they apply to,
so .env.development is used to store development environment variables,
and .env.test for test.
2 https://github.com/bkeepers/dotenv
36
Be Careful with ENV
Ruby’s ENV constant behaves like a Hash, but it’s actually a special object
implemented in C. It may only contain strings (or objects that implement
to_str, which is used to store the object inside ENV):
This means when you access it, you need to coerce the string value to
whatever type you need. A very common error developers make is assuming
the strings "true" and "false" are equivalent to their boolean counterparts.
This leads to code like so:
if ENV["PAYMENTS_DISBLED"]
give_free_order
end
if ENV["PAYMENTS_DISBLED"] == "true"
give_free_order
end
Since our development and test runtime configuration values aren’t actual
secrets, we can safely check them into version control. We also won’t allow
dotenv to run in production, so there’s no chance of files containing secrets
creeping into our app and being used.
This also has the added benefit of pushing more consistency into our devel-
oper workflow. There’s really no reason developers should have different
Postgres configurations, and putting the credentials inside files checked into
version control makes being consistent much easier.
37
# Gemfile
source "https://rubygems.org"
ruby "3.2.2"
→
→ # All runtime config comes from the UNIX environment
→ # but we use dotenv to store that in files for
→ # development and testing
→ gem "dotenv-rails", groups: [:development, :test]
Notice how we’ve preceded it with a comment explaining its purpose? This
is a good practice to document why gems are there and what they do. Ruby
gems don’t have a great history of self-explanatory naming, so taking a few
seconds to document what a gem is for will help everyone in the future
when they need to understand the app. Rails 7 uses this convention and
you should, too.
We can now install dotenv with Bundler:
When Bundler loads the dotenv-rails gem, the gem activates itself automati-
cally. There’s no further action we need to take for our app to use it (other
than creating the files containing the environment variables). Because we’ve
specified it only in the :development and :test group, it won’t be used in
production.
The last step is to create our initial .env.development and .env.test files.
All they need to specify right now are the database credentials. If you
followed the Docker-based setup on page 445, the Postgres we are using has
a username and password of “postgres”, runs on port 5432, and is available
on the host named db. We also follow Rails’ convention for our database
names (widgets_development and widgets_test).
Create .env.development as follows.
# .env.development
38
DATABASE_URL="
postgres://postgres:postgres@db:5432/widgets_development"
# .env.test
DATABASE_URL=postgres://postgres:postgres@db:5432/widgets_test
Note if you are not using the Docker-based set up described in the Appendix
on page 445, you’ll need to use whatever credentials you used when setting
up Postgres yourself. Also note that you don’t need to quote this value—I’m
doing that to avoid a long line extending off the edge of the page.
dotenv recognizes more files than just the two we’ve made. Three of them
would be very dangerous to accidentally check into version control, so we’re
going to modify our local .gitignore file right now to make sure no one
ever adds them.
The first file is named .env, and it’s used in all environments. This leads to
a lot of confusion, and in my experience it is better to have development
and testing completely separated, even if that means some duplication in
the two files. The second two files are called .env.development.local and
.env.test.local. These two files override what’s in .env.development and
.env.test, respectively.
Convention dictates that these two .local files are used when you need
an actual secret on your development machine, such as an AWS key to a
development S3 bucket. Unlike our local database credentials, you don’t
want to check that into version control since they are actual secrets you
want to keep protected.
Although we don’t have any such secrets yet, ignoring .env.development.local
and .env.test.local now will prevent mishaps in the future (and codify
our decision to use those files for local secrets when and if needed).
We’ll also follow the convention established in our Gemfile by putting
comments in .gitignore about why files are being ignored.
# .gitignore
39
→
→ # The .env file is read for both dev and test
→ # and creates more problems than it solves, so
→ # we never ever want to use it
→ .env
→
→ # .env.*.local files are where we put actual
→ # secrets we need for dev and test, so
→ # we really don't want them in version control
→ .env.*.local
With that done, our Rails app should be able to start up, however any
attempt to use it will generate an error because we have not set up our
database. We could do that with bin/rails db:setup, but this would
then require documenting for future developers and we’d rather maintain
automation than documentation.
The place to do this is in bin/setup.
Rails provides a bin/setup script that is decent, but not perfect. We want
our bin/setup to be a bit more user friendly, but we also want it to be
idempotent, meaning it has the exact same effect every time it’s run. Right
now, that means it must blow away and recreate the database.
Many developers infrequently reset their local database. The problem with
this is that your local database builds up cruft, which can inadvertently
create dependencies with tests or local workflows, and this can lead to
complicated and fragile setups just to get the app working locally.
Worse, you might use a copy of the production database to seed local
development databases. This is a particularly unsustainable solution, since
it puts potentially personal user information on your computer and becomes
slower and slower to copy over time as the database size increases.
Instead we want to create a culture where the local development database
is blown away regularly. This, becomes a forcing function to a) not depend
on particular data in our database to do work, and b) motivate us to script
any such data we do need in the db/seeds.rb file so that everyone can have
the same setup.
The situation we want to create is that developers new to the app can pull
it down from version control, set up Postgres, run bin/setup, and be good
to go. We also want existing developers to get into the habit of doing this
40
frequently. As the app gets more and more complex to set up, this script
can automate all of that, and we don’t need to worry about documentation
going out of date.
Let’s replace the Rails-provided bin/setup with one of our own. Remember,
this script runs before any gems are installed, so we have to write it with only
the Ruby standard library. This script also won’t be something developers
work on frequently, so our best approach is to make it explicit and procedural,
free of clever DSLs or other complicated constructs.
We’ll create a main method called setup that performs the actual setup steps.
That will go at the top of the script. We’ll also need to add the shebang
line to indicate this is a Ruby script. We’ll also require Ruby’s OptionParser
library, which we’ll use to allow -h and --help to trigger a help message.
Here’s how this should look:
# bin/setup
#!/usr/bin/env ruby
require "optparse"
def setup
log "Installing gems"
# Only do bundle install if the much-faster
# bundle check indicates we need to
system! "bundle check || bundle install"
41
log and system! are not in the standard library, and we’ll define them in a
moment. system! executes a shell command (similar to the built-in system
method) and log prints output (similar to puts).
Note how we’ve written this script. Because it’s not something developers
will edit frequently, we’ve written comments about why and how each
command works so that if someone needs to go into it, they can quickly
understand what’s going on. And since these comments explain why and
not what, they are unlikely to go out of date.
Comments like this are particularly useful for complicated scripting and
setup. The fact that bin/rails db:reset will fail the first time it’s run
isn’t obvious, and there’s no sense forcing someone to search the web in a
moment of stress as they navigate unfamiliar code.
Before we define log and system!, let’s create a method called help that
will print out help text (note that in Ruby, $0 contains the name of the script
being executed).
# bin/setup
42
→ puts ""
→ end
→
→ # start of helpers
We’ll define bin/dev and bin/ci in the next section. We’ve documented
bin/rails test and bin/rails test:system here to be helpful to new or
inexperienced developers. They might not realize that bin/rails -T will
produce a documented list of all rake tasks, and even if they did, it might
not be clear which ones run the tests.
Next, let’s create our two helper methods. First is log, which wraps puts
but prepends a message to the user that bin/setup is where the message
originated. This can be helpful when interpreting a lot of terminal output.
# bin/setup
end
# start of helpers
→
→ # It's helpful to know what messages came from this
→ # script, so we'll use log instead of `puts`
→ def log(message)
→ puts "[ bin/setup ] #{message}"
→ end
→
→ # end of helpers
Next, system! will defer to Kernel#system3 , but handle checking the return
value and aborting if anything goes wrong. It will also log what it’s doing
explicitly.
# bin/setup
end
# start of helpers
→
→ # We don't want the setup method to have to do all this error
3 https://ruby-doc.org/core-3.1.0/Kernel.html
43
→ # checking, and we also want to explicitly log what we are
→ # executing. Thus, we use this method instead of Kernel#system
→ def system!(*args)
→ log "Executing #{args}"
→ if system(*args)
→ log "#{args} succeeded"
→ else
→ log "#{args} failed"
→ abort
→ end
→ end
The last part of bin/setup is to actually call either setup or help, depending
on what the user has asked for. We want our script to respond to -h and
--help, as these are somewhat standard ways to ask a program what it
does without doing anything. Ideally, our script will also produce an error if
the user provides other flags that aren’t known. This can be achieved with
Ruby’s OptionParser.
Lastly, we’ll also respect bin/setup help as a way to get help, as this is often
expected to work. We can check ARGV[0] to see if the user specified that.
Here’s how it all looks:
# bin/setup
end
# end of helpers
→
→ OptionParser.new do |parser|
→ parser.on("-h", "--help") do
→ help
→ exit
→ end
→ end.parse!
→
→ if ARGV[0] == "help"
→ help
→ elsif !ARGV[0].nil?
→ puts "Unknown argument: '#{ARGV[0]}'"
→ exit 1
44
→ else
→ setup
→ end
With that done, we want to make sure the file is executable (it should be,
since Rails created it that way, but if you deleted the file before editing, it
won’t be):
> bin/setup
[ bin/setup ] Installing gems
[ bin/setup ] Executing ["bundle check || bundle install"]
The Gemfile's dependencies are satisfied
[ bin/setup ] ["bundle check || bundle install"] succeeded
[ bin/setup ] Dropping & recreating the development database
[ bin/setup ] Executing ["bin/rails db:reset || bin/rails db. . .
/root/widgets/db/schema.rb doesn't exist yet. Run `bin/rails. . .
Dropped database 'widgets_development'
Created database 'widgets_development'
[ bin/setup ] ["bin/rails db:reset || bin/rails db:migrate"]. . .
[ bin/setup ] Dropping & recreating the test database
[ bin/setup ] Executing [{"RAILS_ENV"=>"test"}, "bin/rails d. . .
Dropped database 'widgets_test'
Created database 'widgets_test'
[ bin/setup ] [{"RAILS_ENV"=>"test"}, "bin/rails db:reset"] . . .
[ bin/setup ] All set up.
[ bin/setup ]
[ bin/setup ] To see commonly-needed commands, run:
[ bin/setup ]
[ bin/setup ] bin/setup help
[ bin/setup ]
We can also see that bin/setup --help produces some useful help:
45
bin/dev
# run app locally
bin/ci
# runs all tests and checks as CI would
bin/rails test
# run non-system tests
bin/rails test:system
# run system tests
bin/setup help
# show this help
This file will stand in for any documentation about setting up the app. To
keep it always working and up to date, it will also be used to set up the
continuous integration environment. That way, if it breaks, we’ll have to fix
it.
Before that, we need to run the app locally.
While this is easy enough to remember, our app will one day require more
complex commands to run it locally. Following our pattern of using scripts
instead of documentation, we’ll create bin/dev to wrap bin/rails server.
We’re calling it bin/dev (instead of, say, bin/run) for two reasons. First,
Rails has somewhat standardized on bin/dev for situations where you have
to run more than one process to run your app locally (and we’ll need to
do that in when we learn about background jobs on page 331). Secondly,
running is something the app does in production as well, and this script is
not for doing that. Calling it bin/dev makes it clear it’s just for our local dev
environment.
This will be a Bash script since it currently just needs to run one command.
The first line indicates this to the operating system. We’ll then call set -e to
make sure the script fails if any command it calls fails. We’ll also add some
46
code to check for -h, --help, and help to show a brief help message. After
that, we call bin/rails server.
# bin/dev
#!/usr/bin/env bash
set -e
if [ "${1}" = -h ] || \
[ "${1}" = --help ] || \
[ "${1}" = help ]; then
echo "Usage: ${0}"
echo
echo "Runs app for local development"
exit
else
if [ ! -z "${1}" ]; then
echo "Unknown argument: '${1}'"
exit 1
fi
fi
Bash is weird. You are reading this right that the way to end an if statement
is to use the word if spelled backward: fi. And yes, if we created a case
statement, we would end it in esac. I wish I were making that up.
bin/dev will need to be executable:
> bin/dev
=> Booting Puma
=> Rails 7.1.1 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 6.4.0 (ruby 3.2.2-p53) ("The Eagle of Durango")
47
* Min threads: 5
* Max threads: 5
* Environment: development
* PID: 782
* Listening on http://0.0.0.0:3000
Use Ctrl-C to stop
If you can keep bin/setup and bin/dev maintained, you have a shot at
a sustainable developer workflow, and this will be a boon to the team.
Nothing demoralizes developers more than having a constantly broken dev
environment that no one seems capable of fixing. And the bigger the team
gets and the more important the app becomes, the harder it will be to justify
taking precious developer time away to fix the development environment.
This leaves two things left: scripting all the app’s quality checks and creating
a production-ready logging configuration.
48
4.6 Putting Tests and Other Quality Checks in bin/ci
# Gemfile
49
→ gem "bundler-audit"
# bin/ci
#!/usr/bin/env bash
set -e
if [ "${1}" = -h ] || \
[ "${1}" = --help ] || \
[ "${1}" = help ]; then
echo "Usage: ${0}"
echo
echo "Runs all tests, quality, and security checks"
exit
else
if [ ! -z "${1}" ]; then
echo "Unknown argument: '${1}'"
exit 1
fi
50
fi
Note again that we print a message for each step of the process and prepend
those messages with [ bin/ci ] so that it’s obvious where the messages
came from. These messages also serve as documentation for why the
commands exist.
We’ll need to make this executable:
And, since we just created our app and have written no code, all the checks
should pass:
> bin/ci
[ bin/ci ] Running unit tests
Running 0 tests in a single process (parallelization thresho. . .
Run options: --seed 57185
# Running:
51
# Running:
Note that the extremely verbose lecture from git above is a factor of my
development environment and the way bundler-audit works (it does a git
clone to get the latest security vulnerabilities). Like much of git’s UI, this
information is useless, confusing, and can be ignored.
The last thing is to get ready for production by changing how Rails does
logging
Rails’ application logs have colored text and appear on multiple lines. This
might be nice for local development, but wreaks havoc with most log
aggregation tools we may use in production to examine our application logs.
Even if we download the files and grep them, we need each logged event to
be on a single line on its own.
lograge5 is a gem that provides this exact feature. It requires only a short
initializer in config/initializers as configuration.
Let’s install the gem first:
5 https://github.com/roidrage/lograge
52
# Gemfile
Install it:
# config/initializers/lograge.rb
Rails.application.configure do
if !Rails.env.development? ||
ENV["LOGRAGE_IN_DEVELOPMENT"] == "true"
config.lograge.enabled = true
else
config.lograge.enabled = false
end
end
# bin/setup
53
puts ""
puts " bin/dev"
puts " # run app locally"
→ puts ""
→ puts " LOGRAGE_IN_DEVELOPMENT=true bin/dev"
→ puts " # run app locally using"
→ puts " # production-like logging"
→ puts ""
puts ""
puts " bin/ci"
puts " # runs all tests and checks as CI would"
Before we finish, we should update the app’s README so it’s consistent with
everything we just did. Replace README.md with the following:
## Setup
54
1. `bin/dev`
## Tests and CI
1. `bin/ci` contains all the tests and checks for the app
2. `tmp/test.log` will use the production logging format
*not* the development one.
## Production
This minimal README won’t go out of date, because we now have three
scripts that automate setup, running, and CI. Because we’ll be using these
scripts every day, they will have to be kept up to date, since when they break,
we can’t do our work.
If you can get your app into a production-like environment now, you should
try to do so before writing too much code. You should also actually configure
continuous integration to make sure all this automation is working for you.
See the section “Continuous Integration” on page 405 for some tips and tricks
on how to do this if you don’t have much flexibility in your CI environment.
Up Next
That might’ve felt like a lot of steps, but it didn’t take too long and this
minor investment now will pay dividends later. Instead of an out-of-date
README, we have scripts that we can keep up to date and can automate the
setup and execution of our development environment. It works the same
way for everyone (as well as in the CI environment), so it’s one less thing to
go wrong, break, or have to be maintained.
It’s almost time to dive into the parts of Rails, but before we do that, I want
to talk about what makes your app special: the business logic. In the next
chapter I’ll define what I mean by business logic, why it’s critical to manage
properly, and the one strategy you need to manage it: don’t put it in your
Active Records.
55
5
Business logic is the term I’m going to use to refer to the core logic of your
app that is specific to whatever your app needs to do. If your app needs to
send an email every time someone buys a product, but only if that product
ships to Vermont, unless it ships from Kansas in which case you send a text
message. . . this is business logic.
The biggest question Rails developers often ask is: where does the code
for this sort of logic go? Rails doesn’t have an explicit answer. There is no
ActiveBusinessLogic::Base class to inherit from nor is there a bin/rails
generate business-logic command to invoke.
This chapter outlines a simple strategy to answer this question: do not put
business logic in Active Records. Instead, put each bit of logic in its own
class, and put all those classes somewhere inside app/ like app/services or
app/businesslogic.
The reasons don’t have to do with moral purity or adherence to some object-
oriented design principles. They instead relate directly to sustainability by
minimizing the impact of bugs found in business logic. That said, Martin
Fowler—who popularized the active record pattern upon which Active
Record is based—does not recommend putting all business logic in active
records, either.
We’ll learn that business logic code is both more complex and less stable
than other parts of the codebase. We’ll then talk about fan-in which is a
rough measure of the inter-relations between modules in our system. We’ll
bring those concepts together to understand how bugs in code used broadly
in the app—such as Active Records—can have a more serious impact than
bugs in isolated code.
57
5.1 Business Logic Makes Your App Special. . . and
Complex
Rails is optimized for so-called CRUD, which stands for “Create, Read,
Update, and Delete”. In particular, this refers to the database: we create
database records, read them back out, update them, and sometimes delete
them.
Of course, not every operation our app needs to perform can be thought
of as manipulating a database table’s contents. Even when an operation
requires making changes to multiple database tables, there is often other
logic that has to happen, such as conditional updates, data formatting and
manipulation, or API calls to third parties.
This logic can often be complex, because it must bring together all sorts of
operations and conditions to achieve the result that the domain requires it
to achieve.
This sort of complexity is called necessary complexity (or essential complexity)
because it can’t be avoided. Our app has to meet certain requirements, even
if they are highly complex. Managing this complexity is one of the toughest
things to do as an app grows.
58
is notoriously hard to specify, so this feedback cycle tends to work the best.
And that means changes, usually in the business logic. Changes are often
called churn, and areas of the app that require frequent changes have high
churn.
Churn doesn’t necessarily stop after we deliver the first version of the app.
We might continue to refine it, as we learn more about the intricacies of the
problem domain, or the world around might change, requiring the app to
keep up.
This means that the part of our app that is special to our domain has high
complexity and high churn. That means it’s a haven for bugs.
North Carolina State University researcher Nachiappan Nagappan, along
with Microsoft employee Richard Ball demonstrated this relationship in
their paper “Use of Relative Code Churn Measures to Predict System Defect
Density”1 , in which they concluded:
Hold this thought for a moment while we learn about another concept in
software engineering called fan-in.
rn.pdf
59
reason that bugs in User will have wider effects compared to bugs in
WidgetFaxOrder.
While there are certain other confounding factors (perhaps WidgetFaxOrder
is responsible for most of our revenue), this lens of class dependencies is a
useful one.
The concepts here are called fan-out and fan-in. Fan-out is the degree to
which one method or class calls into other methods or classes. Fan-in is what
I just described above and is the inverse: the degree to which a method or
class is called by others.
What this means is that bugs in classes or methods with a high fan-in—
classes used widely throughout the system—can have a much broader
impact on the overall system than bugs in classes with a low fan-in.
Consider the system diagrammed in the figure below. We can see
that WidgetFaxOrder has a low fan-in, while Widget has a high one.
WidgetFaxOrder has only one incoming “uses” arrow pointing to it. Widget
has two incoming “uses” arrows, but is also related via Active Record to two
other classes.
60
Figure 5.2: Bug Effects of a Low Fan-in Module
Now consider if instead Widget has a bug. The figure “Bug Effects of a High
Fan-in Module” on the next page shows how a broken Widget class could
have serious effects throughout the system in the worst case. Because it’s
used directly by two controllers and possibly indirectly by another through
the Active Record relations, the potential for the Widget class to cause a
broad problem is much higher than for WidgetFaxOrder.
It might seem like you could gain a better understanding of this problem
by looking at the method level, but in an even moderately complex system,
this is hard to do. The system diagrammed here is vastly simplified.
What this tells me is that the classes that are the most central to the app
have the highest potential to cause serious problems. Thus it is important to
make sure those classes are working well to prevent these problems.
A great way to do that is to minimize the complexity of those classes as well
as to minimize their churn. Do you see where I’m going?
61
Figure 5.3: Bug Effects of a High Fan-in Module
So why would we put the code most likely to have bugs in the classes
most widely used in the system? Wouldn’t it be extremely wise to keep
the complexity and churn on high fan-in classes—classes used in many
places—as low as possible?
If the classes most commonly used throughout the system were very stable,
and not complex, we minimize the chances of system-wide bugs caused
by one class. If we place the most complex and unstable logic in isolated
classes, we minimize the damage that can be done when those classes have
bugs, which they surely will.
Let’s revise the system diagram to show business logic functions on the
Active Records. This will allow us to compare two systems: one in which
we place all business logic on the Active Records themselves, and another
where that logic is placed on isolated classes.
Suppose that the app shown in the diagram has these features:
• Purchase a widget
• Purchase a widget by fax
• Search for a widget
• Show a widget
• Rate a widget
• Suggest a widget rated similar to another widget you rated highly
I’ve added method names to the Active Records where these might go in the
figure “System with Logic on Active Records” on the next page. You might
62
put these methods on different classes or name them differently, but this
should look pretty reasonable for an architecture that places business logic
on the Active Records.
Now consider an alternative. Suppose that each bit of business logic had
its own class apart from the Active Records. These classes accept Active
Records as arguments and use the Active Records for database access, but
they have all the logic themselves. They form a service layer between the
controllers and the database. We can see this in the figure below.
63
Granted, there are more classes, so this diagram has more paths and seems
more complex, but look at the fan-in of our newly-introduced service layer
(the classes in 3-D boxes). All of them have low fan-in. This means that a
bug in those classes is likely to be contained. And because those classes are
the ones with the business logic—by definition the code likely to contain
the most bugs—the effect of those bugs is minimized.
And this is why you should not put business logic in your Active Records.
There’s no escaping a system in which a small number of Active Records are
central to the functionality of the app. But we can minimize the damage
that can be caused by making those Active Records stable and simple. And
to do that, we simply don’t put logic on them at all.
There are some nice knock-on effects of this technique as well. The business
logic tends to be in isolated classes that embody a domain concept. In our
hypothetical system above, one could imagine that WidgetPurchaser encap-
sulates all the logic about purchasing a widget, while WidgetRecommender
holds the logic about how we recommend widgets.
Both use Widget and User classes, which don’t represent any particular
domain concept beyond the attributes we wish to store in the database.
And, as the app grows in size and features, as we get more and more
domain concepts which require code, the Widget and User classes won’t
grow proportionally. Neither will WidgetRecommender nor WidgetPurchaser.
Instead, we’ll have new classes to represent those concepts.
In the end, you’ll have a system where churn is isolated to a small number
of classes, depended-upon by a few number of classes. This makes changes
safer, more reliable, and easier to do. That’s sustainable.
But don’t take my word for it. Martin Fowler, the person who coined and
first described the active record pattern that was inspiration for this part of
Rails encourages this as well, when your application is complex.
Let’s set aside that this is an appeal to authority and let’s also set aside
that 99% of Active Record’s documentation and 100% of its API are about
database access. Is this actually what Martin Fowler, the author of Patterns
of Enterprise Application Architecture, intended? No.
64
Many designers, including me, like to divide “business logic” into two
kinds: “domain logic,” having to do purely with the problem domain
(such as strategies for calculating revenue recognition on a contract),
and “application logic,” having to do with application responsibili-
ties. . . sometimes referred to as “workflow logic”.
Later, when talking about the active record pattern, he is clear that the logic
you’d couple to your database schema is domain logic only:
Each Active Record is responsible for saving and loading to the database
and also for any domain logic that acts on the data.
“Domain logic that acts on the data” is certainly a subset of your application’s
business logic. For one, it doesn’t include application logic, as defined by
Fowler. Secondly, it doesn’t include domain logic that doesn’t “act on data”.
Fowler goes on to clarify this point:
Active Record is a good choice for domain logic that isn’t too complex,
such as creates, reads, updates, and deletes. Derivations and valida-
tions based on a single record work well in this structure. . . If your
business logic is complex, you’ll soon want to use your object’s direct
relationships, collections, inheritance, and so forth. These don’t map
easily onto Active Record, and adding them piecemeal gets very messy.
I have never worked on an application that was so simple it could keep all of
its logic in the Active Records. But I have definitely worked on applications
where application logic and database-agnostic domain logic were crammed
into the Active Records. It was not sustainable.
I mention this to really underscore that it’s not just me telling you not to put
all your business logic in Active Records. The guy that came up with it also
doesn’t think you should do that.
OK, let’s see an example of some code that doesn’t put business logic in the
Active Records.
65
6. When the widget is updated, two things have to happen:
1. Depending on the widget’s manufacturer, we need to notify an
admin to approve of the changes
2. If the widget is of a particular type, we must update an inventory
table used for reporting.
7. The user sees a result screen.
8. Eventually, an email is sent to the right person.
class WidgetEditingService
def edit_widget(widget, widget_params)
widget.update(widget_params)
if widget.valid?
# create the InventoryReport
# check the manufacturer to see who to notify
# trigger the AdminMailer to notify the right person
end
widget
end
end
66
The code in the other classes would be more or less idiomatic Rails code
you are used to.
Here’s WidgetsController:
def update
widget = Widget.find(params[:id])
@widget = WidgetEditingService.new.edit_widget(
widget, widget_params
)
if @widget.valid?
redirect_to widgets_path
else
render :edit, status: :unprocessable_entity
end
end
private
def widget_params
params.require(:widget).permit(:name, :status, :type)
end
end
67
class AdminMailer < ApplicationMailer
def edited_widget(widget)
@widget = widget
end
def edited_widget_for_supervisor(widget)
@widget = widget
end
end
class WidgetEditingService
def edit_widget(widget, widget_params)
widget.update(widget_params)
68
if widget.valid?
EditedWidgetJob.perform_later(widget.id)
end
widget
end
def post_widget_edit(widget)
# create the InventoryReport
# check the manufacturer to see who to notify
# trigger the AdminMailer to notify whoever
# should be notified
end
end
As you can see, we’re putting only the code in the background job that has
to be there. The background job is given an ID and must trigger logic. And
that’s all it’s doing.
I’m not going to claim this is beautiful code. I’m not going to claim this
adheres to object-oriented design principles. . . whatever those are. I’m also
not going to claim this is how DHH would do it.
What I will claim is that this approach allows you to get a ton of value out
of Rails, while also allowing you to consolidate and organize your business
logic however you like. And this will keep that logic from getting intertwined
with HTTP requests, email, databases, and anything else that’s provided by
Rails. And this will help greatly with sustainability.
Do note that the “service layer” a) can be called something else, and b) can
be designed any way you like yet still reap these benefits. While I would
encourage you to write boring procedural code as I have done (and I’ll make
the case for it in “Business Logic Class Design” on page 241), you can use
any design you like.
69
Up Next
This will be helpful context about what’s to come. Even when isolating
business logic in standalone classes, there’s still gonna be a fair bit of code
elsewhere in the app. A lot of it ends up where we’re about to head: the
view. And the first view of your app that anyone ever sees is the URL, so
we’ll begin our deep-dive into Rails with routes.
70
PART
II
It can be hard to design routes that serve both purposes. If your routes
are designed first around aesthetic concerns, you will quickly have a sea of
inconsistent and confusing URLs, and this will create a carrying cost on the
team every time a new feature has to be added. But you also can’t insist that
your app is only available with conventional Rails routes. Imagine someone
reading a podcast ad with a database ID in it!
The marketing department isn’t the only source of complexity with your
routes, however. The more routes you add and the more features your app
supports, the harder it can be to keep the routes organized. If routes become
messy, inconsistent, or hard to understand, it adds carrying costs with every
new feature you want to implement.
Fortunately, with a bit of discipline and a few simple techniques, you can
keep your routes file easy to navigate, easy to understand, and still provide
the necessary human-friendly URLs if they are called for.
Let’s dig into each of these to learn how they help sustainability.
73
6.1 Always Use Canonical Routes that Conform to Rails’
Defaults
With just a single line of code, Rails sets up eight routes (seven actions) for
a given resource.
resources :widgets
# config/routes.rb
Rails.application.routes.draw do
→ resources :widgets
→
With just this one line, when we run bin/rails routes we get a glimpse of
what Rails gives us:
74
> bin/rails routes -g widgets
Prefix Verb URI Pattern Controller#Ac. . .
widgets GET /widgets(.:format) widgets#index
POST /widgets(.:format) widgets#creat. . .
new_widget GET /widgets/new(.:format) widgets#new
edit_widget GET /widgets/:id/edit(.:format) widgets#edit
widget GET /widgets/:id(.:format) widgets#show
PATCH /widgets/:id(.:format) widgets#updat. . .
PUT /widgets/:id(.:format) widgets#updat. . .
DELETE /widgets/:id(.:format) widgets#destr. . .
This has set up the eight different routes and also created some URL helpers.
The value under “Prefix” is what we use with either _path or _url to generate
routes without string-building. The helpers that take arguments (such as
widget_path) can also accept an Active Model instead of an ID. Those
helpers will intelligently figure out how to build the URL for us.
Before we make the second route, let’s fill in the controller and view here just
to have something working. Since we don’t have any database tables, we’ll
use the Ruby standard library’s OpenStruct class to make a stand-in widget.
The code below should be in app/controllers/widgets_controller.rb.
Note that the OpenStruct used in the show method creates an object that
responds to id, name, and manufacturer_id.
# app/controllers/widgets_controller.rb
75
See the screenshot “Initial Widget ‘show’ page” below for what this looks
like1 .
Now, let’s create a route for the manufacturer’s page, but use get instead of
resources. This will illustrate the difference in the approaches.
We’ll add the route to config/routes.rb:
# config/routes.rb
Whereas our widgets resource had helpers defined for us, using get doesn’t
do that. This means that if we have to create a URL for our manufacturer,
we either need to create our own implementations of manufacturer_path
and manufacturer_url, or we have to build the URL ourselves, like so:
1 Just don’t forget to nominate me for a Webby.
76
<h1><%= @widget.name %></h1>
<h2>ID #<%= @widget.id %></h2>
<%= link_to "/manufacturers/#{ @widget.manufacturer_id }" do %>
View Manufacturer
<% end %>
This might seem like only a minor inconsistency, but it can have a real
carrying cost. If your routes file only has these two lines in it, you’re already
sending a message to developers that each new feature requires making
unnecessary decisions about routing:
• Should they use the standard resources or should they make a custom
route with get, post, etc.?
• Should they build URLs with string interpolation, or should they make
their own helper in app/helpers/application_helper.rb, or should
it go in app/helpers/manufacturer_helper.rb?
• Should they use as: to give the route a name to make the helper, and
what should that name be?
There’s just no benefit to hand-crafting routes like this. These are the sort of
needless decisions Rails is designed to save us from having to make. And it
won’t end here. Rails provides a lot of ways to generate routes, and some
developers, when they see two ways to do something, create a third.
Of course, using resources on its own isn’t perfect. We’ve created inconsis-
tency around our routes file, controllers, and views. The output of bin/rails
routes shows eight routes that our app supports, but in reality, our app only
responds to one of them.
Running bin/rails routes on an app is a great way to get a sense of its size,
scope, and purpose. If the output of that command lies—as ours currently
does—it’s not helpful. It creates confusion. More than that, it allows you to
use a URL helper that will happily create a route that will never, ever work.
The solution is to use the optional only: parameter to resources. This
parameter takes an array of actions that you intend to support.
Doing this ensures that if you try to create a route you don’t support using
a URL helper, you get a nice NameError (as opposed to a URL that will
generate a 404). I mistype URL helpers all the time, and it’s much nicer to
77
find out about this mistake locally with a big error screen than to scratch
my head wondering why I’m getting a 404 for a feature I just implemented.
A nice side-effect of explicitly listing your actions with only: is that
bin/rails routes provides a clean and accurate overview of your app. It
lists out the important nouns related to your app and what it does, and this
can be a nice jumping-off point for building new features or bringing a new
developer onto the team.
This might not seem like a big win for a small app, but remember, we’re
setting the groundwork for our app to grow. If you start off using resources
and adopt the use of only: when your app gets larger, you now have need-
less inconsistency and confusion. You create another decision developers
have to make when creating routes: Do I use only: or not?
The Rails Guide2 even tells you to avoid creating non-existent routes if your
app has a lot of them:
If your application has many RESTful routes, using :only and :except
to generate only the routes that you actually need can cut down on
memory use and speed up the routing process.
The simplest way to solve this problem is to not create it in the first place.
Let’s fix our routes file now by changing the previous call to resources in
config/routes.rb with this:
# config/routes.rb
Rails.application.routes.draw do
→ resources :widgets, only: [ :show ]
You might also be aware of except:, which does the opposite of only:. It
tells Rails to create all of the standard routes except those listed. For example,
2 https://guides.rubyonrails.org/routing.html
78
if we wanted all the standard routes except destroy, we could use except:
[ :destroy ] in our call to resources.
This technique certainly achieves the goal of making the routes file accurate,
but I find it confusing to have to work out negative logic in my head to
arrive at the proper value. I would advise sticking with only: because it’s
much simpler to provide the correct value. It also means you only have a
single technique for creating routes, which reduces the overhead needed to
work on the app.
The routes in your app are primarily there for developers, and using canon-
ical routes, explicitly listed, creates a consistency that the developers will
benefit from. This works great until the marketing department wants to
plaster a URL on a billboard. Sometimes, we need so-called vanity URLs that
are more human-friendly than our standard Rails routes.
Like it or not, URLs are public-facing, and so they are subject to the require-
ments of people outside the engineering team. Because they show up in
search results, social media posts, and even podcast ads, we really do need
a way to make human-friendly URLs. But, we don’t want to create a ton of
inconsistency with the canonical URLs created by resources.
The way to think about this is that the canonical URLs you create with
resources are for developers and should serve the needs of the team and app
so that all the various URLs can be created easily and correctly. If user-facing
URLs are needed, those should be created in addition to the canonical URLs
and, of course, only if you actually need them.
Let’s suppose the marketing team is creating a big campaign about our
widget collection, all based around the word “amazing”. They are initially
going to buy podcast ads that ask listeners to go to example.com/amazing.
The marketing team wants that URL to show the list of available widgets.
We don’t have that page yet, but we should not make the route /amazing be
the canonical URL for that page. For consistency and simplicity, we want a
canonical URL, which is /widgets. Because we already have the resources
call for the show action, we’ll modify the array we give to only: to include
:index:
# config/routes.rb
Rails.application.routes.draw do
→ resources :widgets, only: [ :show, :index ]
79
# Reveal health status on /up that returns 200 if the app b. . .
# app/controllers/widgets_controller.rb
manufacturer_id: rand(100),
name: "Widget #{params[:id]}")
end
→ def index
→ @widgets = [
→ OpenStruct.new(id: 1, name: "Stembolt"),
→ OpenStruct.new(id: 2, name: "Flux Capacitor"),
→ ]
→ end
end
<h1>Our Widgets</h1>
<ul>
<% @widgets.each do |widget| %>
<li>
<%= link_to widget.name, widget_path(widget.id) %>
</li>
<% end %>
</ul>
80
Figure 6.2: Initial Widgets index page
# config/routes.rb
That’s a lot of code and it’s mostly comments! The first few lines indicate
that we are in a special section of the routes file for vanity URLs, which I’m
calling “custom routes” because that’s a bit more inclusive of what we might
need here. Next, we document our policy around creating these routes. It
makes more sense to put the policy right in the file where it applies than
hide it in a wiki or other external document.
81
Then, we use the to: redirect(. . . ) parameter for the get method to
implement the redirect, along with a comment about what it’s for. Unfortu-
nately, we can’t directly use widgets_path inside the routes file, so we have
to hard-code the route, but it’s a minor duplication. In reality, our canonical
routes aren’t likely to change, so this should be OK.
If you do need to make a lot of custom routes, you could do something more
sophisticated, like use route globbing to a custom controller that uses the
URL helpers, but I would advise against this unless you really need it.
Note that redirect(...) will use an HTTP 301 to do the redirect. You can
provide an additional parameter to get named status: that can override
this HTTP status to use a 302 for example.
Once this route is set up, you should be able to navigate to /amazing and
see your handiwork, just as in the screenshot below.
You’ll also notice that Rails made a URL helper for the custom route, so you
can use amazing_url in a mailer view to put the custom route into an email
or other external communications.
If, for whatever reason, it’s really important that no redirects happen, you
can always use get in the more conventional way:
# config/routes.rb
82
→ get "/amazing", to: "widgets#index"
end
If you check that in your browser, you’ll see the vanity URL render the
widget index page without any redirects.
The key thing here is that every single route in the application has a canoni-
cal route, consistent with Rails’ conventions. Our vanity URLs are created
in addition to those routes. This consistency means that each time a new
route is needed, you always use resources to create it in the normal Rails
way. If you have a need for a vanity route, you also create that using get
and redirect(...).
Playing this technique forward a year or two from now, the routes file might
be large, but it should be relatively well-organized. It will mostly be made
up of a bunch of calls to resources, followed by that big comment block,
and then any custom URLs you may have added over that time (along with
up-to-date comments about what they are for).
Comments often get a bad rap, but the way they are used here is defensible
and important. Routes are one of the most stable parts of the app (they
might even outlive the app itself!). This means that comments about those
routes are equally stable, meaning they won’t get out of date. Because of
that, we can take advantage of the proximity of these comments to the code
they apply to. Don’t underestimate how helpful it can be when a comment
about a piece of code exists and is accurate.
The comments also serve to call out the inconsistency vanity URLs create.
As you scroll through the routes file and come across a big, fat comment
block, your mind will immediately think that something unusual is coming
up. That’s because it is!
Suppose we want to allow users to give a widget a rating, say one to five
stars. Let’s suppose further that we store these ratings aggregated on the
83
widget itself, using the fields current_rating and num_ratings3 .
This example is contrived to create the problem whose solution I want to
discuss, but I’m sure you’ve encountered a similar situation where you have
a new action to perform on an existing resource and it doesn’t quite fit with
one of the standard actions.
We know what parameters we need—a widget ID and the user’s rating—but
we don’t know what route should receive them because it’s not exactly clear
what resource and what action are involved.
We could use the update action on a widget, triggered by a PATCH to the
/widgets/1234 route. This would be mostly conventional, since a PATCH is
“partial modification” to a resource. The problem arises if we have lots of
different ways to update a widget. Our controller might get complicated
since it would need to check what sort of update is actually happening:
def update
if params[:widget][:rating].present?
# update the rating
else
# do some other sort of update
end
end
This creates a decent URL and a route helper, but I don’t recommend this
approach. In my experience, this leads to a proliferation of custom actions,
3 Yes, you can maintain a correct running average with just these two fields. If you’d like to
work out exactly how to do that, the best way is to apply for some jobs in Silicon Valley where
eventually some smug mid-level engineer will make you solve this on a whiteboard, then scoff
at your inability to do so before quickly writing the answer he memorized prior to interviewing
you.
84
where a scant number of resources start to have a growing set of custom
actions in the routes and controllers.
When this happens, the process for making a new feature requires deciding
on a custom action name for an existing resource, rather than considering
what resource is really involved. It also further diverges the app’s codebase
from Rails’ standards and doesn’t provide much value in return.
Rails works best when you are resource-focused, not action-focused. When
you think about common techniques around software design, many involve
starting with a domain model, which is essentially the list of nouns that the
app deals with. Rails intends these to be your resources.
Thus, you should reframe your process to one that is resource-focused,
not action-focused. Doing so results in many different resources that all
support the same small number of actions. Because your app is a web
app, and because HTTP is—you guessed it—resource-based supporting a
limited number of actions on any given resource, this creates consistency
and transparency in your app’s behavior.
It allows you to mentally translate URLs through routes to the controller
without having to do a lot of lookups to see how things are wired together.
As we’ll talk about in the chapter on controllers on page 313, controllers
are the boundary between HTTP and whatever makes your app special.
Sticking with a resource-based approach with standard actions for routes
and controllers reinforces that boundary and keeps your app’s complexity
out of the controllers.
So what do we do about our widget ratings problem? If we stop think-
ing about the action of “rating” and start thinking about the resource of
“a widget’s rating”, the simplest thing to do is create a resource called
widget_rating. When the user rates a widget, that creates a new instance
of the widget_rating resource.
This is how that looks in config/routes.rb:
# config/routes.rb
Rails.application.routes.draw do
resources :widgets, only: [ :show, :index ]
→ resources :widget_ratings, only: [ :create ]
85
# app/controllers/widget_ratings_controller.rb
We don’t need a view for this new action, but let’s add the new flash message
to the existing widget view in app/views/widgets/show.html.erb, along
with a form to do the rating, so we can see it all working.
86
Notice how all the code still looks very Rails-like? Our controller has a
canonical action, our routes file uses the most basic form of resources, and
our view uses standard-looking Rails helpers. There is huge power in this as
the app (and team) gets larger.
Don’t worry (for now) that “widget ratings” isn’t a database table. We’ll talk
about that more in the database chapter on page 213. Just know for now
that this doesn’t create a problem we can’t easily handle.
As we did with custom routes, play this technique forward a few years. You’ll
have lots of resources, each an important name in the domain of your app,
and each will have at most seven actions taken on them that map precisely
to the HTTP verbs that trigger those actions.
You’ll be able to go from URL to route to controller easily, even if your app
has hundreds of routes! That’s sustainability.
This brings us to the last issue around routing, which is nested routes.
This is for good reason, as it starts to blur the lines about what resource is
actually being manipulated and it creates highly complex route helpers like
manufacturer_widget_order_url that then take several positional parame-
ters.
Nested routes do solve some problems, so you don’t want to entirely avoid
them. There are three main reasons to consider a nested route: sub-resource
ownership, namespacing, and organizing content pages.
87
resources :widgets, only: [ :show ] do
resources :ratings, only: [ :create ]
end
This design is making a very strong statement about how your domain
is modeled. Consider that a route is creating a URI—Uniform Resource
Identifier—for a resource in your system. A route like /widget/:id/ratings
says that to identify a widget rating, you must have a widget. It means that
a rating doesn’t have any meaning outside of a specific widget. This might
not be what you mean, and if you create this constraint in your system, it
might be a problem later.
Consider a feature where a user wants to see all the ratings they’ve given to
widgets. What would be the route to retrieve these? You couldn’t use the
existing /widgets/:id/ratings resource, because that requires a widget ID,
and you want all ratings for a user.
This comes back to routes as URIs and routes being for developers’ use. If a
rating can exist, be linked to, or otherwise used on its own, independent of
any given widget, making ratings a sub-resource of widgets is wrong. This
is because a sub-resource is creating an identifier for a rating that requires
information (a widget’s ID) that the domain does not require.
Of course, you might not actually know enough about the domain at the
time you have to make your routes. Because of this lack of knowledge,
making ratings its own resource (as we did initially) is the safer bet. While
a URL like /widget_ratings?widget_id=1234 might feel gross, it’s much
more likely to allow you to meet future needs without causing confusion
than if you prematurely declare that a rating is always a sub-resource of a
widget.
Remember, these URLs are for the developers, and aesthetics is not a primary
concern in their design. They should be chosen for consistency and simplicity.
If you really do need a nicer URL to locate a widget’s rating, you can use
the custom URL technique described above to do that. Just be clear about
why you’re doing that.
88
6.5.2 Namespacing Might (or Might Not) be an Architecture
Smell
Namespacing in the context of routes is a technique to disambiguate re-
sources that have the same name but are used in completely different
contexts.
Perhaps our app needs a customer service interface to view, update, and
delete widgets—the same resources accessed by users—but requires a totally
different UI.
While you could complicate WidgetsController and its views to check to
see if the user is a customer service agent, it’s often cleaner to create two
controllers and two sets of views.
While you could do something like UserWidgetsController and
CustomerServiceWidgetsController, it’s cleaner to use namespaces. We
can assume WidgetsController is our default view for our users, and create
a CustomerService namespace so that CustomerService::WidgetsController
handles the view of widgets for customer service agents.
The namespace method available in config/routes.rb can set this up, like
so:
# config/routes.rb
→ namespace :customer_service do
→ resources :widgets, only: [ :show, :update, :destroy ]
→ end
→
####
# Custom routes start here
#
89
--[ Route 2 ]-----------------------------------------------. . .
Prefix |
Verb | PATCH
URI | /customer_service/widgets/:id(.:format)
Controller#Action | customer_service/widgets#update
Source Location | config/routes.rb:15
--[ Route 3 ]-----------------------------------------------. . .
Prefix |
Verb | PUT
URI | /customer_service/widgets/:id(.:format)
Controller#Action | customer_service/widgets#update
Source Location | config/routes.rb:15
--[ Route 4 ]-----------------------------------------------. . .
Prefix |
Verb | DELETE
URI | /customer_service/widgets/:id(.:format)
Controller#Action | customer_service/widgets#destroy
Source Location | config/routes.rb:15
90
Where possible, you should try to model these as resources, but doing so can
often be awkward. For example, you could use resource :privacy_policy,
only: [ :show ] to manage you privacy policy, using the singular resource,
since you don’t have many privacy policies. Confusingly, Rails wants this
served from the PrivacyPoliciesController. It’s even more difficult when
you have landing pages for marketing that don’t map naturally to a resource
at all.
In these cases, it can be better to create a namespace for such pages and
then have non-standard routes used simply as a way to serve up content.
While some organizations might serve such content from a static web server
or content management system, you may not have the ability to do this
and might be served well by organizing these pages away from the core
resources that make up your app.
Up Next
Bet you didn’t think routing was such a deep topic! I want you to reflect on
the lessons here, however. If you follow these guidelines, you really aren’t
using anything but the most basic features of the Rails router. That’s a good
thing! It means anyone can easily understand your routes, and even the
most inexperienced developer can begin adding features. This is sustainable
over many years.
And with this, let’s move onto the next layer of the view: HTML templates.
91
7
HTML Templates
Now that we’ve learned about some sustainable routing practices let’s move
on to what is usually the bulk of the work in any Rails view: HTML templates.
HTML templates feel messy, even at small scale, and the way CSS and
JavaScript interact with the view can be tricky to manage. And, even though
you can de-couple HTML templates and manage their complexity with
layouts and partials, it’s not quite the same as managing Ruby code, so the
entire endeavor often feels awkward at best.
This chapter will help you get a hold of this complexity. It boils down to
these guidelines:
• Mark up all content and controls using semantic HTML; use div and
span to solve layout and styling problems.
• Build templates around the controller’s resource as a single instance
variable.
• Extract shared components into partials.
• The View Components gem helps manage complex views far better
than partials.
• ERB is fine.
93
The process you follow for building a UI should start by marking up all
the content and controls with specific HTML elements appropriate to the
purpose of the content or control. Do not choose HTML tags based on their
appearance or other layout characteristics. After you have applied semantic
tags, use <div> or <span> elements only to solve layout and styling problems.
This two-step technique will make it much simpler to build views and also
result in sustainable views that are easier to understand and change later.
Let’s start with marking up the view with tags.
• A header explaining what was on the page. We used an <h1> for this.
• A list of widgets that was not ordered. We used a <ul> for this.
• Each widget has a name and a link. We used an <li> for this as well
as an <a> (as provided by Rails link_to helper).
While we can absolutely create the visual appearance we need with just
<div>s, we used tags the way they were intended to create the initial version
of our UI.
Doing this has three advantages:
The first two advantages speak directly to sustainability. When you can
open up the code for a view and easily navigate it to find the parts you
need to change or add, your job working on the app is easier. The decision-
making process for dealing with the view is simpler when you begin by
using semantic markup.
Semantic tags are also more stable. Our widget index page might go through
many redesigns, but none of them will change the fact that an un-ordered
list uses the <ul> tag. That means that tests that involve the UI can rely on
this and thus be more stable.
94
The third advantage only tangentially helps with sustainability, mostly when
someone decides to care about assistive devices. When that happens, se-
mantically marked-up UIs will be a better experience and thus require less
overall work to bridge any gaps in what you’ve done with what is needed
for a great experience with assistive devices.
Even if no stakeholder decides to explicitly target assistive devices, I still do
think it’s important that we make our UIs work with them where we can.
There are more people than you might think that don’t use a traditional web
browser, and if you can be inclusive to their needs with minimal to no effort,
you should be.
There is a practical concern about when to use each tag, because not every
piece of content or UI element will map exactly to an existing tag. You may
have noticed when we added the flash message to our widget show page
that I used the <aside> tag. That tag’s explanation2 is as follows:
That sounds like a flash message to me, but it might not to you. As you build
your app, you should develop a set of conventions about how to choose the
proper tags. Agreeing to not use <div> or <span> for semantic meaning will
go a long way. Ensconcing these decisions in code also helps.
When you identify re-usable components, that is when to have the design
discussion about which tags are appropriate, and the result of that discussion
is the re-usable partial that gets extracted. We’ll talk about that in the next
section.
So, if we aren’t using <div> or <span> to convey semantic meaning (since
they cannot), what are they for? The answer is for styling.
95
Figure 7.1: Rating UI Mockup
First, since we have a new element, we need to add that using a semantic tag
before styling. We’ll use a <p> tag at the bottom of the existing <section>:
</li>
<% end %>
</ol>
→ <p>Your ratings help us be amazing!</p>
</section>
To get the <h3> and the rating buttons all on one line, we’ll float everything
left. I’m going to use inline styles so that you can see exactly what styles are
being applied (I do not recommend inline styles as a real approach).
First, we’ll float the <h3> as well as adjust the margin and padding so it
eventually lines up with the rating buttons.
</aside>
<% end %>
<section>
→ <h3 style="float: left; margin: 0; padding-right: 1rem;">
→ Rate This Widget:
→ </h3>
<ol>
<% (1..5).each do |rating| %>
<li>
96
<%# app/views/widgets/show.html.erb %>
</h3>
<ol style="list-style: none; padding: 0; margin: 0">
<% (1..5).each do |rating| %>
→ <li style="float: left">
<%= button_to rating,
widget_ratings_path,
params: { widget_id: @widget.id,
We can see the problem if we look at the page now, as shown in the
screenshot below.
97
We need to clear the floats before the <p> tag. One way to do this is to use a
<br> tag. However, this is not what the <br> tag is for3 , since it is designed
to help format text that requires line breaks, such as poetry or addresses.
We could put the clear: all style on the <p> tag itself, but this creates
an odd situation with margin collapsing4 that will be very confusing when
applying other styles to it later5 .
Ideally, we could wrap the floated elements in a tag whose sole purpose is
to clear those floats. Since this is a visual styling concern, there isn’t such a
tag. This is what a <div> is for!
A common way to do this is to create a CSS class with a name like “clear-fix”
or “clear-floats” and apply that class to the <div> which we wrap around
floated elements.
We can do that by adding this class to application.css:
/* app/assets/stylesheets/application.css */
*= require_tree .
*= require_self
*/
→ .clear-floats:after {
→ content: "";
→ display: table;
→ clear: both;
→ }
</aside>
<% end %>
<section>
→ <div class="clear-floats">
<h3 style="float: left; margin: 0; padding-right: 1rem;">
Rate This Widget:
</h3>
3 https://developer.mozilla.org/en-US/docs/Web/HTML/Element/br
4 https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Mastering_mar
gin_collapsing
5 Margin collapsing explains a lot about why CSS behaves counter to your intuition.
98
We’ll close it after the ordered list:
</li>
<% end %>
</ol>
→ </div>
<p>Your ratings help us be amazing!</p>
</section>
We could certainly have done this by using a new <section> tag to contain
the <h3> and the rating buttons, but there is no semantic reason to. If we
didn’t have the visual styling requirement, there would be no need to add
an additional wrapper.
If you apply this technique broadly, what will happen is that every view you
open that contains a <div> (or <span>), you can know with certainty that
those tags are there to make some visual styling work. This is a strong cue
to how the overall view works, which is the first thing you need to know in
order to make changes.
It also provides a clear indication for assistive devices that the tag holds no
meaning. If we’d used a <section> tag instead, assistive devices would tell
their users that there is a new section, even though there really isn’t.
99
This might feel a bit dense right now, but after the chapter on CSS, I
hope everything will fall into place about how to apply visual styling in a
sustainable way.
The main thing to take away here is that your view code should be treated
with the same reverence and care as your Ruby code, even though the view
code will be verbose and ugly. If you are disciplined with the HTML in your
view code, it will be easier to work with.
There’s more to say about our HTML templates, so we’ll leave styling for
now and talk about how to communicate data from the controllers to the
templates.
The way Rails makes data from controllers available to views is by copy-
ing the instance variables of the controller into the code for the view as
instance variables with the same name. I highly suggest being OK with
this design. We’ll talk about object-orientation and controllers more in the
chapter on controllers on page 313, but I don’t think there is high value in
circumventing this mechanism with something that feels “cleaner” or “more
object-oriented”.
That said, it’s possible to create quite a mess with instance variables, so
that’s what I want to talk about here. The way to get the most of Rails’
design without creating a problem is to adopt two conventions:
• Expose exactly one instance variable from any given action, ideally
named for the resource or resources being manipulated by the route
to that action. For example, the widget show page should only expose
@widget.
• There are three exceptions: when a view requires access to reference
data, like a list of country codes, when the view needs access to global
context, like the currently logged-in user, or when there is UI state
that is persisted across page refreshes, such as the currently selected
tab in a tab navigation control.
If you follow the advice in the chapter “Routes and URLs” on page 73, these
conventions are surprisingly easy to follow, but it does require doing a good
job modeling your domain and resources.
The key situation to avoid is exposing multiple instance variables that
collectively represent the resource rather than creating a single instance
variable—and perhaps a new model class—to do so.
100
7.2.1 Name the Instance Variable After the Resource
As a reminder, my suggestion is to create routes based on resources that
use the Rails conventional actions. This results in an application with many
resources. Each controller would then expose a single instance variable
named for that resource (for example @widget or @widgets).
The primary prerequisite of this guideline is that your resources be well-
designed. Whatever information is needed to render a given view, the
resource for that view must have access to all of it.
How you do this is a design decision with many subtleties, particularly
around the so-called Law of Demeter6 , which warns against coupling
domain concepts too tightly. Most developers interpret the Law of
Demeter (for better of for worse) as avoiding nested method calls like
@widget.manufacturer.address.country.
I would not have a huge problem with the Guideline of Demeter, but as
a Law, I find it over-reaches, especially given how it is often interpreted.
In many cases, it’s perfectly fine—and often better—to dig into the object
hierarchy for the data you need.
Let’s add some code to our widget show page to see the exact problem
created by the “single instance variable” approach and the Law of Demeter.
For the purposes of this example, we’ll assume our domain model in the
figure on the next page describes our domain, which is:
# app/controllers/widgets_controller.rb
101
Figure 7.4: Widgets and Manufacturers
→ id: rand(100),
→ name: "Sector 7G",
→ address: OpenStruct.new(
→ id: rand(100),
→ country: "UK"
→ )
→ )
@widget = OpenStruct.new(id: params[:id],
manufacturer_id: rand(100),
name: "Widget #{params[:id]}")
# app/controllers/widgets_controller.rb
)
)
@widget = OpenStruct.new(id: params[:id],
→ manufacturer_id: manufacturer.id,
→ manufacturer: manufacturer,
name: "Widget #{params[:id]}")
end
def index
102
Since this is available from the @widget we’re exposing, we can add this to
the view like so:
Set aside how gnarly our placeholder code is. When widgets and manufac-
turers become real models, that code will go away and be simpler, but the
view will still look like this, at least if we do the simplest thing and navigate
the relationships created by Active Record.
The first thing to understand is that the view’s requirements couple the
widget to its manufacturer’s name and country by design. This is not a
coupling created by us developers, but one that naturally occurs in the
domain itself.
To me, this makes the code above perfectly fine, and I don’t believe the Law
of Demeter applies here.
For the sake of argument, however, let’s say that we don’t like this coupling.
If we solve it by creating a new @manufacturer instance variable, we create
a less sustainable solution. Our view would have code like this in it:
<h3>
Built by <%= @manufacturer.name %>
out of <%= @manufacturer.address.country %>
</h3>
This view is intended to show the widget’s manufacturer’s name and country.
This implementation—that uses a second instance variable—means we
cannot verify that the view is correct just by looking at the view code. We
have to go into the controller to figure out how @manufacturer gets its value.
103
Even if we assume widgets and manufacturers are modeled correctly, we
can’t know if the correct manufacturer is being used in this view.
Using a second instance variable also creates a practical problem around
consistency. Once code with multiple instance variables becomes prolific,
developers now have to make a decision every single time they build a
controller action: How many instance variables to expose and which ones
should they be? This can be a hard question to answer.
The alternative is to modify the way we’ve modeled our widget. The widget
show view’s requirements are a big input into what a widget fundamentally
is. So if a widget really is a thing that has a manufacturer name and country,
it would not be unreasonable to model it like so:
@widget = OpenStruct.new(
id: params[:id],
name: "Widget #{params[:id]}",
manufacturer_name: "Sector 7G",
manufacturer_country: "UK",
)
<h3>
Built by <%= @widget.manufacturer_name %>
out of <%= @widget.manufacturer_country %>
</h3>
Because the view is using a single instance variable, we know the view
is showing the correct data—assuming the resource has been modeled
correctly. We can’t make that assumption with the multiple instance variable
implementation.
This may feel like we’ve overloaded our Active Record with “view concerns”.
I would push back on this for three reasons. First, “view concerns” are a
requirement to what your domain should actually be, so they should not be
dismissed simply because they don’t make sense in a relational data model.
Second, when your app is made up of many more resources than database
tables, you won’t end up with tons of methods on your small set of core
models.
Lastly, however, the various solutions to the problem of separating so-called
view concerns mostly result in unsustainable code. Two common solutions
104
are to create presenters (or view models)—classes that just encapsulate
whatever the view needs—or to use decorators—classes that proxy what is
needed for a view to the real Active Records.
Both of these approaches can mask over problems with domain modeling,
especially given Ruby’s highly dynamic nature. I’ve seen code that dynami-
cally changes the methods available on a model depending on the context,
and I can’t think of a more confusing way to build an app:
module WidgetDecorator
def manufacturer_name
manufacturer.name
end
def manufacturer_country
manufacturer.address.country
end
end
## app/controllers/widgets_controller.rb
def show
@widget = Widget.find(params[:id]).include(WidgetDecorator)
end
This adds two methods to the Widget passed to the view. Figuring out
how this works is not necessarily easy. The view code will appear to call
manufacturer_name on a Widget, and figuring out where that method comes
from requires following a circuitous route through the code. I would argue
that if the user thinks about a widget as having a manufacturer name, but
we don’t model that explicitly in our code, we have not done a good job
designing.
When controllers sometimes expose Active Records, sometimes mix in con-
cerns, sometimes create presenters, and sometimes do something else, it
becomes more difficult than necessary to design new views and features.
Even if the team diligently documents how to make those decisions, docu-
mentation is rarely found or interpreted in the way intended. This mental
overhead makes each new feature harder to deliver.
It’s worth re-iterating that if two domain concepts are tightly coupled by
design, having the code tightly couple them can actually be an advantage.
Our original code that navigated from widget to manufacturer to address
mimics the domain.
That being said, I mentioned three exceptions above.
105
7.2.2 Reference Data, Global Context, and UI State are
Exceptions
Almost every Rails app has a method called current_user that exposes an
object representing who is logged in. It’s also common to need a list of
reference data, such as country codes, in order to build a drop-down menu
or other piece of UI. Lastly, it’s common to need to persist UI state between
requests, such as for a tabbed-navigation control. None of these make sense
as part of an existing resource, because you’d end up with every single
model providing access to this data.
These are the exceptions to the “one instance variable per view” guide-
line. You can certainly provide access to data like this in helpers, and
current_user is a very common one. We’ll talk about helpers in the next
chapter, but too many helpers can create view code that is hard to under-
stand. When a piece of view code only uses instance variables, it becomes
very easy to trace back where those instance variables got their values: the
controller.
We don’t have any drop-downs in our app yet, but this is what it would look
like to expose a list of country codes on a hypothetical manufacturer edit
page:
Further, we might have a tabbed navigation on the page and need to know
which tab is active and make that state persist by encoding it in the url, like
/widgets?tab=advanced. The controller might look like so:
106
If you end up needing access to country codes or UI state in many places,
you can extract the lookup logic at the controller level. I’d still recommend
passing this information to the view as an instance variable, for the reasons
stated above: instance variables pop out and can only come from the
controller. Helpers can come from, well, anywhere.
As your app takes shape, you may start to see patterns of data or markup
common to some views. We’ll talk about that in the next few sections.
When your app’s views are relatively self-contained and display data in a
straightforward way, they are easily managed with ERB and helpers. There
aren’t many apps that don’t have more complex needs that would benefit
from the re-use of markup or logic from the view. Rails provides one way to
do this, which is the use of partials.
We’ll talk about partials in this chapter, since they are a lightweight, low-
ceremony way to manage complexity. Partials aren’t great when you have
complex view logic. We’ll talk about that in the next section.
<section>
<div class="clear-floats">
<h3 style="float: left; margin: 0; padding-right: 1rem;">
Rate This Widget:
</h3>
<ol style="list-style: none; padding: 0; margin: 0">
<% (1..5).each do |rating| %>
<li style="float: left">
<%= button_to rating,
107
widget_ratings_path,
params: {
widget_id: @widget.id,
rating: rating
}
%>
</li>
<% end %>
</ol>
</div>
<p>Your ratings help us be amazing!</p>
</section>
We can remove that markup from the widget show page and reference the
partial:
This works great, under certain conditions. I’m not showing the hypothetical
other view that needs this, but it’s not hard to imagine that that view won’t
expose @widget as an instance variable. If following the guidelines in the
previous section, it definitely won’t be. That means, this partial won’t
work, since it would try to call id on nil (the default value of any instance
variable).
We need to make this partial more of a re-usable component.
108
<%# app/views/widgets/show.html.erb %>
</aside>
<% end %>
This results in a better system. If someone tries to use the partial without
setting widget, they won’t get an error about nil, or NoMethodError, but
instead get an error saying that widget is not defined. This is a stronger clue
that widget is required.
But, as of Rails 7.1, we can do even better by using strict locals
109
→ <%# locals: (widget:) %>
<section>
<div class="clear-floats">
<h3 style="float: left; margin: 0; padding-right: 1rem;">
<%
if !local_assigns.key(:show_cta)
show_cta = true
end
%>
This is clunky. Strict locals allows setting a default value, like so:
110
<div class="clear-floats">
<h3 style="float: left; margin: 0; padding-right: 1rem;">
Although our new bit of logic is relatively simple, you don’t have to work on
web apps very long before you end up having to build a complex bit of UI
that has lots of different possible UI states. In this situation, partials become
unwieldy, both in terms of implementation, but also for testing.
The only way to completely cover a complex bit of UI logic with tests is
to use system tests. As we’ll discuss later in the book, these are slow and
brittle. Unfortunately, Rails doesn’t provide anything other than partials and
helpers to manage complex views.
This is why I’d recommend a third-party library for this case: View Compo-
nents.
Our widget rating component is pretty simple, and if how it is now is all it
needs to be, partials work great. But suppose our widget rating component
became more complex? What if ratings below 3 warranted a different visual
design? What if admins looking at the page shouldn’t see the rating controls?
What if are running an A/B test on a different way of gathering ratings?
All of these hypothetical situations will result in complex logic in the ERB
that requires management and testing. If you’ve ever had to do this, it can
111
be difficult. You don’t have a way to test it outside of a system test, and
complex if statements inside a markup language creates a lot of friction.
View Component7 is a library that can solve these problems in a Rails-
like way. View Component can manage server-rendered HTML fragments
with Ruby and ERB code together. A fragment of ERB is connected to a
Ruby class, and the two are used to render HTML. View Component was
developed at GitHub where it is widely used, and is otherwise popular and
well-maintained. And, it’s a small API that can be learned quickly.
Here’s how you’d render a view component named WidgetRatingComponent
in any ERB file:
# Gemfile
112
> bundle install
«lots of output»
This created a component Ruby class, an ERB template, and a test. We’ll
populate the ERB template first.
It’s located in app/components/widget_rating_component.html.erb and
should look pretty similar to the partial we extracted:
<section>
<div class="clear-floats">
<h3 style="float: left; margin: 0; padding-right: 1rem;">
Rate This Widget:
</h3>
<ol style="list-style: none; padding: 0; margin: 0">
<% (1..5).each do |rating| %>
<li style="float: left">
<%= button_to rating,
widget_ratings_path,
params: {
widget_id: widget.id,
rating: rating
}
%>
</li>
<% end %>
</ol>
</div>
<% if show_cta %>
113
<p>
Your ratings help us be amazing!
</p>
<% end %>
</section>
When using View Component, any method called from the ERB must be
available from the component’s class as an instance method. Thus, we’ll
need to define both widget and show_cta in the component class. Since we
don’t need any other logic for now, we can do that by declaring them both as
attr_readers in app/components/widget_rating_component.rb. We’ll also
change the initializer to default show_cta to true:
# app/components/widget_rating_component.rb
# frozen_string_literal: true
Now, we can remove the partial and use the component. First, we’ll delete
app/views/widgets/_rating.html.erb:
> rm app/views/widgets/_rating.html.erb
</aside>
<% end %>
114
If you restart your server, you can see that the page is working (and the CTA
is omitted):
115
Here’s how it will look:
# test/components/widget_rating_component_test.rb
require "test_helper"
Now, we can test this component, and it’ll execute just as fast as a unit test:
# Running:
..
Nice! In my most recent job, I maintained a customer service app that had
a lot of complex UI states. View Component, in particular this method of
116
testing, allowed me to quickly build the UI and have confidence that it was
working.
Why not always use View Component? Why even use partials?
• It’s the default in Rails, so its behavior is managed and updated with
Rails and thus more stable and reliable.
• It is based on HTML, which is widely understood by almost every web
developer, even those unfamiliar with Rails.
8 http://haml.info
9 http://slim-lang.com
117
Sticking with Rails’ default choices is a sustainable decision, because you
will need to update your version of Rails over the life of the app. The fewer
dependencies your app has, the easier that process is going to be. I’m sure
HAML and Slim are well-updated and maintained, but if incompatibilities
exist between these technologies and Rails, it’s not going to delay a Rails
release. Incompatibilities with ERB will. This means that HAML and Slim
(like any dependency) can prevent you from updating to the latest version
of Rails.
As to the broad mindshare of HTML, while it’s not hard to learn HAML or
Slim, neither technology actually makes it easier to write HTML. They are
both translators, not abstractions, so you still need to think about what
HTML is going to be generated. I don’t enjoy writing code that I must
mentally translate as I write it. I find it difficult to both understand how
the dynamic nature of the template affects the resulting markup while also
translating HAML or Slim mentally into HTML.
A non-default templating language is also one more thing to learn in order
to be productive (especially since Slim and HAML use a modified version
of embedded Ruby that doesn’t need end statements). While any single
non-standard thing may not be hard to learn, these tend to add up. Anything
you add to your app should provide a clear benefit to justify its existence.
For non-default templating languages, there really isn’t a strong benefit.
Consider also the use of advanced front-end technologies like React or
Vue. Those use HTML by default, too. Adopting HAML or Slim for HTML
templates means you either have inconsistency with your JavaScript compo-
nents, or you need a JavaScript dependency to change the markup language
there, too. While RubyGem dependencies carry risk, JavaScript dependen-
cies carry a higher risk (as we’ll discuss later).
It’s just not worth it. HAML and Slim simply don’t solve a serious enough
problem to justify the cost of their adoption. Arguments about “cleanliness”
are subjective, and I prefer to limit the number of technical decisions made
based on subjective measures. Subjective or aesthetic arguments can be
decent tiebreakers, but as the foundation of a technical decision, I find them
wanting10 .
Up Next
We’ve talked about HTML templates and how to manage them. As we work
our way into the app, the next view technology to look at is the helper.
Helpers are used to extract logic needed in templates to Ruby code, where
they can be more easily managed and tested. But we can make an awful
mess with them.
10 I want to point out that I have made no argument related to the whitespace-significance
of HAML or Slim. I believe their lack of appropriateness can be understood on technical merits
alone.
118
8
Helpers
Ah helpers! So handy, yet also a magnet for mess and unsustainability. I am
not going to give you a clean, perfect, sustainable solution here, but I can
help clarify the issues with helpers, explain the best way to use them, and
help ease some of the pain.
Helpers are a way (well, the only way included with Rails) to ex-
port methods to be available to a view. Any method defined in
app/helpers/application_helper.rb will be included and available to all
your views. Helpers can also be added via the helper method in a controller,
which will import methods from a class, module, block of code, or really
anywhere.
The main problem that comes up around helpers is the sheer volume of
them. Because they exist in a single global namespace by default, the more
helpers there are, the harder it is to avoid name clashes and the harder it
is to find helpers to reuse. It’s just not feasible to expect engineers to read
through tons of helpers to figure out if what they need exists or not.
An extreme way to deal with this problem is to ban the use of helpers
entirely. You could be successful with this approach, but you’d then need
an answer for where code goes that does what a helper would normally
do. Those approaches, usually called presenters, have their own problems,
which we’ll talk about.
But even a nuanced approach that clearly defines what code should be in a
helper and what shouldn’t still requires answering questions about where all
the code you need should end up. And, of course, helpers generate HTML,
making them a great place to inject security vulnerabilities.
The reality is, there’s going to be a lot of code to handle view logic and
formatting. Whether that code is in helpers or not, it doesn’t change the fact
that we have a code management problem, and there’s no perfect solution.
To deal with this reality, we’ll look at the following techniques:
119
• When generating markup (in a helper or not), use Rails APIs to avoid
security issues.
• Helpers with logic should be tested, but take care not to over-couple
them to the markup being generated.
• Presenters (or other proxies where you might put helper-like methods)
aren’t needed given Active Model and View Components.
We’ll start with the most important technique for managing helpers, which is
to make sure you are putting domain concerns in the domain objects where
they belong, not in your helpers.
Helpers are often used for so-called view concerns, which is the transfor-
mation of canonical data to something only needed for a view. Rails’
number_to_currency is a great example. Therefore, to understand helpers
is to understand view concerns. What are they?
A common convention for identifying view concerns is to assume any piece
of data that doesn’t come from the database, and is thus aggregated or
derived from the database, is a view concern. While easy to follow, this
convention is overly simplistic and ends up pushing too many actual domain
concepts out of the domain.
Instead, you should think more deeply about what really is part of the
domain. The resource upon which your view is based isn’t just an aggrega-
tion of data from the database but instead is everything that’s part of that
domain concept, including data that might be derived or aggregated from
the database.
Let’s suppose our widget IDs are meaningful to users. There are a lot of
good reasons for this to be true. In our imagined domain of widget sales,
we can assume we’re migrating some legacy widget database into our own,
and we’ll suppose that users are used to seeing widget IDs in general, and
specifically, they are accustomed to seeing them formatted with the last two
digits separated by a dot. So the widget with ID 12345 would be shown as
123.45.
This might seem like a view concern. It’s a formatting of canonical data in
our database. But why do we need to do this? Because it’s meaningful to
users. This formatted ID represents a meaningful concept to the users of our
system. That feels fundamentally different than, say, using a monospaced
font to render the ID.
I’d argue that something like this is not a view concern and should be part of
the domain. That doesn’t mean we have to store it in our database, but what
120
it does mean is that it’s part of the widget resource and not something we’d
put in a partial template component or helper. See the sidebar “Formatting
Item IDs” on page 123 for a real-world example of this.
We don’t have a Widget class yet, but we can still add this derived data to
our stand-in OpenStruct. Let’s do that now in widgets_controller.rb:
# app/controllers/widgets_controller.rb
manufacturer_id: manufacturer.id. . .
manufacturer: manufacturer,
name: "Widget #{params[:id]}")
→ def @widget.widget_id
→ if self.id.to_s.length < 3
→ self.id.to_s
→ else
→ self.id.to_s[0..-3] + "." +
→ self.id.to_s[-2..-1]
→ end
→ end
end
def index
@widgets = [
If you haven’t done this sort of hacky metaprogramming, don’t worry. It’s
not a technique you should use often, but essentially this is defining the
method widget_id on the @widget object itself. Note that this code won’t
last long, as we’ll turn Widget into a real class later in the book.
We can use this in the view:
This should work great as shown in the screenshot “Formatted Widget ID”
below.
121
Figure 8.1: Formatted Widget ID
> rm -f log/development.log
When you start to critically examine your domain, and take into account all
the inputs to what should define it, you’ll find that there are many pieces of
data that you won’t store in your database. These aren’t view concerns.
Nevertheless, you will still encounter the need to render data or perform
logic specific to how data is viewed. Formatting numbers or currency based
on locale is one. Another is UI logic based on global state or context, such as
showing or hiding parts of a view based on what the current logged-in user
is authorized to do. This means we’ll need some code between our resources
and our views to manage this. Helpers can do this, and so let’s talk about
what helpers can do, specifically what only helpers can do.
122
Formatting Item IDs
The Stitch Fix warehouses were organized in a seemingly chaotic, ran-
dom fashion. This was by design as it helped the efficiency of the fulfillment
process greatly. We initially had 1,000 locations or bins, and we assigned
an item’s location based on the last three digits of its primary key in the
database.
When you looked at any app, any tag, or any packing slip, item IDs
would render like 1234-567, and this would tell you that bin 567 is where
that item should go. The code to format the IDs originally lived in a helper.
Of course, we ended up needing it in a lot of places over the years. The
result was a ton of duplicate code spread across the app (and later, many
apps), all because we considered it a view concern.
The reality is, this formatted ID was meaningful to everyone, and the
fact that it came from the database primary key was irrelevant. It was part
of the domain model that we missed.
Rails built-in helpers format data for the view, often by generating markup.
For situations where little or no markup is needed, helpers can be a good
solution, since they are lighter-weight than a partial or View Component.
Helpers can also provide access to global state without requiring instance
variables. As long as there’s not too much global state, helpers can work
well.
123
and definitely don’t warrant a View Component.
Note again I’m using inline styles merely to show what styles are being
applied. In reality you’d use CSS for this, but the overall point will stand
(we’ll talk about CSS later). Also note the use of <span>. Certainly, <code>
would achieve the look we want, but our widget ID is not a piece of computer
code, so using <code> would be semantically incorrect.
To create this inline component, we’ll create a new helper in
app/helpers/application_helper.rb.
# app/helpers/application_helper.rb
module ApplicationHelper
→
→ def styled_widget_id(widget)
→ content_tag(:span,
→ widget.widget_id,
→ style: "font-family: monospace")
→ end
end
124
<aside>
<%= flash[:notice] %>
<h2>
ID #<%= render partial: "styled_widget_id",
locals: { widget: @widget } %>
</h2>
<h2>
ID #<%= render(StyledWidgetIdComponent.new(widget: @widget) %>
</h2>
125
Even with a disciplined approach that minimized helpers to only those that
are necessary and beneficial, your app will end up with a lot of them. Thus,
you need a strategy for where they are defined.
If you want to do that, you should configure Rails to not create per-
controller helper files. You can do this by placing code like so in
config/application.rb:
# config/application.rb
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
→ config.generators do |g|
→ # Prevent generators from creating
→ # per-controller helpers
→ g.helper false
→ end
end
end
126
8.3.2 Configure Helpers to Be Actually Modular
If you aren’t using View Component, a way to manage view-specific helpers
is to make Rails treat the per-controller helper files as actually separate. By
default, if you have the file app/helpers/widgets_helper.rb, that would
be included in all views. This behavior can be confusing and error-prone,
because you cannot easily manage the global namespace and avoid name
clashes.
If you wanted actual modularity, meaning app/helpers/widgets_helper.rb
would only be included on views rendered from the WidgetsController,
you can achieve this by configuring Rails like so:
# config/application.rb
require_relative "boot"
require "rails/all"
module Widgets
class Application < Rails::Application
# existing configuration...
I find the use of View Component for consolidating view-specific logic easier
to manage than helpers, but modularizing the helpers system isn’t a bad
solution. It does allow you to keep within vanilla Rails and reduce the
dependencies your project has.
One caveat with this approach is the use of helpers in partials. If a partial
using a helper is rendered by a view from the WidgetsController, but then
is re-used from, say, the ManufacturersController, the helper won’t be
available. This can be confusing, so ensure you have good test coverage to
detect these issues.
Another consideration for defining helpers is when you have logic that must
be shared between a controller and a view.
127
8.3.3 Use helper_method to Share Logic Between Views and
Controllers
A common need in applications is to access the currently logged-in user,
which is typically available via a method named current_user, defined
in ApplicationController (or a module that it includes). The view often
needs access to this method as well. This can be arranged without dupli-
cating the method by using helper_method in ApplicationController, like
so:
private
def current_user
User.find_by(id: session[:current_user_id])
end
helper_method :current_user
end
128
for a web application. Several of these vulnerabilities can be exploited by
allowing unsafe content to be sent to a user’s browser in HTML, CSS, or
JavaScript.
When we just use ERB templates and Rails view helpers, Rails does
a great job of preventing these problems. If a user creates a Widget
named "<strong>HACKED</strong> Stembolts", Rails would escape those
<strong> tags so the browser doesn’t render them.
Problems can occur when we generate markup in Ruby code, which is often
what our helpers need to do.
For example, we could’ve implemented our styled widget ID helper like so:
def styled_widget_id(widget)
%{
<span style="font-family: monospace">
#{ widget.widget_id }
</span>
}
end
Rails does not consider this string to be HTML safe, so it would escape all of
that HTML and the result would be that the user would see raw un-rendered
HTML in their browser.
We can tell Rails that the string is safe to render in the browser by calling
html_safe on it.
def styled_widget_id(widget)
%{
<span style=\"font-family: monospace\">
#{ widget.widget_id }
</span>
→ }.html_safe
end
Rails will then skip escaping this string thus allowing the browser to render it.
For the <span> tags in this method, that’s fine. We can easily see that we have
not introduced a security vulnerability. But what about widget.widget_id?
Figuring out where that value comes from, and if it could contain markup
or JavaScript, is not easy. We can’t really be sure this implementation won’t
introduce a vulnerability.
129
If instead, our helper absolutely prevents this problem, we don’t have to
worry about any of that. We need to generate HTML-safe markup, but we
need to escape anything we can’t trust, such as the widget_id. While we
could handle that by calling CGI.escapeHTML from the standard library, it’s
much better to use Rails’ APIs like content_tag.
When our helper code sometimes uses html_safe and sometimes doesn’t,
it creates confusion. Developers will wonder when they have to use it and
when they shouldn’t. They will have to know the nuances of injection attacks
and know when to escape values and when not to. And they will have to
do it correctly. This is exceedingly difficult to manage. I’ve seen very senior
developers—myself included—mess this up, even after thinking it through
and getting peer feedback.
Instead, Rails provides content_tag (along with all the other various form
helpers), which will safely build strings with dynamic content.
Helpers, being Ruby code, can be tested, and it can be worth testing them
via unit tests.
Helpers are Ruby code, and if one of them is broken, the only way to know
that is to hope that a system test catches it. Since helpers are relatively easy
to test, there is value in testing them in isolation, at least when they have
nontrivial logic in them. We have to be careful not to overly specify our tests
for helpers, however, because we don’t want our helpers’ tests to fail if we
change immaterial things like styling.
130
Given the way styled_widget_id is implemented, there isn’t any logic to
it, and thus I’m not sure there is a lot of value in testing it. But, as a
demonstration, let’s make it more involved. Suppose that the business rules
are such that if the widget ID is less than 10,000 (which indicates it’s a
legacy widget imported from the old system), we prefix the value with
zeros to make it six digits. The widget with ID 1234 would render 0012.34,
whereas widget with ID 987654 would render as 9876.54.
We don’t want to over-couple our test to the markup in question, so let’s try
to assert only on the content, since that is what will change based on the
business rules.
We’ll write two tests, both of which go in application_helper_test.rb,
located in test/helpers/. The first will test that a widget with ID 1234
prefixes the value with two zeros. The second will check that widget 987654
does not.
We’ll do these checks using regular expressions, asserting that the rendered
widget id is present. Our regular expressions will also use \D to ensure that
no additional digits are present around the rendered ID.
Since we’re testing the output, we can also assert that it is HTMl safe. It’s
not worth testing just for this, but since we are testing the output, it’s good
to include.
# test/helpers/application_helper_test.rb
require "test_helper"
assert_match /\D0012\.34\D/,rendered_markup
assert rendered_markup.html_safe?
end
assert_match /\D9876\.54\D/,rendered_markup
assert rendered_markup.html_safe?
end
end
131
This test should fail, since we haven’t made the change yet:
# Running:
Failure:
ApplicationHelperTest#test_styled_widget_id_<_6_digits,_pad_. . .
Expected /\D0012\.34\D/ to match "<span style=\"font-family:. . .
Let’s change the implementation to match our test using Ruby’s rjust
method, which does basically what we want:
# app/helpers/application_helper.rb
def styled_widget_id(widget)
content_tag(:span,
→ widget.widget_id.to_s.rjust(7,"0"),
style: "font-family: monospace")
end
end
# Running:
132
..
If we change the styling of the component in the future, the test will continue
to pass as long as the ID is formatted properly.
Before we move on, let’s talk about handling situations where you have
more complex view logic. By default, helpers are the only way in Rails
to invoke a method directly in a view. Given that helpers are in a global
namespace, it can be come quite hard to manage them over time, and if
you have complex view-specific methods you want to write, you have to put
them into the global namespace and hope no one re-uses them.
Presenter frameworks are historically used to manage this, but in my ex-
perience, you can handle this with better resource modeling and/or View
Components.
class WidgetPresenter
delegate_missing_to :@widget
def initialize(widget)
@widget = widget
end
133
def local_to_user?(user)
widget.manufacturer.address.us_state == user.address.us_state
end
end
There are many ways to decorate one object with additional behavior, but
they all create the same problems. Managing these problems can be difficult,
and often requires code review to avoid a convoluted mess.
Because of this, I would caution against the use of presenters or proxies
and instead use one of the two techniques described below, starting with
resource modeling.
134
# config/routes.rb
resources :geographic_local_widgets, only: [ :index, :show ]
<ul>
<% @geographic_local_widgets.each do |geographic_local_widget| %>
<li>
<%= link_to geographic_local_widget do %>
Widget <%= geographic_local_widget.id %>
<% end %>
</li>
<% end %>
</ul>
class GeographicLocalWidget
include ActiveModel::Model
def local_to_user?(user)
manufacturer.address.us_state == user.address.us_state
end
end
135
def show
widget = Widget.find(params[:id])
@geographic_local_widget = GeographicLocalWidget.new(
id: widget.id,
name: widget.name,
manufacturer: widget.manufacturer
)
end
# app/components/widget_show_page_component.rb
class WidgetShowPageComponent < ViewComponent::Base
def initialize(widget: )
@widget = widget
end
136
def local_to_user?(current_user)
@widget.manufacturer.address.us_state ==
current_user.address.us_state
end
end
Up Next
Helpers are problematic, but so are the alternatives. Of course, you could
just live with some duplication in your markup, and this isn’t the worst idea
in the world. The “Don’t Repeat Yourself” (DRY) Principle isn’t any more of
a real rule than the Law of Demeter. It’s all trade-offs.
The news is about to get worse. All the problems that exist with helpers are
exacerbated by our next topic: CSS.
137
9
CSS
This section’s code is in the folder 09-01/ of the sample code.
Like helpers, the problem with CSS is how to manage the volume of code.
CSS, by its nature, makes the problem worse, because of the way CSS can
interact with itself and the markup. It’s not unheard of for a single line of
CSS to break an entire website’s visuals.
When CSS is unmanaged, developer productivity can go down, and the app
becomes less sustainable. There are two main factors that lead to this that
you must control:
• Some CSS must be written for each new view or change to a view. The
more required, the slower development will be.
• The more CSS that exists, that harder it is to locate re-usable classes,
which leads to both duplication and even more CSS. As with helpers,
there is a volume at which no developer can reasonably understand
all the CSS to locate re-usable components, and the safest route is to
add more CSS.
Therefore, to keep CSS from making your app unsustainable, you must
manage the volume. Ideally, the rate of growth in CSS is lower than the rate
of growth of the codebase. Thus, the more re-usable CSS you have, the less
CSS we will need.
To achieve this, you need three things:
The absolute biggest boon to any team in wrangling CSS is to adopt a design
system.
139
9.1 Adopt a Design System
A design system is a set of elemental units of the design of your app. At its
base, it is:
Any design for any part of the app uses these elemental units. For example,
any text in the app should be in one of the eight available sizes.
Many designers create a design system before doing a large project, because
it reduces the number of design decisions they have to make. Most apps
can be very well designed without needing an infinite number of font sizes,
spacing, or colors. For example, when a designer is laying out a page, they
can literally audition all eight font sizes and choose the best one.
You can leverage this by replicating the design system in your code. So
instead of specifying the font-size directly in pixels or rems, you specify “font
size 3” or “font size 5” (for example).
The design system can also contain reusable components like buttons, form
fields, or other complex layouts. These reusable components might not all
be known up front, so some emergent additions to the design system will
appear over time.
If your app is designed based on a design system, this will vastly reduce the
amount of CSS you have to write, and the CSS you do write will be easier
to understand and predict. Ideally, entire views can be created using the
design system’s classes without creating any new CSS.
Talk to your design team, if you have one, and ask about the design system.
Even if all they have is a set of font-sizes, that’s something. Encourage
them to standardize colors and spacings if they haven’t, and explain to them
(plus whatever manager might be around making decisions) that a stable
design system will boost your team’s productivity. You can always—if you
are feeling subversive—implement their designs using a design system you
reverse engineer. Many designers don’t want pixel-perfect designs.
Of course, not everything will conform to the design system. Some designs
will require something custom, but this should be a small percentage of the
designs and pages. Writing some CSS is OK, as long most of what you do
conforms to the design system.
If you don’t have a design team, which is common when building so-called
“internal” software (for example, a customer service app), you can use a CSS
framework which will be based on its own design system. We’ll talk about
that in the next section.
140
9.2 Adopt a CSS Strategy
A design system is great, but if you don’t have a way to manage your CSS
and leverage that system, your CSS will be a huge mess. Unfortunately,
Rails does not provide any guidance on how to manage CSS. Before Rails 7,
Rails’ generators create per-controller .css files, creating confusion. These
files gave the illusion of modularity, but those .css files were rolled up into
one application.css and you ended up with a global namespace of classes
spread across many files.
I do want to be explicit about a strategy you should not use, which is likely
the strategy you learned when you first learned web development: semantic
CSS.
There is no value in giving markup a class that has some semantic meaning.
Users using a web browser won’t see this class, and assistive technologies
rely on ARIA Roles1 when more meaning is needed for some markup. If
you need to provide a hook for a piece of the DOM for non-presentational
purposes, data- attributes are more effective.
Both OOCSS and Functional CSS take the approach of using classes in
markup to have presentational meaning. They differ in exactly how they do
that. Both approaches are ways to manage CSS and thus create your design
system in code. A framework does all this for you, but it’s not always the
right choice.
1 https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles
141
9.2.1 A CSS Framework
A CSS Framework is something like Bootstrap2 or Bulma3 . These contain
a wide variety of pre-styled components, from font-sizes to complex forms
and dialog boxes. For an internally-facing app, a framework is going to
make your team far more productive than hand-styling views, because the
design doesn’t matter as much as for a public-facing app, and, you rarely
need highly-branded visual styling for internal apps.
Using something like Bootstrap means you don’t need to create a design
system (Bootstrap and other frameworks have a set of defaults built-in),
and without writing any CSS, anyone on the team can design and build
UIs that look pretty good. CSS Frameworks aren’t replacements for real
designers or user-experience experts, but if you have internal apps that can
use a framework as its design system, you have fewer decisions to make and
will have an easier time building views.
Also, there will be far less CSS to manage, and you won’t need to write
much, if any. This is highly sustainable.
That said, most public-facing apps need more customization, more special-
ized branding, and have more functionality than the simple web forms and
info dumps present in an internal app.
In those cases, you will want more control over CSS and you will want to
implement and grow the design system yourself. Thus, you need a single
convention on how to use CSS, which comes down to deciding what the
classes should be on your markup.
There are many popular approaches that I’m going to group together as
object-oriented CSS, which we’ll discuss first.
142
and modifiers which tweak it. For example, a button always has rounded
corners, but a dangerous button will additionally have red text.
Two common strategies for OOCSS are Block-Element-Modifier (BEM)4
and SMACCS5 . If you like the OOCSS approach, I strongly recommend
adopting one of these two, with BEM being slightly easier to understand in
my experience.
For example, suppose we want to enhance the <h1> and <h2> in our widget
show page. We want the widget’s name to be bold and in all-caps, and we
want the ID to be in a monospace font. In an OOCSS approach, you might
do something like this:
<header class="widget-title">
<h1 class="widget-title__name">Stembolt</h1>
<h2 class="widget-title__id">123.45</h2>
</header>
The classes demarcate each part of the component. The CSS might look like
so:
.widget-title__name {
font-weight: bold;
text-transform: uppercase;
}
.widget-title__id {
font-weight: normal;
font-family: monospace;
}
Although the widget-title class doesn’t get styling in this example, you
can begin to see the theory here. Components have a class indicating what
they are (not semantically, but presentationally), and we use a naming
convention to create classes as needed for the parts of the component. Note
that we don’t prescribe the HTML tags to use; the CSS is agnostic. This
allows us to re-use this component’s styling in a situation where perhaps an
<h3> is more appropriate than an <h1>.
This approach is sustainable, mostly because it provides a clear and simple
way to keep CSS isolated. CSS can get very complicated when there is deep
4 https://getbem.com
5 http://smacss.com
143
nesting and stacking of styles, and an OOCSS approach instead keeps them
flat
But, it’s not perfect. There are a few downsides:
The result is that you will read and write a lot of CSS. The CSS you write
will more or less grow linearly with your markup and views, and the more
of it that exists, the less likely you and your team are to re-use it without
careful grooming and documentation.
Another approach is functional CSS.
144
The CSS for this code (which, remember, you don’t have to write) might
look like so:
These terse classes are based on Tachyons6 . When you use functional CSS,
you typically use a library like Tachyons or Tailwind7 which provide all the
CSS classes you need. There are usually several CSS classes for each CSS
attribute.
For example, there might be 10 classes for 10 common values for
font-weight: 100, 200, 300, 400, 500, 600, 700, 800, 900, and bold.
Tachyons uses extremely terse names like fw4 or fwb, whereas Tailwind uses
more semantic names like font-thin or font-bold.
In my experience, Tachyons becomes easier to use over time, because most
of the classes are terse initialisms and mnemonics. For example ttu is a way
to set text-transform to uppercase. In Tailwind, the class to accomplish
this is uppercase, which might read better but has zero connection to the
underlying CSS it produces.
145
Re-use with Functional CSS Leverages your HTML Templates
Unlike using a framework or OOCSS, functional CSS does not include an
obvious way to extract re-usable components. If we have red bold text, set
in the second largest font, all in uppercase with wide letter spacing, we’d
have to write <p class="red fwb f2 ttu tracked"> everywhere we wanted
to re-use that.
Functional CSS approaches assume that the unit of re-use is not the CSS
class, but your templating system. We discussed in the chapter “HTML Tem-
plates” on page 93 how to re-use markup using partials or View Components.
Because that markup has classes that represent all the needed styles, this
naturally allows re-use of styling as well.
Thinking of markup as the unit of re-use also provides one way to manage
re-usable components, not two. That said, when you do need to create
your own classes, you may want to re-use aspects of your functional CSS
framework. Some frameworks make this easy—by providing CSS custom
properties—and some make it more difficult, requiring a complex toolchain
of configuration files and pre-processors.
146
use Tailwind, I highly suggest you do this, otherwise you will have all the
mess of semantic CSS, but spread all overyour view code instead of in .css
files.
Once you have chosen a strategy, you need to use it to build the design
system, and the best way to do that is to create a living style guide.
# Gemfile
8 https://getbootstrap.com/docs/4.4/getting-started/introduction/
9 https://sass-lang.com
10 https://github.com/sustainable-rails/tachyonscss-rails
147
# View Component is used to manage
# and test complex view logic
> rm app/assets/stylesheets/application.css
/* app/assets/stylesheets/application.scss */
@import "tachyons";
@import is a SASS function that brings in external SASS files. Figuring out
the value to give it for an externally-required gem is not usually possible—
the gem maintainer has to tell you. In this case, the gem maintainer is me,
and the README indicates to use "tachyons".
As mentioned above, our design system should have at least a set of font
sizes, spacings, and colors. For the sake of brevity, let’s assume that our
design system’s spacing and colors are exactly those provided by Tachyons.
Our font sizes are different. Our designer has chosen these eight sizes
(specified in rems):
• 4.8rem
• 3.7rem
• 2.8rem
• 2.2rem
• 1.7rem
• 1.3rem
• 1.0rem
• 0.8rem
148
The tachyonscss-rails gem embeds the .scss files from the Tachyons SASS
port. You can see what is included by looking at the gem’s source on
GitHub11 .
At the top are the values for font sizes, like so:
The !default construct means that if we don’t set a value for that variable,
the value in _variables.scss will be used. For example, if we don’t set a
value for $font-size-1, the value 3rem will be used. This allows tachyons
to have a default design system if we don’t provide our own.
To override these, we’ll set values for all nine font variables (the two smallest
fonts will be the same size since we only have eight font sizes). It’s important
that we leave $font-size-5 as 1rem, because that is assumed by Tachyons
to be the body font size, which is the size of normal text.
Note that we’ll need to set these values before the call to @import or they
won’t take affect. Here’s the change to app/assets/stylesheets/application.scss:
/* app/assets/stylesheets/application.scss */
→ $font-size-headline: 4.8rem;
→ $font-size-subheadline: 3.7rem;
→ $font-size-1: 2.8rem;
→ $font-size-2: 2.2rem;
→ $font-size-3: 1.7rem;
→ $font-size-4: 1.3rem;
→ /* font-size-5 should always be 1rem
→ * as Tachyons expects this to be the
→ * body font. */
→ $font-size-5: 1rem;
11 https://github.com/sustainable-rails/tachyonscss-rails/blob/main/app/assets/styleshee
ts/scss/_variables.scss
149
→ $font-size-6: 0.8rem;
→ $font-size-7: 0.8rem;
→
@import "tachyons";
With that done, we’ll create our style guide, which is a demonstration of our
design system. We’ll create a new resource called design_system_docs that
has an index action.
We’ll first add the route, but only if we are in development (we don’t want
our users seeing the style guide):
# config/routes.rb
→ if Rails.env.development?
→ resources :design_system_docs, only: [ :index ]
→ end
→
####
# Custom routes start here
#
We still want to follow the conventions we’ve established about views, so that
means our controller methods should expose an instance variable named
@design_system_docs. We’ll use OpenStruct again to create this object. It’ll
have three methods: font_sizes, sizes, and colors.
The font_sizes attribute will be a list of class names to use to achieve
those font sizes. For sizes, since there are margins and padding, we’ll use
the numbers 1–5 and dynamically construct the class names in the view.
For colors, we’ll create a map from the color name to the CSS class that
achieves it.
# app/controllers/design_system_docs_controller.rb
def index
@design_system_docs = OpenStruct.new(
150
font_sizes: [
"f-headline",
"f-subheadline",
"f1",
"f2",
"f3",
"f4",
"f5",
"f6",
],
sizes: [ 1,2,3,4,5 ],
colors: {
text: "near-black",
green: "dark-green",
red: "dark-red",
orange: "orange"
}
)
end
end
The view is going to be a bit gnarly, because we have to generate markup that
uses these styles but also show the code that achieved that markup. We’ll
have three sections and a <nav> at the top, along with a link to Tachyons’
docs.
<section class="pa3">
<h1>
Design System Docs
<nav class="f4 di ml3">
<a href="#font-sizes">Font Sizes</a> |
<a href="#sizes">Sizes</a> |
<a href="#colors">Colors</a> |
<a href="https://tachyons.io/docs/">Tachyons Docs</a>
</nav>
</h1>
<h2 id="font-sizes">Font Sizes</h2>
<% @design_system_docs.font_sizes.
each do |font_size_css_class| %>
<p class="<%= font_size_css_class %> mt0 mb0">
<%= font_size_css_class %> Font Size
</p>
151
<code><pre>
<p class="<%= font_size_css_class %>">
<%= font_size_css_class %> Font Size
</p>
</pre></code>
<% end %>
<h2 id="sizes">Sizes</h2>
<% @design_system_docs.sizes.each do |size_number| %>
<h3>Size <%= size_number %></h3>
<div class="pa<%= size_number %> ba
h<%= size_number %>
w<%= size_number %> bg-gray">
</div>
<code><pre>
<div class="pa<%= size_number %>">
Padding all sides
</div>
152
Now, if you go to /design_system_docs, you should see it just like the screen-
shot “Font Size Documentation” on the next page, “Sizes Documentation”
on page 155, and “Color Documentation” on page 156.
You may need more documentation than this, depending on what you are
doing. You could also build the page statically instead of making an object
like I did. In any case, this page should provide as much information as pos-
sible about your CSS strategy, the design system, any reusable components,
and how to use it all.
Whenever a re-usable component is created, this page should also be up-
dated, and you’ll have to manage that with code review or pair program-
ming.
If you can manage this, you’ll stick to your CSS Strategy and leverage your
design system, and while your CSS won’t be amazingly perfect, it will be as
sustainable as you can make it, and that’s a pretty good result.
Up Next
CSS is not an easy thing to learn or manage. So it goes with JavaScript.
153
154 Figure 9.1: Font Size Documentation
Figure 9.2: Sizes Documentation 155
Figure 9.3: Color Documentation
156
10
Minimize JavaScript
JavaScript and front-end development is a deep topic. I won’t be able to
cover it all here and I definitely can’t give you a guide on sustainably creating
highly complex dynamic web applications that run entirely in the browser.
The good news is that you almost certainly don’t need your application
to work that way. At best, you’ll need what Zach Briggs calls “islands of
interactivity”1 : bits of dynamic behavior on some of your pages.
The single best thing you can do to keep your front-end sustainable is to use
only what JavaScript you actually need to deliver value to the app’s users.
There are a lot of current realities about client-side JavaScript and web
browsers that make it inherently more difficult to work with than back-end
technologies.
In this chapter, we’ll focus on JavaScript generally: how to think about it
and manage it at a high level. The overall strategy here is:
JavaScript solves real problems we face as developers, but it’s not perfect—
how could it be? The strategy here is designed to keep your app sustainable
by dealing directly with the realities of JavaScript and the front-end ecosys-
tem. It’s important to make decisions based on the realities of how our tools
work, not on how we wish they worked.
To understand this strategy requires being honest about how serious of a
liability client-side JavaScript is to your app, so let’s dive in.
157
Your app is a liability. You are responsible for it. You are responsible for
building it, maintaining it, operating it, and explaining its behavior to others.
This book is about how to manage that responsibility.
But liabilities are relative. Compared to the other code in your app, client-
side JavaScript (here on called simply “JavaScript”) is a more serious liability.
It is a large responsibility relative to the back-end, all other things being
equal.
It’s important to understand why this is, so that you can drive your technical
architecture decisions based on realities and not dogma.
There are three contributors to JavaScript as a more serious liability:
158
Pretty much the only mechanisms you have in your development environ-
ment are the odd calls to console.log or step through the code in the
browser’s debugger. Browsers do provide additional tools for inspecting
your code, but JavaScript’s nature prevents them from being very sophisti-
cated. When you see errors in the console, the stack traces are often wrong.
Most JavaScript runtimes produce unhelpful errors such as “undefined is
not a function”. But at least you can do something in your own browser.
In production, JavaScript is running on the browsers of your app’s users
and there is no way by default for you to observe that behavior on any level.
If you’ve ever supported applications for users at the company you work
for, you’ve no doubt asked those users to open the browser console to help
debug a problem2 .
What this means is that your code that’s already running on myriad envi-
ronments you cannot control also cannot be observed. The most common
tool available to try to observe JavaScript’s behavior is to install an error
reporting system like Bugsnag. In my experience, tools like this are useful,
but they produce a lot of noise and don’t drive a lot of clarity (see the
sidebar “A Year of JS Monitoring and Nothing to Show For It” below for an
example of this). JavaScript libraries you depend on generate spurious error
messages and, even with source maps on production, stack traces are almost
always wrong.
2 The associates working in Stitch Fix’s warehouse called the JavaScript console “The
Matrix”, because it was like going behind the scenes of the real world and hacking the system.
159
Compare this to your back-end code. It is possible to get a very fine-
grained understanding of how it behaves. By default, Rails logs requests and
responses, which is more than you get with JavaScript. We set up lograge in
the section “Improving Production Logging with lograge” on page 52, which
makes those logs even more useful. We can write our own log messages.
We can install tools like DataDog or Honeycomb to tell us how often certain
parts of our app are executed and how long they took. And on and on.
This means that problems in your JavaScript code are harder to predict,
harder to detect, and harder to fix once detected.
But it gets worse, because the ecosystem as it stands moves forward very
fast, favoring progress over stability.
160
This reality results in a situation where regular updates of your depen-
dencies can cause a cascading effect of errors that can be difficult and
time-consuming to fix. While you can somewhat rely on the Rails core team
to make sure the dependencies that are a part of Rails keep working with
Rails, anything you bring in isn’t subject to that level of care. This is your
responsibility.
The single best thing you can do to manage the liabilities that come with
JavaScript is to minimize its use to only where it is needed. By all means,
use it when you need it, but don’t use it when you don’t.
A big step toward that goal is to prefer server-rendered views using ERB.
161
Figure 10.1: Server-Rendered Views
You may use Hotwire, which is included in Rails 7. This provides a zero-
JavaScript solution for common uses cases, but leaves you on your own for
anything else. Or you might choose React, which requires that some of your
HTML be written in JSX, leaving you two ways to write markup. In the next
chapter on page 169, we’ll talk a bit about how to navigate these trade-offs.
Another perceived downside is performance. The theory goes that full page
refreshes are always slower than if content is fetched with Ajax. It is true
that server-rendered HTML sends more bytes over the network than an
162
Ajax request and it is true that re-rendering the entire page is slower than
updating part of the existing DOM.
What is not true is that these differences always matter. Optimizing the
performance of an application is a tricky business. Often the source of poor
performance isn’t what you think it might be, and it requires careful analysis
to understand both where the problem lies and what the right solution is.
In my experience, most performance problems are caused by the database.
If our page requires executing a database query, and that query isn’t indexed,
no front-end rendering optimization in the world is going to fix what a
single line of SQL can.
All this to say that choosing to avoid server-rendered views because of a
performance problem that you don’t know you have and that you don’t know
matters is not a sound basis for making technical architecture decisions.
And, of course, using the JAM Stack to boost performance carries a large
carrying cost. Let’s see how that works.
Carefully consider your problem space against these benefits. There are
many downsides to this approach:
• You must carefully map JSON responses to the input of each front-
end component and carefully manage the state of the app’s front-end.
163
Figure 10.2: JAM Stack Rendering
164
A JAMStack approach might feel good because it decouples the front-end
from the back-end, and we are often taught that decoupling is good. But
Rails is designed to couple key parts of our app together to make common
needs easy to implement.
When working on a Rails app, the developers have control over the entire
experience, so the back-end can be built in concert with the front-end. De-
coupling them doesn’t have a strong advantage. It just makes things harder
to build.
That’s not to say you should never use the JAM Stack in your app, but you
should use it only when it’s needed, and only if you are confident that the
risks are outweighed by the benefits. This is not common.
If you use Rails server-rendered views by default, you will create a situation
in which simple things are simple. You can still use the JAM Stack in portions
of your app when you determine there is a strong need to do so. See the
sidebar “Single Feature JAM Stack Apps at Stitch Fix” on the next page for
an example of how this can make your app successful.
165
Single Feature JAM Stack Apps at Stitch Fix
The Stitch Fix warehouse was originally managed by a run-of-the-mill
Rails app that we called S PECTRE. The warehouse was comprised of different
stations and the person working those stations used a custom-built screen in
S PECTRE to do their job. For example, one station printed shipping labels,
and another located items for a shipment.
Locating items—which we called picking—was by far the most frequent
activity in the warehouse. Users would be given five items at a time to locate.
This required at least seven full-page refreshes: one to get started, one for
each item, and one to tell the picker what to do after all five items were
picked. The Internet connection in the warehouses was initially very slow
and unreliable, so these page refreshes, driven by server requests, often
timed out and caused pickers to spend too much time picking.
We re-implemented this feature using the hottest front-end framework of
2014: AngularJS. The initial page load grabbed all the data, and the browser
handled all interactivity during the picking process. The only network
connection needed was after picking was complete. The entire picking
process could be done without any network connection at all.
Even though the rest of S PECTRE was driven by server-rendered views,
the picking feature was a JAM Stack app that solved a real problem for users.
While there was friction if you had to switch back and forth while working
on S PECTRE, the result was that easy things were easy, but complex things
could be built.
All this to say, you will need JavaScript. You might need very small bits of
glue code between elements or full-blown interactive components, but you
can’t avoid it entirely. You want it predictable, stable, and small.
In order to effectively manage the behavior of your views, and any JavaScript
that is needed, you need a solid baseline of behavior on which to build. Rails
provides this, with one tiny exception: Turbo’s default setting for showing a
progress bar.
Turbo (formerly called Turbolinks) hijacks all clicks and form submissions
and replaces them with Ajax calls. It then replaces the <body> of the page
with whatever the <body> is of the returned result. This is ostensibly to
make every page faster, but it often leads to your app feeling broken instead
since it will only show a progress bar after 500ms of waiting.
My recommendation is to modify Turbo’s progress timeout.
The reason is that Turbo can make your app feel broken any time a controller
fails to respond instantly. A common rule of thumb in user experience is
166
that if the response to a user’s action takes more than 100ms to happen, the
user will lose the sense of causality between their action and the result. The
app will feel broken.
If your controller, along with the network time, takes more than 100ms to
respond, and Turbo is enabled, your app may feel broken, because Turbo
prevents the browser from showing any progress UI. Turbo will provide its
own, but only if more than 500ms have elapsed. That’s too long.
Fortunately, we can change the default without much code. Our app is
loading Turbo from app/javascript/application.js, but we need access
to the returned object in order to make configuration changes. We’ll modify
the import statement to assign the result to the variable Turbo, which we
can then use to call setProgressBarDelay:
/* app/javascript/application.js */
One thing to note about Turbo is that while the developers have gone to
great lengths to make sure it plays well with the browser and any other
JavaScript you may have, it is a layer of indirection between user actions in
the browser and your code. Make sure you understand how any JavaScript
that might also hook into the browser works. In particular, the use of
DOMContentLoaded could cause unpredictable behavior, since it won’t be
triggered every time a link is clicked (you must use the turbo:load event,
instead).
Up Next
These small changes will give you a more predictable base on which to build,
along with a more reasonable default user experience.
Of course, there’s almost no way to avoid JavaScript entirely and so this
leads to our next topic, which is how to manage the JavaScript you do have
to write. You want to use whatever JavaScript you actually need to make
your app succeed, but you should carefully manage it, since it is the least
stable part of your app.
167
11
Let’s jump into the first one, which is to embrace the power of plain,
framework-free JavaScript.
The more dependencies your app has, the harder it’s going to be to maintain.
Fixing bugs, addressing security issues, and leveraging new features all
require updating and managing your dependencies. Further, as we discussed
way back in “Consistency” on page 14, the fewer ways of doing something
in the app, the better.
Your app likely doesn’t need many interactive features, especially when it’s
young. For any interactivity that you do need, it can often be simpler to
build features that work without JavaScript then add interactivity on top
169
of that. Modern browsers provide powerful APIs for interacting with your
markup, and it can reduce the overall complexity of your app to use those
APIs before reaching for something like React.
Let’s do that in this section. Our existing widget rating system is built in a
classic fashion. Although there is no back-end currently, you might imagine
that it will show your rating for any widget where you’ve provided one.
Let’s suppose we want to do that without a page refresh. We want the user
to submit a rating and have the page remove the widget rating form and
replace it with a message like “You rated this widget 4”.
Let’s see how to do this with just plain JavaScript. I realize that the Hotwire1
framework in Rails provides a zero-code solution to this exact use case.
However, if you have not written plain JavaScript in a while, it’s important
to see just how little code is required to do this. The point I’m making in this
section is that you can get quite far without taking on any dependencies.
There are a lot of ways to do it, but the way I’ll show here is one that keeps
the number of moving parts to a minimum. We’ll render all the markup and
most of the content we will need for this feature in the ERB file, using CSS
to hide the markup that should not be shown.
When the user clicks on a rating, we’ll run some JavaScript to modify the
CSS on various parts of the markup to remove the form and show the rating,
while dynamically inserting that rating into the DOM in the right place.
First, we’ll add a new bit of markup that says “Thanks for rating this”.
Semantically, this should be inside a <p> tag. Since the rating depends on
what button the user clicked on, we’ll place a <span> to hold the value, and
we’ll use JavaScript to set it dynamically. The entire thing will need to be
surrounded in a <div>.
We’ll then use data- attributes on each bit of markup so that we can locate
them using JavaScript. This is preferable to using special classes because
data- elements aren’t commonly used for styling, whereas classes are almost
always used for styling.
<section>
→ <div class="dn" data-rating-present>
→ <p>Thanks for rating this a
→ <span data-rating-label></span>
→ </p>
→ </div>
<div class="clear-floats">
1 https://hotwired.dev
170
<h3 style="float: left; margin: 0; padding-right: 1rem;">
Rate This Widget:
The existing <div> will get hidden when the user clicks a rating, so that needs
a data- attribute as well. We’ll also replace our hand-made clear-floats
class with Tachyons’ cf class that does the same thing.
<span data-rating-label></span>
</p>
</div>
→ <div class="cf" data-no-rating-present>
<h3 style="float: left; margin: 0; padding-right: 1rem;">
Rate This Widget:
</h3>
Next, we’ll make two changes to the button_to call. The first is to make it
a remote Ajax call to WidgetRatingsController. That controller currently
does a redirect, but we’ll remove that so that it responds with an HTTP
204. This will allow us to trigger back-end logic without a page refresh.
The second change is to add a data- attribute to the button so that we can
attach a click handler to it.
First, we’ll add remote: true and data-rating to the button_to call:
# app/components/widget_rating_component.html.erb
Then, we’ll remove the redirect in the controller. I like to add the comment
# default render whenever there is branching logic in a controller, since
171
the absence of code in a Rails controller does imply a particular behavior is
going to occur.
# app/controllers/widget_ratings_controller.rb
def create
if params[:widget_id]
# find the widget
# update its rating
× # redirect_to widget_path(params[:widget_id]),
× # notice: "Thanks for rating!"
→ # default render
else
head :bad_request
end
Since there is no template for this controller action, the default behavior
is to return an HTTP 204, which is what we want. If we wanted to render
a view or take an action for a non-remote call, we can use respond_to to
differentiate.
Next, we need to write the actual JavaScript. We’ll put that in
app/javascript/widget_ratings/index.js which we’ll later reference via
the main application.js file. The way this will work is that we’ll create a
function named updateUIWithRating that will locate all the DOM elements
with data-rating-present and show them by adding Tachyons’ db class,
which stands for display: block (thus showing them).
We’ll then locate all elements with data-no-rating-present and add dn,
which stands for display: none (thus hiding them). Finally, we’ll locate the
<span> with data-rating-label and set its inner text to the chosen rating,
which will make the user see a sentence like “You rated this widget 4”.
We’ll use document.querySelectorAll, which allows locating elements via
a CSS selector and returning an array of matching elements. Even though
we only have one element for each selector we’re going to use, it’s better to
have our JavaScript not be coupled to that. Instead, it’ll handle any number
of those selectors. updateUIWithRating will accept the document and the
rating as parameters.
/* app/javascript/widget_ratings/index.js */
172
forEach( (element) => {
element.classList.add("db")
element.classList.remove("dn")
})
document.querySelectorAll("[data-no-rating-present]").
forEach( (element) => {
element.classList.add("dn")
})
document.querySelectorAll("[data-rating-label]").
forEach( (element) => {
element.innerText = `${rating}`
})
}
Note that the way we show and hide elements is to use CSS. Because we are
using functional CSS as discussed in “Functional CSS” on page 144, we can
use the same techniques here that we’d use in our markup, which a is nice
bit of consistency when it comes to styling the visual appearance of our app.
Now, we want this function to be run whenever a widget rating button is
clicked. To do that, we need to create an onclick event handler for each
button. To do that we have to wait until the DOM has been loaded so the
buttons are there for us to hook into. Since we are using Turbo (as it is
configured by default), the way to wait on the DOM to be loaded is to wait for
the event turbo:load, which is the Turbo equivalent of DOMContentLoaded.
We’ll wrap all of this into a function named start, and we’ll export that
function so it can be called in app/javascript/application.js. Note that
start will require the window as a parameter. Passing in global objects like
window and document keeps our functions self-contained if we should need
to unit test them.
/* app/javascript/widget_ratings/index.js */
element.innerText = `${rating}`
})
}
→
→ const start = (window) => {
→ const document = window.document
→ window.addEventListener("turbo:load", () => {
→ document.querySelectorAll(
→ "button[data-rating]"
173
→ ).forEach( (element) => {
→ element.onclick = (event) => {
→ const rating = element.innerText
→ updateUIWithRating(document, rating)
→ }
→ })
→ })
→ }
→
→ export const WidgetRatings = {
→ start: start,
→ }
/* app/javascript/application.js */
Turbo.setProgressBarDelay(100)
import "controllers"
→
→ import { WidgetRatings } from "widget_ratings"
→ WidgetRatings.start(window)
Prior to Rails 7, this code would’ve relied on Webpacker and Webpack and
been slightly different. Rails 7 introduced the concept of import maps,
provided by importmap-rails2 . Import maps are a modern mechanism for
managing JavaScript without a pre-compiler like Webpack.
Rails won’t automatically detect any new JavaScript file we create, so we
must add it to the configuration in config/importmap.rb. Even though
we only have one file in app/javascript/widget_ratings, we’ll still use
pin_all_from so that any newly-created files there in the future will be
picked up without needing a configuration change.
# config/importmap.rb
2 https://github.com/rails/importmap-rails
174
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: tru. . .
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", . . .
pin_all_from "app/javascript/controllers", under: "controller. . .
→ pin_all_from "app/javascript/widget_ratings",
→ under: "widget_ratings"
What this does is to tell the front-end that whenever code like import
"widget_ratings" is processed, to get the requested code from
app/javascript/widget_ratings. The browser does all this for you.
Rails will reload config/import.rb in theory, so if you are running your
server, you should not have to restart it.
With this in place, here is the order of events on our page:
1. The page is loaded when someone navigates to the widget show page.
2. start(window) is called in our new JavaScript code. This registers a
turbo:load handler.
3. The turbo:load event is fired.
4. Our handler is called, which attaches an onclick event to all five
buttons we created with button_to.
5. The user clicks a rating
6. Because we did not call preventDefault, the button will submit the
remote form back to the server.
7. This will trigger the create method of the WidgetRatingsController.
Although this doesn’t do anything now, you could imagine that it
would update a rating in the database or something like that.
8. updateUIWithRating is called with the given rating. This hides the
rating buttons and shows the “Thanks for rating” message, along with
the user’s specific rating.
Note that we aren’t waiting for the results of our AJAX call. This may
not be the right decision, depending on what the backend logic is. If
there is a chance the user could make a mistake, we want to wait for the
back-end to let us know if the request was successful and update the UI
accordingly. In this case, we assume any invalid request is the result of
someone circumventing our UI and so we won’t explicitly handle it.
Putting it all together, you should be able to navigate the widget show page,
click a rating and see all this working as in the screenshot on the next page.
This might have seemed like a lot of steps, but consider how little code
we had to change. We needed to add some new markup, but the existing
markup hardly changed at all. We had to write around 40 lines of JavaScript,
and we didn’t have to make any significant changes to the back-end.
This change feels commensurate with the complexity of the feature we
added. If we used something like React, we would’ve had to rewrite the
entire UI first, and then add the feature.
175
Figure 11.1: Ajax-based widget rating
As I said, there are many ways to do this, but the main idea to take away is
just how much you can actually do with plain JavaScript. For interactions
like showing or hiding DOM elements, plain JavaScript might be a good
trade-off, because we didn’t need any new dependencies to do this.
As our app ages and grows, this code will remain solid and reliable. As Rails
changes front-end approaches, something it has historically done frequently,
plain JavaScript will continue to work.
That said, you may need more. When the interactivity you require exceeds
basic Ajax calls and the showing or hiding of markup, a plain JavaScript
approach could turn into a hand-rolled framework. In those cases, an off-
the-shelf framework might be preferable. Adding any dependency to your
app introduces a carrying cost, and a JavaScript framework is one of the
largest, so you must choose carefully.
176
If you have no other constraints, Rails’ default of Hotwire is a good choice,
but React is also something to consider. Let’s take this section to see why
and how it relates to sustainability.
As your app evolves and as time goes by, versions of your dependencies—
including Rails—will change. Bugs will be fixed, features will be added, and
security vulnerabilities will be addressed. Your app will also gain features,
change developers, change teams, and generally evolve. The more you can
rely on your dependencies to weather these changes, the better.
Thus, when you make decisions for sustainability, you want to favor depen-
dencies that are stable, widely understood, well-supported, and that easily
work with Rails. These are potentially more important than features and far
more important than personal preference.
I would urge you to make a decision aid for each framework you want to
consider. Write down these criteria, along with any other that you feel are
important. Here are three different versions for React, Angular, and Hotwire.
I’ve included two subjective criteria: “Org Support”, how well the overall
organization supports the framework, and “Team Appetite”, how excited the
team would be to use the framework. We’ll start with one for React.
177
Table 11.3: Decision Aid for Hotwire as a Front-end Framework
Criteria Rating Notes
Mind-share Low Subset of Rails developers at best
Stability Medium Used by 37Signals in production
Rails Support High Developed by 37Signals
Org Support No guidance
Team Appetite Medium
In the next chapter on page 181, we’ll talk about the deeper value and
purpose of testing, but to briefly preview it, testing is a way to mitigate the
risk of shipping broken code to production.
178
Because of JavaScript’s unique attributes as discussed in the previous chapter
on page 157, it may seem that there is greater value in unit testing JavaScript
that is already covered by system tests if that JavaScript is complex.
That said, unit testing JavaScript is not easy. There is no all-in-one testing
framework and setting up JavaScript-based tests requires a lot of decisions,
plumbing, and dependencies. It also requires having Node installed in your
development environment, which results in testing your JavaScript in an
environment that is not the same as that where it runs: Node is a server-side
platform, whereas your JavaScript runs in a web browser.
To make matters worse, common tools for JS testing fall out of favor quickly,
as you can see in the State of JS Survey results3 .
The previous edition of this book had us set up Jest4 and write a unit test of
the code we wrote in the last section. I no longer believe it is worth doing
based on the code we have and the approach we have taken thus far. This
chapter of the book broke every time there was a change in the underlying
tooling, and I think there is a sustainability lesson here.
Your system tests (discussed in the next chapter) should fail if your
JavaScript is broken. While a unit test might be a faster way to know this,
the carrying cost of Node, NPM, and the modules required for even a basic
testing toolchain may be too high for the value they bring, especially if you
are really minimizing JavaScript. In particular, if you are using Hotwire and
related technologies, you may not even have that much JavaScript to test.
What I would recommend instead is to include unit testing as part of your
technology decision aide. For example, Hotwire provides zero-code solutions
to common use cases, thus no unit testing would be required, since there
is no code. React may require unit tests, and setting this up is relatively
supported by the community. Arbitrary testing of plain JavaScript is less-
supported, though possible.
My recommendation is to add JavaScript unit testing if you truly believe the
value it brings exceeds the carrying cost of the toolchain required, and to
choose your toolchain based on community support and stability. I would
also strongly recommend that you not rely entirely on Node-based unit tests
to ensure the proper functioning of your JavaScript, because your JavaScript
will be run in a browser.
Up Next
As mentioned above, your view should be tested and those tests should fail
if the JavaScript is broken. That’s what we’ll cover next.
3 https://2020.stateofjs.com/en-US/technologies/testing/
4 https://jestjs.io
179
12
I get paid for code that works, not for tests, so my philosophy is to test
as little as possible to reach a given level of confidence.
181
A user-focused test is one that exercises a part of the software the way a
user would use it. In a Rails app, that means a system test.
System tests are expensive. They have a high carrying cost, but if we
approach them in the right way, they can bring immense value. The key is
to avoid over-testing.
The strategy I recommend is to have a system test for every major user flow,
use unit tests to get coverage of anything else that is important, and closely
monitor production for failures.
A “major” flow is one that is critical to the problem the app exists to solve.
It’s something that, if broken, would severely impact the efficacy of the
app. Authentication is a great example. An FAQ page would not be a good
example (in most cases).
The point is, you have to decide what is and is not a major user flow. Most
of your app’s features ought to be major flows, because hopefully you are
only building features that matter. But however many it is, they should have
system tests.
To keep system tests manageable, we’ll talk through the following tactics:
• Do not use a real browser for features that don’t require JavaScript.
• Test against markup and content by default.
• If markup becomes unstable, use data-testid to locate elements
needed for a test.
• Cultivate diagnostic tools to debug test failures.
• Fake out the back-end to get the test of the front-end passing, then
use that test to drive the back-end implementation.
• Use a real browser for any feature that does require JavaScript.
Because we’re only using JavaScript where we need it, and because we
are favoring Rails’ server-rendered views, most of our features should work
without requiring JavaScript2 . One of the benefits to this approach is that
we can test these features without using a real web browser.
Rails system tests use Chrome by default. We’ll set that up later, but for
now, let’s codify our architectural decisions around server-rendered views
by making the default test driver for system tests the :rack_test driver.
2 This doesn’t mean there isn’t any JavaScript for these features, just that the features can
182
We can do this in test/application_system_test_case.rb.
# test/application_system_test_case.rb
require "test_helper"
There is currently an issue with Rails 7 that causes system tests to run in the
wrong environment when using the dotenv-rails gem, which we are using.
To work around that, we’re going to add some code to Rakefile, like so:
# Rakefile
Rails.application.load_tasks
We have a major user flow where the user sees a list of widgets, clicks
one, and sees more information about that widget. It does not re-
quire JavaScript, so we can write a test for it now. We’ll do that in
test/system/view_widget_test.rb:
183
# test/system/view_widget_test.rb
require "application_system_test_case"
We’ll use the DOM to locate content that allows us to confidently assert the
page is working. As a first pass, we’ll use the DOM as it is. That means we’ll
184
expect two <li>s in a <ul> that have our widget names in them. We’ll click
an <a> inside one, and expect to see the widget’s name in an <h1> with its
formatted ID in an <h2>.
We’ll assert on regular expressions instead of exact content, so that trivial
changes in copy won’t break our test. Also note that we’re using case-
insensitive regular expressions (they end with /i) to further insulate our
tests from trivial content changes.
# test/system/view_widget_test.rb
# Running:
185
F
Failure:
ViewWidgetTest#test_we_can_see_a_list_of_widgets_and_view_on. . .
expected to find visible css "h1" with text /stembolt/i but . . .
The error message is not very helpful. It tells us what assertion failed, but it
doesn’t tell us why. To figure this out often requires some trial and error.
A common tactic is to add something like puts page.html right before the
failing assertion, but let’s make a better version of that concept that we can
use as a surgical diagnostic tool.
A big part of the carrying cost of system tests is the time it takes to diagnose
why they are failing when we don’t believe the feature being tested is
actually broken. The assertions available to Rails provide only rudimentary
assistance. Your team will eventually learn to use puts page.html as a
diagnostic tool, but let’s take time now to make one that works a bit better.
Let’s wrap puts page.html in a method called with_clues. with_clues will
take a block of code and, if there is any exception, produce some diagnostic
information (currently the page’s HTML) then re-raise the exception. This
will be a foothold for adding more useful diagnostic information later.
Let’s put this in a separate file and module, then include that into
ApplicationSystemTestCase. As we build up a library of useful diagnostic
tools, we don’t want our test/application_system_test_case.rb file
getting out of control.
We’ll put this in test/support/with_clues.rb:
# test/support/with_clues.rb
186
module TestSupport
module WithClues
# Wrap any assertion with this method to get more
# useful context and diagnostics when a test is
# unexpectedly failing
def with_clues(&block)
block.()
rescue Exception => ex
puts "[ with_clues ] Test failed: #{ex.message}"
puts "[ with_clues ] HTML {"
puts
puts page.html
puts
puts "[ with_clues ] } END HTML"
raise ex
end
end
end
# test/application_system_test_case.rb
require "test_helper"
→ require "support/with_clues"
# test/application_system_test_case.rb
require "support/with_clues"
187
Note that we’ve prepended messages from this method with [ with_clues ]
so it’s clear what is generating these messages. There’s nothing more difficult
than debugging code that produces output whose source you cannot identify.
If we wrap the assertion like so:
# test/system/view_widget_test.rb
When we run the test, we’ll see the HTML of the page:
# Running:
<!DOCTYPE html>
<html>
<head>
<title>Widgets</title>
<meta name="viewport" content="width=device-width,initia. . .
188
"controllers/hello_controller": "/assets/controllers/hel. . .
"controllers": "/assets/controllers/index-2db729dddcc5b9. . .
"widget_ratings": "/assets/widget_ratings/index-b6eb9ad1. . .
}
}</script>
<link rel="modulepreload" href="/assets/application-85989497. . .
<link rel="modulepreload" href="/assets/turbo.min-dfd93b3092. . .
<link rel="modulepreload" href="/assets/stimulus.min-dd364f1. . .
<link rel="modulepreload" href="/assets/stimulus-loading-357. . .
<script src="/assets/es-module-shims.min-4ca9b3dd5e434131e3b. . .
<script type="module">import "application"</script>
</head>
<body>
<h1>Widget 1</h1>
<h2>ID #<span style="font-family: monospace">0000001</span><. . .
<section>
<div class="dn" data-rating-present>
<p>Thanks for rating this a
<span data-rating-label></span>
</p>
</div>
<div class="cf" data-no-rating-present>
<h3 style="float: left; margin: 0; padding-right: 1rem;". . .
Rate This Widget:
</h3>
<ol style="list-style: none; padding: 0; margin: 0">
<li style="float: left">
<form class="button_to" method="post" action="/wid. . .
</li>
<li style="float: left">
<form class="button_to" method="post" action="/wid. . .
</li>
<li style="float: left">
<form class="button_to" method="post" action="/wid. . .
</li>
<li style="float: left">
<form class="button_to" method="post" action="/wid. . .
</li>
<li style="float: left">
<form class="button_to" method="post" action="/wid. . .
</li>
</ol>
</div>
</section>
189
</body>
</html>
Failure:
ViewWidgetTest#test_we_can_see_a_list_of_widgets_and_view_on. . .
expected to find visible css "h1" with text /stembolt/i but . . .
We can see that the problem is that our faked-out data isn’t consistent. The
fake widgets in the index view are not the same as those in the show view.
We’ll fix that in a minute.
Note that with_clues is a form of executable documentation. with_clues is
the answer to “How do I figure out why my system test failed?”. As your
team learns more about how to diagnose these problems, they can enhance
with_clues for everyone on the team, including future team members. This
reduces the carrying cost of these tests.
While this implementation is perfectly fine, it’s really only a demonstration
of the concept of creating a diagnostic tool. If you’d like to use with_clues
in your app, you can use the gem with_clues3 that was extracted from
codebases where this concept was developed.
OK, to fix our test, we should make our faked-out back-end more consistent.
System tests are hard to write in a pure test-driven style. You often need to
start with a view that actually renders the way it’s intended, and then write
your test to assert behavior based on that.
If you are also trying to make the back-end work at the same time, it can be
difficult to get everything functioning at once. It’s often easier to take it one
3 https://github.com/sustainable-rails/with_clues
190
step at a time, and since we are working outside in, that means faking the
back-end so we can get the view working.
Once you have the view working, you don’t actually need a real back-end to
write your system test. If you write your system test against a fake back-end,
you can then drive your back-end work with that system test. This leaves
you where you want to be: an end-to-end test of the actual functionality. It’s
just easier to get there by starting off with a fake back-end.
Let’s do that now. We need the hard-coded Stembolt to have an ID of
1234, and we need our show page to detect item 1234 and use the name
“Stembolt” instead of “Widget 1234”. We can do this in WidgetsController:
# app/controllers/widgets_controller.rb
end
def index
@widgets = [
→ OpenStruct.new(id: 1234, name: "Stembolt"),
OpenStruct.new(id: 2, name: "Flux Capacitor"),
]
end
Next, we need the show method to use the name “Stembolt” if the id is 1234:
We’ll create a variable called widget_name:
# app/controllers/widgets_controller.rb
country: "UK"
)
)
→ widget_name = if params[:id].to_i == 1234
→ "Stembolt"
→ else
→ "Widget #{params[:id]}"
→ end
@widget = OpenStruct.new(id: params[:id],
manufacturer_id: manufacturer.id. . .
manufacturer: manufacturer,
And we’ll use that for the name: value in our OpenStruct:
191
# app/controllers/widgets_controller.rb
Now that our faked-out back-end is more consistent with itself, our test
should pass:
# Running:
With this test passing, we should remove our diagnostic call to with_clues,
because we really don’t want it littered all over the codebase.
# test/system/view_widget_test.rb
What if our view’s markup changes in a way that causes our tests to fail but
doesn’t affect the app’s functionality? For example, we may change the <h1>
to an <h2> to address an issue with accessibility. This will cause our test to
fail even though its functionality is still working, since this is not a test what
192
tags were used in the view. This sort of test failure can create drag on the
team and reduce sustainability. Chasing the markup can be an unpleasant
carrying cost, so let’s talk about a simple technique to reduce this cost next.
The tags used in our view are currently semantically correct, and thus our
tests can safely rely on that. However, these semantics might change without
affecting the way the page actually works. Suppose our designer wants a
new message, “Widget Information”, on the page as the most important
thing on the page.
That means our widget name should no longer be an <h1>, but instead an
<h2>.
Here’s the change to update the view:
→ <h1>Widget Information</h1>
→ <h2><%= @widget.name %></h2>
<h2>ID #<%= styled_widget_id(@widget) %></h2>
<% if flash[:notice].present? %>
<aside>
This change will break our tests even though the change didn’t affect the
functionality of the feature:
# Running:
Failure:
ViewWidgetTest#test_we_can_see_a_list_of_widgets_and_view_on. . .
expected to find visible css "h1" with text /stembolt/i but . . .
193
Finished in 0.268304s, 3.7271 runs/s, 11.1813 assertions/s.
1 runs, 3 assertions, 1 failures, 0 errors, 0 skips
Test Failed
We can see what’s broken, but it’s not clear the best way to fix it. If we
change the tag name used in assert_selector that might fix it now, but
this same sort of change could break it again, and we’d have to fix this test
again. This can be a serious carrying cost with system tests and we need to
nip it in the bud now that it’s broken the first time.
We’ll assume that the widget name can be in any element that has the
attribute data-testid set to "widget-name":
# test/system/view_widget_test.rb
→ assert_selector "[data-testid='widget-name']",
→ text: widget_name_regexp
assert_selector "h2", text: formatted_widget_id_regexp
end
end
Our tests will still fail, but now when we fix them, we can fix them for
hopefully the last time. We can add the data-testid attribute to the <h2>:
<h1>Widget Information</h1>
→ <h2 data-testid="widget-name"><%= @widget.name %></h2>
<h2>ID #<%= styled_widget_id(@widget) %></h2>
<% if flash[:notice].present? %>
<aside>
194
Running 1 tests in a single process (parallelization thresho. . .
Run options: --seed 35741
# Running:
195
Since we’ve set our system tests to use :rack_test, that means they won’t
use a real browser and JavaScript won’t be executed. We need to allow a
subset of our tests to actually use a real browser (which is what Rails’ system
tests do by default).
To that end, we’ll create a subclass of our existing ApplicationSystemTestCase
that will be for browser-driven tests. We’ll call it BrowserSystemTestCase
and it will configure Chrome to run the tests4 .
The default configuration for Rails is to use a real Chrome browser that pops
up and runs tests while you watch. This is flaky, annoying, and difficult to
get working in a continuous integration environment.
Fortunately, it’s unnecessary as Chrome has a headless mode that works
exactly the same way as normal Chrome, but does everything offline without
actually drawing to the screen5 . Practically speaking, Chrome won’t work in
our Docker-based setup anyway.
# test/application_system_test_case.rb
4 If you are using RSpec, this is something you’d implement with tags, as that is a more
196
require "test_helper"
→
→ Capybara.register_driver :root_headless_chrome do |app|
→ options = Selenium::WebDriver::Options.chrome(
→ args: [
→ "headless",
→ "disable-gpu",
→ "no-sandbox",
→ "disable-dev-shm-usage",
→ "whitelisted-ips"
→ ],
→ logging_prefs: { browser: "ALL" },
→ )
→ Capybara::Selenium::Driver.new(
→ app,
→ browser: :chrome,
→ options: options
→ )
→ end # register_driver
→
require "support/with_clues"
I won’t claim to have a deep understanding of what all of those strings given
to args actually do, but suffice it to say, they were needed to make this work
inside a Docker container. Of note is the logging_prefs option. We’ll see a
bit later how we can print the messages sent to the JavaScript console, and
by default, Selenium only allows access to errors and warnings. By using {
browser: "ALL" }, we can get all the messages.
There’s one more step I need to do that you may not. Since this book
was first published, Apple has started selling computers with Apple Silicon
chips. They are not compatible with the previous generation of Intel chips.
Although macOS provides an emulation layer, when running an Intel-based
Docker container on an Apple Silicon-based computer, not everything is
properly emulated. As luck would have it, Chrome requires some of those
features, so basically doesn’t work inside the Docker environment used by
this book.
197
Installing Chromium highly depends on your OS and, if you are running on
macOS or an Intel-based Linux or Windows computer, you don’t need to
do this. The Docker-based setup uses Debian linux and does this to install
Chromium:
# test/application_system_test_case.rb
require "test_helper"
→
→ Selenium::WebDriver::Chrome::Service.driver_path =
→ "/usr/bin/chromedriver"
Note that the right path for your environment might be different, and if you
can use Chrome, that will make this much easier. This API is not documented
by Selenium, so it may change. All part of the fun.
Now, let’s create BrowserSystemTestCase which will use the newly-
registered driver and extend ApplicationSystemTestCase. Since our
existing tests (and any new ones) will include it, we’ll put it in
test/application_system_test_case.rb:
# test/application_system_test_case.rb
include TestSupport::WithClues
driven_by :rack_test
end
→
→ # Base test class for system tests requiring JavaScript
→ class BrowserSystemTestCase < ApplicationSystemTestCase
→ driven_by :root_headless_chrome, screen_size: [ 1400, 1400 ]
→ end
198
While we are setting up our system tests, let’s configure Capybara to
recognize data-testid (which we adopted earlier in this chapter on
page 193) whenever we use helpers like click_on. This will go in
test/test_helper.rb:
# test/test_helper.rb
module ActiveSupport
class TestCase
# test/system/rate_widget_test.rb
199
require "application_system_test_case"
click_on "2"
assert_selector "[data-rating-present]",
text: /thanks for rating.*2/i
end
end
# Running:
200
Now that we’ve added browser-based tests, it may be useful to see the
browser’s logs whenever we use with_clues. Let’s add that ability as a
demonstration of the power of built-in diagnostics we discussed earlier in
the chapter on page 186
# test/support/with_clues.rb
block.()
rescue Exception => ex
puts "[ with_clues ] Test failed: #{ex.message}"
→ if page.driver.respond_to?(:browser)
→ if page.driver.browser.respond_to?(:logs)
→ logs = page.driver.browser.logs
→ browser_logs = logs.get(:browser)
→ browser_logs.each do |log|
→ puts log.message
→ end
→ puts "[ with_clues ] } END Browser Logs"
→ else
→ puts "[ with_clues ] NO BROWSER LOGS: " +
→ "page.driver.browser" +
→ "#{page.driver.browser.class} " +
→ "does not respond to #logs"
→ end
→ else
→ puts "[ with_clues ] NO BROWSER LOGS: page.driver " +
→ "#{page.driver.class} does not respond to #browser"
→ end
→ puts
puts "[ with_clues ] HTML {"
puts
puts page.html
Whew! The reason we didn’t use try is because we want to give a specific
message about why the logs aren’t being output. If someone adds a third
driver later—say Firefox—and it doesn’t provide log access in this way, these
201
error messages will help future developers figure out how to address it. It
certainly helped me when Selenium changed this API since the last version
of this book was published!
Note that if you’d like to use with_clues, I extracted it to a gem called
with_clues8 that provides all this and a bit more, including explicit support
for RSpec.
Up Next
This covers system tests and hopefully has provided some high level strate-
gies and lower-level tactics on how to get the most out of system tests and
keep them sustainable. We’ll discuss unit tests later as we delve into the
back-end of Rails. In fact, that’s up next since we have now completed our
tour of the view layer.
8 https://github.com/sustainable-rails/with_clues
202
13
Models, Part 1
Although Rails is a Model-View-Controller framework, the model layer
in Rails is really a collection of record definitions. Models in Rails are
classes that expose attributes that can be manipulated. Traditionally, those
attributes come from the database and can be saved back, though you can
use Active Model to create models that aren’t based on database tables.
No matter what else goes into a model class, it mostly exists to expose
attributes for manipulation, like a record or struct does in other languages.
As outlined in “Business Logic (Does Not Go in Active Records)” on page 57,
that’s all the logic that should go in these classes. I find it helpful to think of
Active Records as models of a database table, which is what they are (and
they are darn good at it!).
When you follow that guidance, the classes in app/models—the model
layer—become a library of the data that powers your app. Some of that data
comes directly from a database and some doesn’t, but your model layer can
and should define the data model of your app. This data model represents
all the data coming in and going out of your app. The service layer discussed
in the business logic chapter deals in these models.
This chapter will cover the basics around managing that. We’ll talk about
Active Records and their unique place in the Rails Architecture, followed by
Active Model, which is a powerful way to create Active Record-like objects
that work great in your view.
There are other aspects of models that we won’t get to until Models, Part 2
on page 255, since we need to learn about the database and business logic
implementation first.
Let’s start with accessing the data in our database using Active Record.
With two lines of code, an Active Record can provide sophisticated access to
a database table, in the form of class methods for querying and a record-like
203
object for data manipulation. It’s one of the core features of Rails that makes
developers feel so productive.
In my experience, when you place business logic elsewhere, you don’t end
up needing much code in your Active Records. Those few lines of code you
do need are often enough to enable access to all the data your app needs.
That said, there are times when we need to add code to Active Records. The
three main types of code are:
Let’s dig into each of these a bit, but first we need some Active Records to
work with.
Next, we’ll create the Widget model which has a name, a status, and a
reference to a manufacturer:
204
create db/migrate/20231204235351_create_widgets.rb
create app/models/widget.rb
invoke test_unit
create test/models/widget_test.rb
create test/fixtures/widgets.yml
With these created, let’s now talk about Active Record’s configuration DSL.
205
# app/models/manufacturer.rb
On rare occasions you don’t want to allow this relationship to exist in code.
If this applies to you, add a code comment explaining why, so a future
developer doesn’t inadvertently add it.
Regarding additional configuration such as validations, I would recommend
you add only what configuration you actually need. Think about it this
way: if there is no code path in your app to set the name of a widget, what
purpose could a presence validation on that field possibly serve?
Next, let’s talk about the class methods you might add to your Active Record.
• There is a need for the method’s logic in more than one place.
• The method’s logic is related to database manipulation only and not
coupled to business logic.
Let’s see an example. Suppose widgets can have one of three statuses:
“fresh”, “approved”, and “archived”. Fresh widgets require manual approval,
so we might write some code like this in a background job that emails our
admin team for each fresh widget they should approve:
class SendWidgetApprovalEmailJob
def perform
Widget.where(status: "fresh").find_each do |widget|
AdminMailer.widget_approval(widget).deliver_later
end
206
end
end
def index
@widgets = Widget.where(status: "fresh")
end
Using this in two places creates duplication we may want to avoid, particu-
larly because the string "fresh" is a specific value from the database.
Now, anyone needing fresh widgets doesn’t have to worry about what string
is used in the database to represent this.
Let’s see a subtly different example where this would not be the right
solution.
Suppose our manufacturers need to see a list of recently approved widgets.
Suppose that “recently” is defined as approved in the last 10 days. We might
write this code:
def index
@widgets = Widget.where(status: "approved").
where(updated_at: 10.days.ago..)
end
207
The 10.days.ago is certainly business logic, as is the combination of it with
the “approved” status. The concept of “recently approved” might change,
and it might be different depending on context. This should not go into the
Widget class. We’ll talk about the ramifications of putting business logic in
controllers in “Controllers” on page 313, but if we need to re-use this logic,
the place to put it is in the service layer (which we’ll talk about in “Business
Logic Class Design” on page 241).
Lastly, let’s talk about instance methods.
# app/models/widget.rb
208
→ def user_facing_identifier
→ id_as_string = self.id.to_s
→ if id_as_string.length < 3
→ return id_as_string
→ end
→
→ "%{first}.%{last_two}" % {
→ first: id_as_string[0..-3],
→ last_two: id_as_string[-2..-1]
→ }
→ end
end
If the only methods we add to Widget are for clearly defined concepts
derivable from data, we can start to understand our domain better by
looking at the Active Records. Instead of seeing a mishmash of command
methods that invoke logic, presentational attributes, and use-case-specific
values, we see only the few additional domain concepts that we need but
aren’t in the database.
Note that this method deserves a test, but we’re not going to talk about
testing models until “Models, Part 2” on page 255.
As a contrast to user_facing_identifier, suppose we need to show the
first letter of the status on the widget show page. Suppose further that this
is for aesthetic reasons and that the “short form” of a status isn’t part of the
domain—users don’t think about it.
In this case, we should not create a method on Widget with this logic.
Instead, we should put this logic in the view, or even make a helper. If our
needs were even greater, such as deriving new fields of a widget based on
the application of complex logic, we should make an entirely new class.
For that, we should use Active Model.
209
Because we use resources for our routing, we’ll have a route like
/user_shipping_estimates that, when given a destination postal code, will
render a list of estimates based on our current database of widgets. Ideally,
we could use objects that behave like Active Records and thus could be used
with Rails form and URL helpers.
This is what Active Model does. Let’s create our UserShippingEstimate
resource. We need to include ActiveModel::Model and define our attributes
with attr_accessor. Just these two bits of code will enable several handy
features of our class. It will give us a constructor that accepts attributes as a
Hash, and will enable assign_attributes for bulk assignment.
# app/models/user_shipping_estimate.rb
class UserShippingEstimate
include ActiveModel::Model
attr_accessor :widget_name,
:widget_user_facing_id,
:shipping_zone,
:destination_post_code
end
To make our model work with some of Rails’ form and URL helpers,
we need to tell Rails what fields uniquely identify an instance of our
model. For Active Records, that is simply the id field, and this is what
Active Model will use by default. But Rails defines the method to_key (in
ActiveModel::Conversions, included by ActiveModel::Model) to allow us
to override it.
In our case, user_facing_identifier isn’t sufficient to uniquely identify
a UserShippingEstimate because the estimate changes based on the
destination_post_code. By combining both user_facing_identifier and
destination_post_code, we can uniquely identify a shipping estimate.
Thus, if we implement to_key, we can use our model in Rails views the
same as we could an instance of an Active Record. We also need to tell Rails
that our object actually has an identifier, which requires that we implement
persisted? to return true. to_key should return an array of the values
comprising the unique identifier, like so:
# app/models/user_shipping_estimate.rb
:widget_user_facing_id,
:shipping_zone,
210
:destination_post_code
→
→ def persisted?
→ true
→ end
→
→ def to_key
→ [ self.widget_user_facing_id,
→ self.destination_post_code ]
→ end
end
> bin/rails c
rails> user_shipping_estimate = UserShippingEstimate.new(
widget_name: "Stembolt",
widget_user_facing_id: "123.45",
shipping_zone: 4,
destination_post_code: "90210")
rails> Rails.application.routes.draw do
rails* resources :user_shipping_estimates
rails> end
rails> app.user_shipping_estimate_path(user_shipping_estimate)
=> "/user_shipping_estimates/123.45-90210"
211
Up Next
We can start to see some larger architectural principles taking shape. See the
figure “Consistency Across Layers” below for how we can trace names and
concepts from the URLs all the way to the model layer, and that it doesn’t
matter if data is stored in the database or not. This architectural consistency
helps greatly with sustainability.
212
14
The Database
For most apps, the data in its database is more important than the app
itself. If a cosmic entity swooped in and removed your app’s source code
from all of existence, you could likely recreate it, since you’d still have the
underlying data it exists to manage. If that same entity instead removed
your data. . . this would be an extinction-level event for your app.
What this thought experiment tells me is that the way data is managed and
stored requires a bit more care and rigor than is typically applied to code.
This “care and rigor” amounts to spending more time modeling the data
and using everything available in your database to keep the data correct,
precise, and consistent.
This contradicts Rails’ overly simplistic view of the database. By only follow-
ing Rails’ defaults, and designing your database when you write migrations,
you will eventually have inconsistent or incorrect data, and likely a fair bit
of unnecessary complexity in your code to deal with it.
That said, there are some realities about using a database we have to account
for:
• Databases provide much simpler types and validations than our code.
• Large or high-traffic databases can be hard to change.
• Databases are often consumed by more than just your Rails app.
To navigate this, we’ll talk about the logical model of the data—the one the
users talk about and understand—as distinct from the physical model—what
tables, columns, indexes, and constraints are actually in the database. With
regard to the physical model, we’ll break that down into two distinct steps
for development. We’ll learn how to decide what database structures you
want first, and then how to write a proper Rails migration to create them.
First, let’s define logical and physical models.
213
database. The logical model is the data model as understood by users and
other interested parties. For simple domains, these models are often very
similar, but it’s important to understand the differences.
The logical model is a tool to get alignment between the developers who
build the app and the users or other stakeholders who know what problems
the app needs to solve. Users won’t usually care about physical elements
such as indexes, join tables, or reference data lookup tables when discussing
how the app should behave.
The logical model is in the language of the users, at the level of abstraction
they understand. This is often insufficient for properly managing the data,
but you can’t make a database without an understanding of the domain.
For example, a user will think that a widget has a status, or a manufacturer
has an address. This doesn’t mean that the widget table must have a status
column or that the manufacturer table has columns for each part of an
address. You may not want to (or be able to) model it that way in the
database.
See the figure “Example Logical and Physical Models” on the next page for
an example of a logical and physical model for a hypothetical widget and
manufacturer relationship.
It stands to reason, then, that you should create a logical model to build
alignment before you start thinking about the physical model.
214
Figure 14.1: Example Logical and Physical Models
– Is it a required value?
– What other requirements are there, such as allowed values,
uniqueness, etc.
• For each entity, what uniquely identifies it? Can two entities have the
exact same values for all attributes and, if so, what does that mean?
For example:
215
Table 14.1: Example logical model as a spreadsheet
However you draft this logical model, make sure you have a good sense of
the allowed values for each attribute. If the user uses attribute types like
“Address”, define a new entity called “Address” and identify its requirements.
For more general types like “String” or “Date”, try to get clarity on what
values are allowed. There are a lot of strings in the world and probably not
all of them are a valid widget status.
As to the uniqueness questions, getting these right can greatly reduce confu-
sion in the actual data. Often there are several sets of values that represent
uniqueness. For example, the widget ID we’ve discussed previously sounds
like a unique value. But you also may want widget names to be unique. It’s
fine to have multiple unique identifiers for entities, but it’s important to
understand all of them.
The less familiar you are with the domain, or the newer it is, the more time
you should spend exploring it before you start coding. Mistakes in data
modeling are difficult to undo later and can create large carrying costs in
navigating the problems created by insufficient modeling.
You don’t have to know everything, but even knowing how data might
be used is useful. You don’t have to handle those “someday, maybe” re-
quirements, but knowing how stable certain requirements are can help you
properly translate them to the physical model. Stable requirements can be
enforced in the database; unstable requirements might need to be enforced
in code so they can be more easily changed.
Once you have alignment, you can build the physical model, which you
should do in two steps: plan it, then create it.
216
Translating the logical model to the physical model requires making several
design decisions, especially as the app becomes more complex and needs to
manage more types of data.
This should be done in two discrete steps. This section discusses the first,
which is to plan exactly how you are going to store the data in the database.
The next section discusses how to write a Rails migration to implement this
plan.
Whereas the logical model was for building alignment and discovering
business rules, the physical model is for accurately managing data that
conforms to those rules. This means that correctness, precision, and accuracy
are paramount.
The design decisions you’ll make amount to how and where you will enforce
the correctness of the data. Your database is an incredibly powerful tool to
do this, and it’s where most of your constraints around correctness should
go.
217
No matter what, we’re going to use database-specific features. That requires
using a SQL schema instead of a Ruby-based one.
# config/application.rb
# per-controller helpers
g.helper false
end
→
→ # We want to be able to use any feature of our database,
→ # and the SQL format makes that possible
→ config.active_record.schema_format = :sql
end
end
> rm db/schema.rb
I recommend this change for all database types, because it costs nothing
and provides a lot of benefit.
For Postgres specifically, we need to make another change, which is to use
TIMESTAMP WITH TIME ZONE for timestamps.
218
14.3.3 Use TIMESTAMP WITH TIME ZONE For Timestamps
The SQL standard provides for the TIMESTAMP fields to store. . . timestamps. A
timestamp is a number of milliseconds since a reference timestamp, usually
midnight on January 1, 1970 in UTC.
The TIMESTAMP data type does not store a time zone, however. Most data-
bases store timestamps in UTC and provide an automatic translation based
on. . . well, it’s complicated.
By default, the computer your database is running on is configured with a
system time zone. This can be hard to inspect or control. The connection to
the database itself can override this. The code that makes a connection to
the database can override this as well. Rails can override this. Your code
can override Rails.
This means that your timestamps will be translated using a reference time
zone that might not be obvious. And if the wrong reference is used when
reading those timestamps out, the reader can interpret the timestamp differ-
ently. Even though Rails defaults to using UTC, some other process might
be configured differently. This is extremely confusing.
Postgres provides the data type TIMESTAMPTZ (also known as TIMESTAMP WITH
TIME ZONE) that avoids this problem. It stores the reference time zone with
the timestamp so it’s impossible to misinterpret the value. Postgres expert
Dave Wheeler wrote a blog post3 that can provide you more details.
We can make Rails use this type by default. The class PostgreSQLAdapter
(which is in the ActiveRecord::ConnectionAdapters namespace) has an
attribute named datetime_type that allows overriding the default SQL type
used whenever a migration has a datetime in it.
We can set this to :timestamptz and all of our migrations will use
TIMESTAMPTZ instead of TIMESTAMP. This can be done anywhere
as long it loads when Rails does. Best place to do that is in
config/initializers/postgres.rb:
# config/initializers/postgres.rb
require "active_record/connection_adapters/postgresql_adapter"
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type =
:timestamptz
Now, when we write code like t.timestamps or t.datetime, Rails will use
TIMESTAMP WITH TIME ZONE and all of our timestamps will be stored without
ambiguity or implicit dependence on the system time zone.
3 https://justatheory.com/2012/04/postgres-use-timestamptz/
219
With this base, we can start planning the physical model.
220
The type you choose should allow you to store the exact values you need. It
should also make it difficult or impossible to store incorrect values. Here
are some tips for each of the common types.
Strings In the olden days, choosing the size of your string mattered. Today,
this is not universally true. Consult your database’s documentation
and use the largest size type you can. For example, in Postgres, you
can use a TEXT field, since it carries no performance or memory burden
over VARCHAR. It’s important to get this right because changing column
types later when you need bigger strings can be difficult.
Rational Numbers Avoid FLOAT if possible. Databases store FLOAT values
using the IEE 7545 format, which does not store precise values. Either
convert the rational to a base unit (for example, store money in cents
as an integer), or use the DECIMAL type, which does store precise
values. Note that neither type can store all rational numbers. One-
third, for example, cannot be stored in either type. To store precise
fractional values might require storing the numerator and denominator
separately.
Booleans Use the boolean type. Do not store, for example, "y" or "n" as
a string. There’s no benefit to doing this and it’s confusing. And yes,
people do this and I don’t understand why.
Dates Remember that a date is not a timestamp. A date is a day of the
month in a certain year. There is no time component. The DATE
datatype can store this, and allow date arithmetic on it. Don’t store a
timestamp set at midnight on the date in question. Time zones and
daylight savings time will wreak havoc upon you, I promise.
Timestamps As opposed to a date, a timestamp is a precise moment in
time, usually a number of milliseconds since a reference timestamp.
As discussed above, use TIMESTAMP WITH TIME ZONE if using Postgres.
If you aren’t using Postgres, be very explicit in setting the reference
timezone in all your systems. Do not rely on the operating system
to provide this value. Also, do not store timestamps as numbers
of seconds or milliseconds. The TIMESTAMP WITH TIME ZONE and
TIMESTAMP types are there for a reason.
Enumerated Types Many databases allow you to create custom enumer-
ated types, which are a set of allowed values for a text-based field. If
the set of allowed values is stable and unlikely to change, an ENUM can
be a good choice to enforce correctness. If the values might change, a
lookup table might work better (we’ll talk about that below).
No matter what other techniques you use, you will always need to choose
the appropriate column type. Next, decide how to use database constraints.
5 https://en.wikipedia.org/wiki/IEEE_754
221
Using Database Constraints
All SQL databases provide the ability to prevent NULL values. In a Rails
migration, this is what null: false is doing. This tells the database to
prevent NULL values from being inserted. Any required value should have
this set, and most of your values should be required.
Many databases provide additional constraint mechanisms, usually called
check constraints. Check constraints are extremely powerful for enforcing
correctness. For example, a widget’s price must be positive and less than or
equal to $10,000. With a check constraint this could be enforced:
ALTER TABLE
widgets
ADD CONSTRAINT
price_positive_and_not_too_big
CHECK (
price_cents > 0 AND
price_cents <= 1000000
)
If you try to insert a widget with a price of -$100 or $300,000, the database
will refuse. Thus, you can be absolutely sure the price is valid. Check
constraints can do all sorts of things. If you want all widget names to be
lowercase, you can do that, too:
CHECK (
lower(name) = name
)
222
• Any critical requirement should be implemented as a check constraint.
• Unstable requirements on tables expected to grow might be better
implemented in code, so you can change them frequently, but it still
might be better to use a check constraint and wait for the table to
actually get large enough to be a problem.
The next technique for enforcing correctness is the use of lookup tables.
id name widget_status_id
10 Stembolt 1
11 Thrombic Modulator 1
12 Tachyon Generator 2
id name
1 Fresh
2 Approved
3 Archived
Note a key difference between the physical and logical model. The logical
model simply states that a widget has a status attribute. To enforce cor-
rectness and deal with a potentially unstable list of possible values, we are
modeling it with a new table. In our code, a widget will belong_to a status
(which will has_many widgets).
When using lookup tables, you must create a foreign key constraint. This
tells the database that the value for widget_status_id must match an id in
the referenced widget_statuses table. This prevents widgets from having
invalid or unknown statuses, since widget_statuses contains all known
valid statuses.
223
A lookup table also allows modeling metadata on the referenced value. For
example, if only “Approved” widgets can be sold, we might model that with
a boolean column on the widget_statuses table:
id name allows_sale
1 Fresh false
2 Approved true
3 Archived false
224
the migrations we write result in the schema we need. Rails’ API is powerful
and will save us time and make the work easier, but it lacks a few useful
defaults.
In the previous chapter, we created models so we could talk about some
model basics. Rather than edit those models and the schema it created, let’s
start over (you can’t do this in real life, but it’ll make this chapter simpler if
we do).
If we delete the migrations and fixtures created by bin/rails g model and
re-run bin/setup, we should be good to go.
The figure “Example Logical and Physical Models” on page 215 outlines
what we’re going to do, but to re-iterate:
• A Widget has a name, price, status, and manufacturer, all of which are
required.
• A Manufacturer has a name and an address, both of which are re-
quired.
• An address is a street and a zip code (both required).
• Widget names must be unique within a manufacturer.
• Manufacturer names must be unique globally.
• We’ll use lookup tables for addresses and widget statuses.
• We’ll use a database constraint to enforce a price’s lower-bound, but
code for the upper-bound.
It’s important that changes that logically relate to each other go in a single
migration file. Some databases, including Postgres, run migrations in a
transaction, which allows us to achieve an all-or-nothing result. Either our
entire change is applied successfully, or none of it is.
While we still want to end up with one migration, I find it easier to built it
iteratively. Write some of the migration, apply it and check it, then rollback
and continue until everything is correct.
The figure “Authoring Migrations” on the next page outlines this basic
process:
225
Figure 14.2: Authoring Migrations
226
This allows you to take each change step-by-step, but still end up with only
one migration file that makes the cohesive change you’re making. In our
case, we want a single migration that creates the needed tables.
# bin/db-migrate
#!/bin/sh
set -e
if [ "${1}" = -h ] || \
[ "${1}" = --help ] || \
[ "${1}" = help ]; then
echo "Usage: ${0}"
echo
echo "Applies outstanding migrations to dev and test databases"
exit
else
if [ ! -z "${1}" ]; then
echo "Unknown argument: '${1}'"
exit 1
fi
fi
227
# bin/db-rollback
#!/bin/sh
set -e
if [ "${1}" = -h ] || \
[ "${1}" = --help ] || \
[ "${1}" = help ]; then
echo "Usage: ${0}"
echo
echo "Rolls back the current migration from dev and test databases"
exit
else
if [ ! -z "${1}" ]; then
echo "Unknown argument: '${1}'"
exit 1
fi
fi
Let’s also make a script called bin/psql that connects to our development
database. I realize that bin/rails dbconsole does this, but a) it requires us
to type a password each time, and b) it’s incredibly slow to start up because
it must load Rails first, only to delegate to the psql command-line client.
# bin/psql
#!/bin/sh
set -e
if [ "${1}" = -h ] || \
[ "${1}" = --help ] || \
[ "${1}" = help ]; then
echo "Usage: ${0}"
echo
echo "Uses psql to connect to dev database directly"
228
exit
else
if [ ! -z "${1}" ]; then
echo "Unknown argument: '${1}'"
exit 1
fi
fi
It’s also a good idea to add these to bin/setup help. I’ll leave that as an
exercise for the reader.
Now, let’s create our migration file:
For the sake of repeatability when writing this book, I’m going to rename
the migration file to a name that’s not based on the current date and time.
You don’t need to do this.
> mv db/migrate/*make_widget_and_manufacturers.rb \
db/migrate/20210101000000_make_widget_and_manufacturers.rb
With that set up, we can now iteratively put code in this file to generate the
correct schema we want.
229
14.4.2 Iteratively Writing Migration Code to Create the Correct
Schema
We’ll need to work a bit backward. We can’t create widgets first, because it
must reference widget_statuses and manufacturers. manufacturers must
reference addresses. So, we’ll start with widget_statuses.
By default, Rails creates nullable fields. We don’t want that. Fields with
required values should not allow null. We’ll use null: false for these fields
(even for nullable fields I like to use null: true to make it clear that I’ve
thought through the nullability).
I also like to document tables and columns using comment:. This puts the
comments in the database itself to be viewed later. Even for something that
seems obvious, I will write a comment because I’ve learned that things are
never as obvious as they might seem.
# db/migrate/20210101000000_make_widget_and_manufacturers.rb
Note that I’ve created a unique index on the :name field. Although database
indexes are mostly for allowing fast querying of certain fields, they are also
the mechanism by which databases enforce uniqueness. Thus, to prevent
having more than one status with the same name, we create this index,
specifying index: { unique: true }.
This will create a case-sensitive constraint, meaning the statuses "Fresh"
and "fresh" are both allowed in the table at the same time. Currently, the
developers control the contents of this table, so a unique index is fine—we
won’t create a duplicate status in a different letter case. If the contents of
this field were user-editable, I might create a case-insensitive constraint
230
instead. Sean Huber wrote a short blog post7 about how you could do this
if you are interested.
Next, let’s create the addresses table. Our user’s documentation said “street
and zip is fine”, so we’ll create the table with just those two fields for now.
# db/migrate/20210101000000_make_widget_and_manufacturers.rb
Again, liberal use of comment: will help future team members. At this
point, I like to run the migrations to make sure everything’s working before
proceeding.
> bin/db-migrate
[ bin/db-migrate ] migrating development schema
== 20210101000000 MakeWidgetAndManufacturers: migrating ====. . .
-- create_table(:widget_statuses, {:comment=>"List of defini. . .
-> 0.0069s
-- add_index(:widget_statuses, :name, {:unique=>true, :comme. . .
-> 0.0013s
-- create_table(:addresses, {:comment=>"Addresses for manufa. . .
-> 0.0024s
== 20210101000000 MakeWidgetAndManufacturers: migrated (0.01. . .
231
-- create_table(:widget_statuses, {:comment=>"List of defini. . .
-> 0.0039s
-- add_index(:widget_statuses, :name, {:unique=>true, :comme. . .
-> 0.0010s
-- create_table(:addresses, {:comment=>"Addresses for manufa. . .
-> 0.0021s
== 20210101000000 MakeWidgetAndManufacturers: migrated (0.00. . .
I also like to connect to the database and describe the tables to see if it looks
correct. It may seem silly, but looking at the same information in a different
way can often uncover mistakes.
With Postgres, you can use the bin/psql script we made and type \d+
widget_statuses or \d+ addresses to display the schema. If anything looks
wrong—including a spelling error in a comment—use bin/db-rollback, fix
it, and move on.
Of course, we aren’t done yet, so we’ll bin/db-rollback anyway.
> bin/db-rollback
[ bin/db-rollback ] rolling back development schema
== 20210101000000 MakeWidgetAndManufacturers: reverting ====. . .
-- drop_table(:addresses, {:comment=>"Addresses for manufact. . .
-> 0.0012s
-- remove_index(:widget_statuses, :name, {:unique=>true, :co. . .
-> 0.0014s
-- drop_table(:widget_statuses, {:comment=>"List of definiti. . .
-> 0.0005s
== 20210101000000 MakeWidgetAndManufacturers: reverted (0.00. . .
232
add an index to the reference because we’ll definitely be navigating these
foreign keys and an index will ensure that navigation performs well.
# db/migrate/20210101000000_make_widget_and_manufacturers.rb
→ create_table :manufacturers,
→ comment: "Makers of the widgets we sell" do |t|
→
→ t.text :name, null: false,
→ comment: "Name of this manufacturer"
→
→ t.references :address, null: false,
→ index: true,
→ foreign_key: true,
→ comment: "The address of this manufacturer"
→
→ t.timestamps null: false
→ end
→
→ add_index :manufacturers, :name, unique: true
→
end
end
# db/migrate/20210101000000_make_widget_and_manufacturers.rb
→ create_table :widgets,
→ comment: "The stuff we sell" do |t|
→
→ t.text :name, null: false,
→ comment: "Name of this widget"
→
→ t.integer :price_cents, null: false,
→ comment: "Price of this widget in cents"
→
233
→ t.references :widget_status, null: false,
→ index: true,
→ foreign_key: true,
→ comment: "The current status of this widget"
→
→ t.references :manufacturer, null: false,
→ index: true,
→ foreign_key: true,
→ comment: "The maker of this widget"
→
→ t.timestamps null: false
→ end
→
end
end
We have only two steps left. We must enforce the uniqueness of widget
names amongst manufacturers, and enforce the widget’s price allowed
values. We’ll tackle the uniqueness requirement next.
To enforce the widget name/manufacturer uniqueness requirement, we can
create our own index on both fields using add_index:
# db/migrate/20210101000000_make_widget_and_manufacturers.rb
This allows many widgets to have the same name, as long as they don’t also
have the same manufacturer.
To create the constraint on price, we can use the add_check_constraint
method. Prior to Rails 6.1, you needed to use reversible and execute to
put raw SQL in your migration. No longer!
We’ll add this to the migration file:
234
# db/migrate/20210101000000_make_widget_and_manufacturers.rb
→ add_check_constraint(
→ :widgets,
→ "price_cents > 0",
→ name: "price_must_be_positive"
→ )
→
end
end
If you don’t know SQL or it’s still new to you, this syntax for what goes
into the second argument of add_check_constraint can seem daunting and
hard to derive. Your database’s documentation is a great place to start and
you can piece it together from that. A little bit of trial-and-error also helps,
and since you can easily apply and rollback your migration, a combination
of reading docs and trying things out will allow you to arrive at the right
syntax. That’s how I did it!
Also note that we used the optional :name parameter to give the constraint a
name. Like adding comments to our tables and columns, giving constraints
a descriptive name can be useful. If the constraint is violated, the name will
appear in the error message and it can be helpful to use that to start figuring
out what might have gone wrong.
Lastly, you’ll notice that we didn’t need to use any raw SQL, but we are still
using a SQL-based schema. A SQL-based schema is always a better option
from the start, because they you don’t have to remember to change it later
if you do need to use SQL in your migrations.
Let’s apply it:
> bin/db-migrate
«lots of output»
We aren’t quite done, because we have not modeled the upper-limit on price.
We planned to do that in code, so we need to make sure all of our model
classes are created and correct, following the guidelines we learned about
in “Active Record is for Database Access” on page 203.
First up is WidgetStatus. Since there is a to-many relationship with widgets,
we’ll use has_many :widgets. Note that this file will not already exist and
you must create it.
235
# app/models/widget_status.rb
# app/models/address.rb
# app/models/manufacturer.rb
Finally we’ll model Widget. Because we did not model the price’s upper-end
in the database, we should add it to the code now as a validation. Even
though we have no use-case that would trigger this validation, since it’s part
of the logical data model that we couldn’t model in the database, we have
to put it here.
Note that we aren’t putting any other validations in these models. The
database will enforce correctness and prevent bad data from being written.
We only need redundant checks if there’s a specific reason. We’ll discuss this
more in “Validations Don’t Provide Data Integrity” on page 255.
# app/models/widget.rb
236
last_two: id_as_string[-2..-1]
}
end
→ belongs_to :widget_status
→ validates :price_cents,
→ numericality: { less_than_or_equal_to: 10_000_00 }
end
If you aren’t used to database constraints, it might feel like we’ve put
business logic in our database. In a way, we have, and we really should
consider testing some of it. The check constraint, in particular, seems hard
to be confident in without a test.
Let’s see what a test looks like for our database constraints.
Like all tests, tests for the correctness of the data model have a carrying cost.
I don’t see a lot of value in testing null: false, or unique: true, because
these tend to be easy to get right. Check constraints are more like real code
and thus easier to mess up. I usually write tests for them.
Let’s write a test for the constraint around the widget’s price. We’ll need two
tests: one that successfully sets the widget’s price to a correct value, and
another that fails in an attempt to set it to a negative value.
Because this is testing the database and not the code in app/models, our
tests will use update_column, which skips validations and callbacks, writing
directly to the database. If we used update! instead, and we later added
validations to the Widget class, our test would fail to write the database at
all. Using update_column ensures we are testing the database itself.
To do that, we’ll set up a valid widget in the setup method, which requires
a widget status and a manufacturer (which requires an address).
# test/models/widget_test.rb
require "test_helper"
237
name: "Cyberdyne Systems",
address: Address.create!(
street: "742 Evergreen Terrace",
zip: "90210"
)
)
@widget = Widget.create!(
name: "Stembolt",
manufacturer: manufacturer,
widget_status: widget_status,
price_cents: 10_00
)
end
test "valid prices do not trigger the DB constraint" do
assert_nothing_raised do
@widget.update_column(
:price_cents, 45_00
)
end
end
test "negative prices do trigger the DB constraint" do
ex = assert_raises do
@widget.update_column(
:price_cents, -45_00
)
end
assert_match(/price_must_be_positive/i,ex.message)
end
end
Note the way we are checking that we violated the constraint. We check
that the message in the assertion references the constraint name we used
in the migration: price_must_be_positive. This means our test should
hopefully only pass if we violated that constraint, but fail if we get some
other exception.
Now, let’s run the test.
# Running:
..
238
Finished in 0.046877s, 42.6650 runs/s, 85.3301 assertions/s.
2 runs, 4 assertions, 0 failures, 0 errors, 0 skips
This should pass. While we could write a test for the validation, I find those
sorts of tests less valuable since the code is straightforward with no real
logic.
Up Next
Data modeling is not easy and it can take a lot of experience to get com-
fortable with it. Hopefully, I’ve stressed how important it is to create your
database in a way that favors correctness and precision at the database layer,
as well as some helpful techniques to get there.
In the chapter after next, we’ll finish talking about models, but to do that,
we need to revisit business logic. While our database schema implements
some of our business rules, most of the logic that makes our app special will
be in code, so let’s talk about that next.
239
15
This is precisely what I am recommending you do, and this chapter is about
that, and what it may look like.
To understand this, we need to first be clear about what’s important—and
not very important—about the code that implements business logic. We’ll
then talk about the seam itself, which has three parts: a class, a method,
and a return value. The strategy I will advocate is to have a stateless class
named for the specific process or use case it implements, a single method
that accepts the parameters needed to perform the logic, and an optional
241
Figure 15.1: Seam Overview
242
• you have used idiomatic Ruby or Rails (whatever they. . . well, you get
the point).
I mention this because I have seen time and time again developers write code
to serve one or more of the above purposes at the cost of clarity in behavior.
Refactoring code to be “more OO” is a specious activity. In particular, the
so-called SOLID Principles can wreak havoc on a codebase when applied
broadly1 . I’ve been guilty of this many times in my career. Some of the
most elegant, compact, object-oriented code I’ve ever written was the most
difficult to understand and change later2 .
This isn’t to say there is no value in the list above. Design patterns, object-
oriented programming, and Ruby idioms do serve a purpose, but it should
be directed toward the larger goal, which is to write code that can be easily
changed. . . by being behavior-revealing.
The technique I have had the most success with—and seen others succeed
with as well—is to create a single class and method from which a given bit
of business logic is initiated. That class and method (as well as the object
the method returns) represent a seam or dividing line between the generic
world of Rails-managed code, and my own. The internals of that class can
then be freely structured as needed.
243
to respond to change in the future. It’s much easier to combine disparate
bits of code that turn out to be related than it is to excise unrelated code
inside a large, rich class.
Classes like this are often called services, and I would encourage the use of
this term. It’s specific enough to avoid conflating with models, databases,
data structures, controllers, or mailers, but general enough to allow the
code to meet whatever needs it may have.
So what do we call these services?
WidgetsCreator.new.create_widget(...)
What I’m suggesting will definitely result in code like this. I won’t claim
this code is elegant, but it does have the virtue of being pretty hard to
misinterpret. It also closes the fewest doors to changes in the future.
Now, you might think “We have a Widget class and it has a create method.
Isn’t that where widget creation should go?”. I understand this line of
thinking, but remember, Widget is a class to manipulate a database table
that holds one particular representation of a real-life widget. And the create
method is one way (out of many) to insert rows into that table. This isn’t
my opinion—this is what Rails provides. It’s the very essence of the Widget
class. And there is no reason to conflate inserting database rows with the
business process of widget creation.
And, what if we require another way to create a widget? WidgetsCreator
can grow a new method, or we can make a whole new class to encapsulate
that process. We can couple these implementations only as tightly as the
underlying process in the real world is coupled. Our code can reflect reality.
Wrapping it around the insertion of a row in a database divorces our code
from reality.
244
You might be thinking we should not have to call new or perhaps
create_widget should be named in a more generic way, like call. We’ll get
to that, but let’s talk about input to this method first.
When you follow these guidelines, your code will communicate clearly how
it works and what its requirements are. For example:
245
class WidgetsCreator
def initialize(notifier: )
@notifier = notifier
end
def create_widget(widget_params)
widget = Widget.create(widget_params)
if widget.valid?
@notifier.notify(:widget, widget.id)
sales_tax_api.charge_tax(widget)
end
end
private
def sales_tax_api
@sales_tax_api ||= ThirdParty::Tax.new
end
end
That tells you a lot about the runtime behavior of this code. If Widget and
ThirdParty::Tax were also passed into the constructor, you’d have more
sleuthing to do in order to figure out what this routine did. And you’d know
less about how coupled this routine is to the various objects it needs to do
its work.
You may have thoughts about this, but let’s wait one more section, because
the last bit of our seam requires a return value. For that, I recommend using
rich result objects.
246
15.2.3 Return Rich Result Objects, not Booleans or Active
Records
A caller often needs to know what happened in the call they made. Not
always, but often. Typical reasons are to report errors back to the user, or to
feed into logic it needs to execute. As part of the seam between the outside
world and our business logic, a boolean value—true if the call “succeeded”,
false otherwise—is not very useful and can be hard to manage3 .
If, instead, you return a richer object that exposes details the caller needs,
not only will your code and tests be more readable, but your seam can now
grow more easily if needs change.
A rich result doesn’t have to be fancy. I like creating them as inner classes of
the service’s class as a pretty basic Ruby class, like so:
class WidgetsCreator
def create_widget(widget_params)
if ...
Result.new(created: true, widget: widget)
else
Result.new(created: false, widget: widget)
end
end
class Result
attr_reader :widget
def initialize(created:, widget: nil)
@created = created
@widget = widget
end
def created?
@created
end
end
end
operation could not be completed”, you can be sure there is a boolean return value somewhere
that has made it difficult or impossible to provide a useful error message.
247
we could include any other things that are relevant and we can enhance this
class over time without having to touch any Active Records.
The caller’s code will then read as more specific and explicit:
result = WidgetsCreator.new.create_widget(widget_params)
if result.created?
redirect_to widget_path(result.widget)
else
@widget = result.widget
render "new"
end
Result objects should not be generic. Over time, you may see that related
concepts and logic have related result classes, and you can certainly extract
duplication then, but by default, don’t make a generic result class library.
Take the 20 seconds required to type out what initially might amount to
wrapping a boolean value.
Rich results shine in two places as you later change code. First, if your needs
change, you have a return object that you control and can change. Perhaps
the results of widget creation aren’t just “did it get created or not”:
result = WidgetsCreator.new.create_widget(widget_params)
if result.created?
redirect_to widget_path(result.widget),
info: "Widget created"
→ elsif result.existing_widget_updated?
→ redirect_to widget_path(result.widget),
→ info: "Widget updated"
else
@widget = result.widget
render "new"
end
If we’d started off with a boolean return value, this change would be signifi-
cant. A result object can also wrap sophisticated errors (or, more commonly,
refer to relevant Active Records/Models that themselves expose validation
errors).
The other benefit to rich result objects is with testing. They can make tests
more clear, certainly, but they can also cause your tests to fail in an obvious
way if you change the contract of the seam.
248
For example, here is how we might mock our service using RSpec’s mocking
library4 :
mocked_widgets_creator = instance_double(WidgetsCreator)
allow(mocked_widgets_creator).to
receive(:create_widget).and_return(
WidgetsCreator::Result.new(created: false)
)
249
Figure 15.2: Business Logic Seam with Rich Result
class WidgetsCreator
def self.create_widget(widget_params)
# ...
end
end
## to use:
WidgetsCreator.create_widget
This approach might save a few keystrokes, but it prevents you from encap-
sulating state later, if you should need to.
250
Some developers will try to split the difference and use the Singleton Pat-
tern5 :
class WidgetsCreator
def self.create_widget(widget_params)
self.instance.create_widget
end
def create_widget(widget_params)
# ...
end
private
def self.instance
@instance ||= self.new
end
end
This is better, but still unnecessary. It saves callers from typing four char-
acters at the cost of maintaining a lot of code to manage the singleton
instance or—worse—the use of a gem that does it for you. It will also
require you to think through multi-threading issues at some point, and those
are notoriously hard to get right.
class WidgetsCreator
def initialize(widget_params)
@widget_params = widget_params
end
5 https://en.wikipedia.org/wiki/Singleton_pattern
6 https://en.wikipedia.org/wiki/Command_pattern
251
def call
@widget_params....
end
end
## to use:
WidgetsCreator.new(widget_params).call
There are even gems and libraries that wrap this into a DSL to, in theory,
make it easier to manage these classes. There is almost no reason to create
classes like this in a Rails app.
Outside of Rails, a class designed this way is used when you wish to execute
some code at a different time or location than when that code’s input
parameters were available. This situation arises frequently in Rails apps,
but in those cases you would use a background job, not a so-called “service
object”. Rails background jobs are the Rails implementation of the command
pattern.
There is little benefit to adding a second set of classes that use the command
pattern, however there are several downsides:
• Having all your core logic be invoked with the same method name—
call—can be incredibly confusing, as compared to methods that say
what they do.
• You cannot share code using private methods because your class may
only have one public method. Code re-use through private methods is
extremely powerful, and this pattern makes that difficult or impossible
to do.
• Collecting parameters in one method (the constructor) and using them
in another (call) splits up core logic for no benefit. It also can make
complex routines more difficult to understand since parameters are
initialized far from where they are used.
This isn’t to say that creating a specialized set of classes that respond to the
same interface is always bad. But, as a default way of designing your core
business logic, “service objects”—AKA the command pattern—is not a good
one.
252
refer to a class directly (since, even though a class is an object, the object
should be injected, not pulled out of the air).
Our WidgetsCreator might look like this, if it were implemented using
dependency injection:
class WidgetsCreator
def initialize(notifier:,
sales_tax_api:,
widget_repository:)
@notifier = notifier
@sales_tax_api = sales_tax_api
@widget_repository = widget_repository
end
def create_widget(widget_params)
widget = widget_repository.create(widget_params)
if widget.valid?
notifier.notify(:widget, widget.id)
sales_tax_api.charge_tax(widget)
end
end
private
end
253
That said, sometimes a class does need to be flexible. Some classes are
designed to make use of an object that conforms to some well-known
interface. In that case, dependency injection is a great pattern. You just
don’t need to use it by default. Flexibility leads to complexity, and a key way
to achieve sustainability is to avoid unneeded complexity.
Up Next
This chapter was a lot of theory and rhetoric and light on useful examples.
If you can bear with me, the impact of the guidelines outlined here will be
more apparent with an end-to-end example (which will also afford us to
talk about testing). We’ll get to that after the following chapter. We must
return to models and see how stuff like callbacks, validations, and other
model-related features fit into all this. That’s what’s next.
254
16
Models, Part 2
This section’s code is in the folder 16-01/ of the sample code.
Now that we’ve had an intro to models, a full discussion of business logic,
and a journey through database design, I want to cap off the models discus-
sion by talking about validations, callbacks, scopes, and testing. Then, in the
next chapter, we can see an end-to-end example of how this all fits together,
which I think will paint a complete picture of the sustainable approach to
business logic.
I’ve made the point several times to keep business logic out of Active Records,
but I’ve also heavily implied that we should be using validations, which are
a form of business logic. We also talked briefly about managing queries,
along with a handful of references to avoid callbacks. This chapter will
cover all of these topics.
Let’s start with validations, which are great at user experience management
and not so great at data integrity.
• Any code that accesses the database outside your Rails app won’t use
your validations.
255
• Rails provides a public API on each Active Record to allow bypassing
validations.
• Some validations don’t actually work due to race conditions.
The biggest reason for me is the first one: someone else might access the
database.
256
16.1.2 Rails’ Public API Allows Bypassing Validations
All Active Records have the method update_column, which updates the
database directly, skipping validations. The existence of this method (and
others that allow it like save(validate: false)) implies that there are
times when your validations may not apply. If that’s not actually true—if
your validations should always apply—there’s no way to achieve that with
Active Record.
And this means that no matter how well-factored your code is, it can end up
writing data that violates the domain, either due to a misunderstanding by
a developer, a bug, or a mistake made in a production Rails console.
The database, on the other hand, does not allow such circumvention, so
when you encode a domain rule in the database, misunderstandings, bugs,
and mistakes will generate errors, but they won’t result in bad data being in
the database.
Of course, even if update_column didn’t exist, not all validations actually
work.
257
16.2 Validations Are Awesome For User Experience
In the previous chapter on writing migrations on page 230, we created a
validation to constrain the maximum value of a widget’s price. We didn’t use
the database because we decided this particular domain rule wasn’t stable
and we wanted flexibility that comes with code changes to be able to easily
change it later. This won’t ensure the database contains only valid values,
but it was a trade-off we made.
But validations really shine at something else: managing the user experi-
ence. If we were to create a form to add a widget, and a user provided a
blank value, they would get an exception from the app. That’s not a great
experience. By adding a presence validation to the widget, we can then
access structured error information to present to the user in a friendly and
helpful way.
This coupling of validations, errors, and views is a big reason why working
with Rails feels productive. When we call .valid? on an Active Record (or
Active Model), it will populate the errors attribute with a rich data structure
allowing us to give the user a detailed breakdown of all the validation errors.
Of course, these kinds of validations are technically business logic, which
I went through great pains to convince you not to put in an Active Record.
When people say that programming is all trade-offs, it’s true.
We can either keep all business logic out of our models, which requires
throwing out the Rails validation API (and presumably building our own
replacement), or we can let a little bit of our business rules leak into our
models and get access to an extremely powerful API for managing the user
experience.
I choose the latter and you should, too. Just know that you are making a
trade-off.
Speaking of trade-offs, it might seem that using both validations and data-
base constraints is creating a duplication of effort. If there is a NOT NULL on
the widget name in the database and a validates :name, presence: true
on the model, aren’t we creating problematic duplication?
It’s true that if the rules around widget names change, you’ll have to modify
the database and the model. You might have to change a whole bunch of
things. That doesn’t mean all of that code is duplicative. The database
constraints prevent bad data from getting into our database. The validations
assist the user in providing good data. Although they are related in what
they do and the way they do it, they aren’t the same things.
The only other point to mention about validations is that you can use them
on Active Models as well. ActiveModel::Validations provides most of what
you get with an Active Record. This means that you can use validations on
your non-database-backed resources. This wasn’t always the case with Rails,
so it’s great that the core team has made it available!
258
Let’s talk about callbacks next.
before_validation do
if self.name.blank?
self.name = nil
end
end
# app/models/widget.rb
belongs_to :widget_status
validates :price_cents,
numericality: { less_than_or_equal_to: 10_000_00 }
→ normalizes :name, with: ->(name) { name.blank? ? nil : name }
end
The only other common use for callbacks I can think of is to collect statistics
about the use of certain tables. For example, if you are trying to deprecate a
database table, you may want to add some logging around the use of that
table in your code. You could do this with the after_commit callback:
2 https://guides.rubyonrails.org/active_record_callbacks.html
3 https://api.rubyonrails.org/classes/ActiveRecord/Normalization.html
259
class OldStuff < ApplicationRecord
after_commit do
Rails.logger "#{caller[0]} is using OldStuff"
end
end
Since after_create runs inside a database transaction, this code will hold
that transaction open while deliver_later completes. If this is set up to
queue a job, and you are using Resque or Sidekiq (the two most popular job
queueing systems for Rails), this means you are making a network call to
Redis while holding a database transaction open.
If there is high activity on the WIDGETS table, or on that specific row, this will
create locks in the database. These locks will cause the application to block
and eventually cascade into failures that will seem to have nothing to do
with database transactions or Redis. I have seen this happen first hand at
far below the scale you might think could cause this.
You can avoid this by treating callbacks for what they are: a means to run
code during specific phasers of a database operation. Describing your logic
in those terms usually points out the problem. Would anyone design a
system that made network requests to a key/value store while holding open
a database transaction? Not intentionally, they wouldn’t.
Next, let’s talk about scopes, which are another feature of Active Record you
won’t end up needing much of.
260
16.4 Scopes are Often Business Logic and Belong
Elsewhere
In earlier versions of Rails, scopes were bestowed magical powers not
available to regular methods. You could chain scopes together:
Widget.recent.unapproved.chronological
This means that you don’t even have to declare methods on your Active
Record in order to query the database and chain parts of a query you might
be building up. For example:
Because this is part of the public API on all your Active Records, you should
use where, order, limit and friends as needed to implement your business
logic.
Only when you see a pattern of duplication should you consider extracting
that duplication somewhere. I prefer the “rule of three”, which states that a
third time you do the same thing, extract it somewhere for re-use.
Note also that you may find it better to extract the query logic to a new
service. For example, if we find ourselves constantly needing “fresh” widgets,
but the definition of “fresh” is based on business rules, it might make more
sense to create a FreshWidgetLocator.
Conversely, if we are frequently needing all widgets created in the last day,
that’s less about business logic and more about manipulating data directly.
That would be fine as a class method on Widget like created_in_last_day.
Although we’ve seen a few model tests already, now is a good time to talk
about how to think about testing what little code ends up in your models.
261
16.5 Model Testing Strategy
Models tend to be inputs to (and outputs of) your business logic. In many
cases, models are only bags of data, so they don’t require that much testing
themselves. That said, there are three considerations related to model
testing:
262
of the model for other tests. Rails provides a test fixture facility, but I find
fixtures difficult to manage at even moderate scale, and have not worked
with a team that found them superior to the popular alternative, Factory
Bot.
Factory Bot4 is a library to create factories. Factories can be used to create
instances of objects more expediently than using new. This is because a
factory often sets default values for each field. So, if you want a reasonable
Widget instance but don’t care about the values for each attribute, the factory
will set them for you. This allows code like so:
widget = FactoryBot.create(:widget)
If you need to specify certain values, create acts very much like new or
create on an Active Record:
A factory can also create any needed associated objects, so the above in-
vocations will create (assuming we’ve written our factories properly) a
manufacturer with an address as well as a widget status.
To generate dummy values, I like to use Faker5 . Faker can provide random,
fake values for fields of various types. For example, to create a realistic
email address on a known safe-for-testing domain like example.com, you
can write Faker::Internet.safe_email.
While Faker does introduce random behavior to your tests, I view this as
a feature. It makes sure your tests don’t implicitly become dependent on
values used for testing. You can always re-run tests using a previous random
seed if you need to debug something.
Let’s set it all up. We’ll use the factory_bot_rails gem since that sets up
internals for a Rails app automatically as well as brings in the factory_bot
gem. They go in the development and test groups.
# Gemfile
263
group :development, :test do
→ # We use Factory Bot in place of fixtures
→ # to generate realistic test data
→ gem "factory_bot_rails"
→
→ # We use Faker to generate values for attributes
→ # in each factory
→ gem "faker"
→
# See https://guides.rubyonrails.org/debugging_rails_applic. . .
gem "debug", platforms: %i[ mri windows ]
end
It’s important that our factories produce instances that pass validations and
satisfy all database constraints. To help us manage this, Factory Bot provides
FactoryBot.lint, which will create all of the configured factories and raise
an exception if any fail to create due to constraint or validation failures.
I like to wrap a call to this in a test so it runs as part of our test suite. Let’s
do that before we actually make any factories:
# test/lint_factories_test.rb
require "test_helper"
Now, let’s create a factory for addresses, and we’ll initially create it to
produce invalid data (so we can see our lint test fail).
Factories traditionally go in test/factories (or spec/factories if using
RSpec). The code itself is revealing of intent and does what it appears to do,
but relies on meta-programming to do it. I’ll explain how it works, but first,
here’s what it looks like:
264
# test/factories/address_factory.rb
FactoryBot.define do
factory :address do
street { Faker::Address.street_address }
end
end
You can likely reason that this produces an Address whose street value
comes from the Faker call being made. But I want to explain a bit about
how that works. First, factory :address knows to create an instance of
Address, just as factory :widget_status would know to create an instance
of WidgetStatus. Factory Bot is following the various Rails conventions6 .
Second, the method calls with blocks inside the factory :address block
are declaring test values to use for attributes of Address. Because Address
has a street attribute, the dynamically-created method street is how we
indicate the value to use for it when creating an Address.
In this case, the block being given is evaluated each time we want an instance
in order to get the value. That value is Faker::Address.street_address,
which returns a randomly generated, realistic street address like “742 Ever-
green Terrace”.
Any attribute we don’t list will have a value of nil. Since we omitted zip
and since zip is required by the database, running our lint test should fail:
# Running:
Error:
LintFactoriesTest#test_all_factories_can_be_created:
FactoryBot::InvalidFactoryError: The following factories are. . .
better than using the class name—Address or "Address". The latter is super clear, the same
amount of typing, and doesn’t require explanation.
265
(ActiveRecord::NotNullViolation)
test/lint_factories_test.rb:5:in `block in <class:LintFa. . .
# test/factories/address_factory.rb
FactoryBot.define do
factory :address do
street { Faker::Address.street_address }
→ zip { Faker::Address.zip }
end
end
# Running:
# test/factories/manufacturer_factory.rb
FactoryBot.define do
266
factory :manufacturer do
name { Faker::Company.name }
address
end
end
The call to address on its own works because Factory Bot knows this is not a
normal attribute, but a reference to a related object. Since there is a factory
for that relation, Factory Bot will use that as the value for address.
One thing that can lead to flaky tests is when randomness ends up producing
the same value multiple times in a row for a field that must be unique. While
it doesn’t happen often, it does happen. Faker can manage this by calling
unique on any class before calling the data-generating-method. Let’s use
this in our widget status factory, because widget statuses must be unique
(we should’ve used that on the Manufacturer name as well).
# test/factories/widget_status_factory.rb
FactoryBot.define do
factory :widget_status do
name { Faker::Lorem.unique.word }
end
end
Faker::Lorem will use Lorem Ipsum7 to come up with a fake word. Because
we used unique, no WidgetStatus instance we create with this factory will
ever have the same value.
Note that we did not use one of the known values for widget status. This is
a bit of a trade-off. Even though widget statuses have a set of known valid
values, since those values are in the database, our code should generally not
be coupled to them. Thus, a test that needs any old widget status should
not care what the value is.
That said, if we do need to create a status from one of the known valid
values, we can do that like so:
widget = FactoryBot.create(
:widget,
status: FactoryBot.create(
7 https://en.wikipedia.org/wiki/Lorem_ipsum
267
:widget_status,
name: "Approved")
)
# test/factories/widget_factory.rb
FactoryBot.define do
factory :widget do
name { Faker::Lorem.unique.word }
price_cents { Faker::Number.within(range: 1..10_000_00) }
manufacturer
widget_status
end
end
# Running:
As a final step, let’s replace the setup code in our widget test with factories
instead.
# test/models/widget_test.rb
require "test_helper"
268
× # manufacturer = Manufacturer.create!(
× # name: "Cyberdyne Systems",
× # address: Address.create!(
× # street: "742 Evergreen Terrace",
× # zip: "90210"
× # )
× # )
× # @widget = Widget.create!(
× # name: "Stembolt",
× # manufacturer: manufacturer,
× # widget_status: widget_status,
× # price_cents: 10_00
→ @widget = FactoryBot.create(
→ :widget
)
end
test "valid prices do not trigger the DB constraint" do
That single line of code will use the widget factory to create the widget,
which will in turn create a widget status and a manufacturer, which itself will
in turn create an address. Note that you can call build to create in-memory
versions of these objects without touching the database.
This test should pass:
# Running:
..
269
Up Next
What a journey! It’s now time to look at an end-to-end example. I realize
we have not discussed controllers, jobs, mailers, and other stuff like that,
but now that we understand the relationship between the view, models, the
database, and business logic, it’s time to see a real example. That’s what
we’ll do next.
270
17
End-to-End Example
We haven’t talked about controllers, mailers, jobs, or mailboxes yet, but
we’ve gotten far enough in that I think a more involved is example will
help codify what we’ve learned so far. It should crystallize the benefits of
the approach toward managing business logic. What you’ll see is that we
avail ourselves of all that Rails has to offer, but our core business logic code
will be much more sustainable than if we’d put everything on our Active
Records.
This might seem convoluted, but I have rarely experienced real world
requirements that aren’t like this.
In the remainder of the chapter, we’ll write the code to implement these
requirements, starting with the UI. We’ll follow the guidelines laid out
already in the book and proceed to write a system test, then implement the
business logic.
271
17.2 Building the UI First
No matter how the UI must be styled, it needs to allow the user to select a
manufacturer, enter a widget name and price, and see any validation errors
related to the data entered. We’ll create the UI using semantic markup that
is connected to the controller, which we’ll leave pretty bare. We’ll freshen up
the UI using our design system, then write a system test. When that system
test is done, we can start on the business logic.
Before we create the UI, we’ll need to set up a route and some controller
methods. We should also create some development data in db/seeds.rb.
# config/routes.rb
Rails.application.routes.draw do
→ resources :widgets, only: [ :show, :index, :new, :create ]
resources :widget_ratings, only: [ :create ]
Next, we’ll create some basic controller methods so our views can be ren-
dered. For new we’ll create an empty Widget, but we’ll also expose the list of
manufacturers, since we need that for a drop-down. If you recall from the
section on exposing instance variables on page 100, we ideally expose only
one instance variable for the resource in question, but we can also expose
reference data when needed. The list of manufacturers qualifies as reference
data.
# app/controllers/widgets_controller.rb
272
→ def create
→ render plain: "Thanks"
→ end
→
def show
manufacturer = OpenStruct.new(
id: rand(100),
# db/seeds.rb
if !Rails.env.development?
puts "[ db/seeds.rb ] Seed data is for development only, " +
"not #{Rails.env}"
exit 0
end
require "factory_bot"
Widget.destroy_all
273
Manufacturer.destroy_all
Address.destroy_all
WidgetStatus.destroy_all
Note that this will be run as part of db:reset, so there’s no need to change
our bin/setup script. It’ll now insert this data into the database after re-
creating it.
Now, let’s build the UI.
<section>
<h1>New Widget</h1>
<%= form_with model: @widget do |f| %>
<%= f.label :name %>
<%= f.text_field :name %>
274
<%= f.text_field :price_cents %>
Semantically, this is what is required to make the feature work. Let’s make
sure this is working by navigating to /widgets/new before we embark on
our styling adventure. It should look amazingly awful, as in the screenshot
below.
We could create the system test now, but I find it easier to get at least some
of the styling done first, just in case we end up needing some odd markup
that could affect the test.
These are the improvements we need to make:
275
17.2.4 Provide Basic Polish
First, we’ll deal with the label for price_cents. We can do that by editing
config/locales/en.yml, which is where Rails will look for labels to use
(specifically for English).
# config/locales/en.yml
en:
hello: "Hello world"
→ activerecord:
→ attributes:
→ widget:
→ price_cents: "Price"
This incantation is not easy to find if you don’t know that the problem you
are solving is one about locale and internationalization (and that “interna-
tionalization” is often abbreviated as “i18n”1 ). The documentation is in the
Rails Guide for Internationalization2 .
We can address the placeholders and auto-focus like so:
<h1>New Widget</h1>
<%= form_with model: @widget do |f| %>
<%= f.label :name %>
→ <%= f.text_field :name, autofocus: true,
→ placeholder: "e.g. Stembolt" %>
“internationalization”, but I guess that’s just too difficult so we have to have the most ridiculous
means of abbreviating technical words possible: count the number of letters in the word and
subtract two. Type the first letter of the word, followed by that count (minus two, remember),
followed by the last letter of the word. Sigh. This has brought us i18n, l10n, a11y, o11y, k8s,
and Leto knows how many other nonsense gate-keeping terms.
2 https://guides.rubyonrails.org/i18n.html
276
<%# app/views/widgets/new.html.erb %>
Note that we aren’t using the placeholder as a label—that’s not what place-
holder text is for.
Lastly, let’s sort the manufacturers. We do this in the view, because it is truly
a view concern. The controller’s job (as we’ll discuss later) is to provide
data to the view. The view’s job is to make it consumable by the user.
<%=
f.select :manufacturer_id,
options_from_collection_for_select(
→ @manufacturers.sort_by(&:name),
→ "id", "name"
),
{
include_blank: "-- Choose --",
That was the easy part. The hard part is making it look semi-decent. In lieu
of a wireframe and spec from a designer we’ll use our judgement and do
our best. That will include styling validation errors.
277
Figure 17.2: Create Widget Mockup
278
}
%>
<%= f.label :manufacturer_id, class: "fw4 i" %>
</div>
<div class="tr">
<%= f.submit "Create",
class: "ba br3 ph3 pv2 white bg-dark-blue" %>
</div>
<% end %>
</section>
279
The top level error code looks like so:
This might feel like a re-usable component or that the big mess of classes
should be extracted to some sort of error-dialog class. Resist these feelings.
If we need this exact markup again, we can extract it into a re-usable
component by creating a partial or View Component. Since we only have
this in one place, there’s no value in extracting it or making it re-usable.
What we will want to be re-usable is the field-level error styling. Let’s style
the error using the label. When there’s no error, we’ll show the label as
normal. When there is an error, we’ll show the error messages as the label.
The messages contain the field name so this should be reasonable.
Because the code will be the same for all three fields, we can extract it to a
re-usable component (when I was developing this, I didn’t plan on making
a component, but after the third repetition of the same thing—the “rule of
three”—it seemed like a good idea).
We’ll use a View Component for this and call it LabelWithErrorComponent.
It will require the Active Record, the name of the field it’s labeling, and the
Rails form object.
280
while worth embracing in that context, is not a great object-oriented design
pattern.
# app/components/label_with_error_component.rb
# frozen_string_literal: true
The ERB for the component can handle all the logic of checking for an error.
It’s in app/components/label_with_error_component.html.erb.
With this in place, we replace the label for the name field:
<div class="mb3">
<%= f.text_field :name, class: "db w-100 pa2 mb1",
autofocus: true, placeholder: "e.g. Stembolt" %>
→ <%= render(
→ LabelWithErrorComponent.new(record: @widget,
→ field_name: :name,
→ form: f)
→ ) %>
</div>
281
<div class="mb3">
<%= f.text_field :price_cents, class: "db w-100 pa2 mb1. . .
<div class="mb3">
<%= f.text_field :price_cents, class: "db w-100 pa2 mb1. . .
placeholder: "e.g. 123.45" %>
→ <%= render(
→ LabelWithErrorComponent.new(record: @widget,
→ field_name: :price_cents,
→ form: f)
→ ) %>
</div>
<div class="mb3">
<%=
To reveal this styling, we’ll manually add errors to the widget in the con-
troller:
282
# app/controllers/widgets_controller.rb
You can see the complete styling in the screenshot “New Widget Error UI”
below.
Before writing the system test, here’s a recap of how we went about this,
following the guidelines discussed in previous chapters.
283
• We started with semantic HTML.
• We added div tags to afford styling.
• We extracted a re-usable component into a View Component, as op-
posed to extracting only the styling information as a CSS class.
• We faked out the back-end in order to do the styling we need so we
aren’t wrestling with both back-end logic and front-end styling at the
same time.
In “Fake the Back-end To Get System Test Passing” on page 190, we learned
about minimizing the business logic in play in order to write a system test.
Let’s see that in action now.
We want to test major flows, and there are two that I can see: correctly saving
a widget and seeing validation errors. Our system test can’t reasonably test
all the back-end business logic, and it doesn’t need to exhaustively test each
possible error case. We really only need to make sure that all fields that
could have an error will show one. Fortunately, we can create a blank widget
and this will show validation errors for all three fields.
Since we don’t have JavaScript, our system test can use the standard test
case, ApplicationSystemTestCase. Let’s call the test CreateWidgetTest:
# test/system/create_widget_test.rb
require "application_system_test_case"
Let’s start with the validation errors, because the back-end is already faked-
out to provide errors no matter what.
This test will go to the new widget page, skip filling in any fields, click
“Create”, then validate that there are errors for each field.
284
# test/system/create_widget_test.rb
end
# app/controllers/widgets_controller.rb
end
def create
→ redirect_to new_widget_path
end
def show
# Running:
..
285
We are asserting on content, and so this test could be brittle. We need to
assert on something, so this is reasonable enough to get started. As we
learned in “Use data-testid Attributes to Combat Brittle Tests” on page
193, we can deal with this problem when or if it shows up.
Let’s write the second test for successful widget creation. We’ll know this
by landing on the widget show page and seeing what we entered. This will
require some manufacturers to exist in the database, so that the drop-down
can be used. We’ll need some actual validation logic to avoid breaking the
test we just wrote.
In other words, we can’t totally fake the back-end. Fortunately, for what
we’re testing, we can implement something without a lot of code. We
can have our controller save the widget, add validations to Widget, then
implement this the old-fashioned way.
Let’s write the test first. It should fill in the fields with correct values, hit
“Create”, then validate that we’re on the widget show page.
To do that, we’ll need a widget status and at least two manufacturers. The
status can be created in a before block since it’s needed for pretty much all
the tests. For manufacturers, since they are only relevant to the test we are
about to write, we’ll create those at the top of the test.
# test/system/create_widget_test.rb
require "application_system_test_case"
286
end
# app/controllers/widgets_controller.rb
end
def create
→ @widget = Widget.create(
→ name: params.require(:widget)[:name],
→ price_cents: params.require(:widget)[:price_cents],
→ manufacturer_id: params.require(:widget)[:manufacturer_id],
→ widget_status: WidgetStatus.first)
→ if @widget.valid?
→ redirect_to widget_path(@widget)
→ else
→ @manufacturers = Manufacturer.all
→ render :new, status: :unprocessable_entity
→ end
end
def show
Remember, this is just to get the system test passing. This is not production-
ready code. If we run the test now, it’ll still fail for two reasons: we aren’t
validating all the fields of Widget, and our show method still has all that
OpenStruct stuff in it, meaning it’s not locating the widget we just created.
First, we’ll add validations to Widget:
# app/models/widget.rb
}
end
belongs_to :widget_status
287
→ validates :name, { presence: true }
→ validates :manufacturer_id, { presence: true }
validates :price_cents,
numericality: { less_than_or_equal_to: 10_000_00 }
normalizes :name, with: ->(name) { name.blank? ? nil : name. . .
Stay with me. These aren’t all the validations we might want, but are
enough for us to get our system tests passing. When we move onto the
business logic, the system test can serve as a signal that we haven’t broken
any user-facing behavior.
Let’s head back to WidgetsController and update the show method to look
up the Widget from the database:
# app/controllers/widgets_controller.rb
× # def show
× # manufacturer = OpenStruct.new(
× # id: rand(100),
× # name: "Sector 7G",
× # address: OpenStruct.new(
× # id: rand(100),
× # country: "UK"
× # )
× # )
× # widget_name = if params[:id].to_i == 1234
× # "Stembolt"
× # else
× # "Widget #{params[:id]}"
× # end
× # @widget = OpenStruct.new(id: params[:id],
× # manufacturer_id: manufacturer.id,
× # manufacturer: manufacturer,
× # name: widget_name)
× # def @widget.widget_id
× # if self.id.to_s.length < 3
× # self.id.to_s
× # else
× # self.id.to_s[0..-3] + "." +
× # self.id.to_s[-2..-1]
288
× # end
× # end
→ def show
→ @widget = Widget.find(params[:id])
end
def index
@widgets = [
# app/helpers/application_helper.rb
def styled_widget_id(widget)
content_tag(:span,
→ widget.user_facing_identifier.rjust(7,"0"),
style: "font-family: monospace")
end
end
The test of this helper is still using OpenStruct, so we’ll need to change that
to use FactoryBot. First, we’ll change the first test:
# test/helpers/application_helper_test.rb
assert_match /\D0012\.34\D/,rendered_markup
289
# test/helpers/application_helper_test.rb
end
assert_match /\D9876\.54\D/,rendered_markup
One last thing: we should clean up the explicit error-setting we put in the
new method.
# app/controllers/widgets_controller.rb
# Running:
..
At this point, we have the UI we want, and we have code to make it behave
the way we want, at least as far as the user experience goes. We also have
defined the seam between Rails and the code we have yet to write.
290
Our code will take a name, a price (in cents?), and a manufacturer ID.
It should return, among other things, a Widget instance that, if there are
validation errors, makes those available as an Active Record would.
Now we can implement our business logic, as well as test it for all the
various edge cases we don’t want to test through the UI.
Let’s create the service class that will hold our business logic. This will codify
the contract between our code and the controller. We should be able to do
this without breaking the system test. Once that’s done, we can then start
to build out the real business logic.
# app/services/widget_creator.rb
class WidgetCreator
def create_widget(widget)
widget.widget_status = WidgetStatus.first
widget.save
class Result
attr_reader :widget
def initialize(created:, widget:)
@created = created
@widget = widget
end
def created?
@created
end
end
end
291
This may seem like a lot of code has been introduced just to call valid? on
an Active Record, but bear with me. It will make a lot more sense when we
put all the actual business logic here.
Next, we modify the controller to use this class.
# app/controllers/widgets_controller.rb
@manufacturers = Manufacturer.all
end
def create
× # @widget = Widget.create(
× # name: params.require(:widget)[:name],
× # price_cents: params.require(:widget)[:price_cents],
× # manufacturer_id: params.require(:widget)[:manufacturer_id],
× # widget_status: WidgetStatus.first)
× # if @widget.valid?
× # redirect_to widget_path(@widget)
× # else
× # @manufacturers = Manufacturer.all
× # render :new, status: :unprocessable_entity
→ widget_params = params.require(:widget).permit(
→ :name, :price_cents, :manufacturer_id
→ )
→
→ result = WidgetCreator.new.create_widget(
→ Widget.new(widget_params)
→ )
→
→ if result.created?
→ redirect_to widget_path(result.widget)
→ else
→ @widget = result.widget
→ @manufacturers = Manufacturer.all
→ render :new, status: :unprocessable_entity
end
end
This looks better. The controller now has no knowledge of business logic.
The only thing it knows is what the service wants, and it uses strong para-
meters to get that. The only logic it has is related to routing the user to the
right UI, which is what controllers are for.
292
This means that potentially large changes in the business logic—or its
implementation—won’t require this controller method to change. That’s a
good thing.
Let’s run our system test, which should still pass:
# Running:
..
Nice! We’re almost ready to turn our attention to the business logic, but
there’s one thing that’s a bit wrong. We are passing in price_cents, but
we’ve instructed the user to enter dollars in our placeholder text. Even if
we instruct the user to enter cents, they are going to enter dollars, since it’s
more natural.
This is a UI concern that our business logic should not have to worry about.
If it wants to receive cents, it should receive cents. It could, alternately,
receive dollars instead. Either way, the controller has to do something,
because the value for price_cents is a string.
If the service wants dollars, we have to convert that string into a BigDecimal
(since using to_f to make it a float will lose precision as previously dis-
cussed). If the service wants cents, the controller has to also multiply it by
100.
There are a lot of ways to solve this, but in all cases, we want the controller
to handle it (we’ll talk more about why this is in Controllers on page 313).
The controller is receiving a string containing dollars, and the service wants
cents (as an integer), so the controller should do the conversion. We’ll do
that right in the method:
# app/controllers/widgets_controller.rb
→ if widget_params[:price_cents].present?
→ widget_params[:price_cents] = (
→ BigDecimal(widget_params[:price_cents]) * 100
293
→ ).to_i
→ end
→
result = WidgetCreator.new.create_widget(
Widget.new(widget_params)
)
Our test isn’t affected by the price, because the price is currently not shown
in the UI at all. Because of this conversion, it would be a good idea to find a
way to test it, so that if this conversion changed, a test somewhere would
fail. Since the price is not in the UI, let’s add an assertion about the data
that gets written, so we at least have some coverage.
# test/system/create_widget_test.rb
assert_selector "[data-testid='widget-name']",
text: "Stembolt"
→ assert_equal 123_00, Widget.first.price_cents
end
This test would’ve failed before the conversion, and now it should pass:
# Running:
..
And now we have defined our seam: a Widget instance is passed in, and a
result object is returned that tells the caller exactly what happened. The
result also exposes the possibly-saved Widget.
Note that the controller no longer has to intuit that a valid active record
means the process it initiated completed successfully. After all, creating a
widget is more than just writing data into a database. By using the rich
294
result object (as we discussed in “Return Rich Result Objects. . . ” on page
247), it can be explicit about what it’s checking for.
With this seam in place, we can implement the business logic, using the
system test to make sure we haven’t broken the user experience.
With our seam now defined, I find it easier to switch to a test-first workflow.
The logic we have to build is pretty complex, and this will require a lot of
tests.
For the sake of brevity, we won’t implement all of these right now, but we
will implement a few that allow us to see the affect of Rails validations and
mailers on our implementation and tests.
Let’s start with the basic happy path.
# test/services/widget_creator_test.rb
require "test_helper"
295
FactoryBot.create(:widget_status)
end
test "widgets have a default status of 'Fresh'" do
result = @widget_creator.create_widget(Widget.new(
name: "Stembolt",
price_cents: 1_000_00,
manufacturer_id: @manufacturer.id
))
assert result.created?
assert_equal Widget.first, result.widget
assert_equal "Fresh", result.widget.widget_status.name
end
end
This test should fail since we’re using whatever status is returned by
WidgetStatus.first and not looking for one named “Fresh”.
# Running:
Failure:
WidgetCreatorTest#test_widgets_have_a_default_status_of_'Fre. . .
Expected: "Fresh"
Actual: "hic"
We could fix this by naming the status we’re creating in the setup block, but
that won’t work in production. We need to make sure that the code breaks if
it doesn’t choose the proper status. That means we need the “Fresh” status,
but also another one that would be returned by first.
296
# test/services/widget_creator_test.rb
@manufacturer = FactoryBot.create(:manufacturer,
created_at: 1.year.ago)
FactoryBot.create(:widget_status)
→ FactoryBot.create(:widget_status, name: "Fresh")
end
test "widgets have a default status of 'Fresh'" do
result = @widget_creator.create_widget(Widget.new(
# app/services/widget_creator.rb
class WidgetCreator
def create_widget(widget)
→ widget.widget_status =
→ WidgetStatus.find_by!(name: "Fresh")
widget.save
# Running:
Note the use of find_by!. Our code assumes “Fresh” is in the database, and
if it’s not, we want it to raise an exception, not return nil, since this is a
condition we should not have allowed to go into production. This assumes
we are monitoring for such unexpected exceptions (we’ll talk more about
this in Operations on page 425). Also note that we aren’t thinking about
297
refactoring. We can worry about that later (or maybe never). Right now we
need to get the code working.
Next, let’s write a test of a validation that doesn’t yet exist. Widget names
have to be five characters or longer, so let’s test that.
# test/services/widget_creator_test.rb
Note that we’re checking for the specific error we expect, not just any error.
Also note that second parameter to refute_nil is the summary of all the
errors on the object, so if there is an error, but not the one we expect, the
test failure message is actually helpful.
This test should fail at the first refute:
298
# Running:
.F
Failure:
WidgetCreatorTest#test_widget_names_must_be_5_characters_or_. . .
Expected true to not be truthy.
To fix it, we’ll add a validation to Widget that the name must be at least 5
characters long by using the length: attribute to validates.
# app/models/widget.rb
}
end
belongs_to :widget_status
→ validates :name, {
→ presence: true,
→ length: { minimum: 5 }
→ }
validates :manufacturer_id, { presence: true }
validates :price_cents,
numericality: { less_than_or_equal_to: 10_000_00 }
# Running:
..
299
OK, so why is the WidgetCreatorTest testing code on Widget? The reason is
that WidgetCreatorTest is a test of the business process of creating widgets.
As such, it’s a form of integration test. It’s testing the seam between the
outside world and our code. The test isn’t concerned with precisely how the
validation is implemented, just that it happens.
The only reason our Widget even has this validation is because the business
process—as implemented by WidgetCreator—requires it. There is no other
reason to have written that code. And, as you recall from the last chapter,
we’re putting this business logic on the Active Record because the validations
API is powerful and we don’t want to throw that out.
And this is how we can safely refactor the actual implementation of widget
creation. As long as the API between our code and the controller (the seam)
is stable, and as long as the contract between the UI and the controller is
stable, we can do what we will inside that.
This is extremely powerful. See the sidebar “Return Processing Makeovers”
below for a real world example.
Let’s add one more test around notifying our financial staff of widgets priced
higher than $7,500. This will further demonstrate the layered nature of this
approach.
We can either mock a hypothetical FinanceMailer, or we can examine
ActionMailer::Base.deliveries to see what was emailed. Both strategies
couple us to the use of Rails mailers as the notification mechanism, but the
latter avoids coupling our test to a specific mailer. Let’s take that approach.
300
# test/services/widget_creator_test.rb
refute_nil too_short_error,
result.widget.errors.full_messages.join(",")
end
→ test "finance is notified for widgets priced over $7,500" do
→ result = @widget_creator.create_widget(Widget.new(
→ name: "Stembolt",
→ price_cents: 7_500_01,
→ manufacturer_id: @manufacturer.id
→ ))
→
→ assert result.created?
→ assert_equal 1, ActionMailer::Base.deliveries.size
→ mail_message = ActionMailer::Base.deliveries.first
→ assert_equal "[email protected]", mail_message["to"].to_s
→ assert_match /Stembolt/, mail_message.text_part.to_s
→ end
end
Since deliveries is not well documented, it’s risky to use it, but it’s been in
Rails for many years, so it should be stable enough to rely on. deliveries
returns an array of Mail::Message, which is not part of Rails, but part of
the mail3 gem that is transitively included in all Rails apps.
The approach of examining the mail queue for just enough data to as-
sume everything worked echoes our approach to system testing. The
WidgetCreatorTest cares that an email was sent, but it tries to care as
little as possible so that when the actual mail view is implemented, it can
do what it needs to do without breaking our test. For our purposes, if an
email goes to the finance team’s inbox with the name of the widget, that’s
good enough.
When we implement the mailer for real, this test will make sure that the
mail properly fits into the larger widget creation process. That mailer’s test
can cover all the specificities of what that email should contain.
Back to the test, we should also make sure no emails were sent in our other
test, since the price there is below $7,500.
# test/services/widget_creator_test.rb
assert result.created?
3 https://www.rubydoc.info/github/mikel/mail/Mail
301
assert_equal Widget.first, result.widget
assert_equal "Fresh", result.widget.widget_status.name
→ assert_equal 0, ActionMailer::Base.deliveries.size
end
test "widget names must be 5 characters or greater" do
result = @widget_creator.create_widget(Widget.new(
# test/services/widget_creator_test.rb
To make all the tests pass, we’ll need an actual mailer, so let’s create it:
We’ll implement the mailer and its views to do just enough to pass our test.
Here’s the entire mailer:
# app/mailers/finance_mailer.rb
302
end
end
The views can just show the widget name only for now.
The generator created a test for FinanceMailer that will now be bro-
ken. Let’s delete that for now since we aren’t actually building the real
FinanceMailer.
> rm test/mailers/finance_mailer_test.rb
Now, we can call it in our service and get the test passing:
# app/services/widget_creator.rb
widget.widget_status =
WidgetStatus.find_by!(name: "Fresh")
widget.save
→ if widget.price_cents > 7_500_00
→ FinanceMailer.high_priced_widget(widget).deliver_now
→ end
303
# Running:
...
Each of the tests we wrote should demonstrate the overall strategy to get
to complete coverage. Note again, that this is a strategy, and you can apply
this to RSpec-based tests if you like.
I know it’ll make this section even longer, but let’s quickly go through the
remainder of the implementation. Here are the remaining tests:
# test/services/widget_creator_test.rb
304
→
→ test "price cannot be 0" do
→ result = @widget_creator.create_widget(Widget.new(
→ name: "Stembolt",
→ price_cents: 0,
→ manufacturer_id: @manufacturer.id
→ ))
→
→ refute result.created?
→
→ assert result.widget.errors[:price_cents].any? { |message|
→ message =~ /greater than 0/i
→ }, result.widget.errors.full_messages_for(:price_cents)
→
→ end
→
→ test "price cannot be more than $10,000" do
→ result = @widget_creator.create_widget(Widget.new(
→ name: "Stembolt",
→ price_cents: 10_000_01,
→ manufacturer_id: @manufacturer.id
→ ))
→
→ refute result.created?
→
→ assert result.widget.errors[:price_cents].any? { |message|
→ message =~ /less than or equal to 1000000/i
→ }, result.widget.errors.full_messages_for(:price_cents)
→
→ end
→
→ test "legacy manufacturers cannot have a price under $100" do
→ legacy_manufacturer = FactoryBot.create(:manufacturer,
→ created_at: DateTime.new(2010,1,1) - 1.day)
→
→ result = @widget_creator.create_widget(Widget.new(
→ name: "Stembolt",
→ price_cents: 99_00,
→ manufacturer_id: legacy_manufacturer.id
→ ))
→
→ refute result.created?
→
→ assert result.widget.errors[:price_cents].any? { |message|
→ message =~ /< \$100.*legacy/i
→ }, result.widget.errors.full_messages_for(:price_cents)
→ end
305
→ test "email admin staff for widgets on new manufacturers" do
→ new_manufacturer = FactoryBot.create(:manufacturer,
→ name: "Cyberdyne Systems",
→ created_at: 59.days.ago)
→
→ result = @widget_creator.create_widget(Widget.new(
→ name: "Stembolt",
→ price_cents: 100_00,
→ manufacturer_id: new_manufacturer.id
→ ))
→
→ assert result.created?
→
→ assert_equal 1, ActionMailer::Base.deliveries.size
→ mail_message = ActionMailer::Base.deliveries.first
→ assert_equal "[email protected]", mail_message["to"].to_s
→ assert_match /Stembolt/, mail_message.text_part.to_s
→ assert_match /Cyberdyne Systems/, mail_message.text_part.to_s
→ end
end
The first test—that tests for omitting all of the values—fails, but not in the
right way. Our WidgetCreator has a bug, in that it assumes price_cents
has a value. We can fix that by early-exiting when we see the widget is
invalid:
# app/services/widget_creator.rb
widget.widget_status =
WidgetStatus.find_by!(name: "Fresh")
widget.save
→ if widget.invalid?
→ return Result.new(created: false, widget: widget)
→ end
if widget.price_cents > 7_500_00
FinanceMailer.high_priced_widget(widget).deliver_now
end
Next, we’ll trigger the mailer to the admin team. We’ll need that mailer:
# app/mailers/admin_mailer.rb
306
class AdminMailer < ApplicationMailer
def new_widget_from_new_manufacturer(widget)
@widget = widget
mail to: "[email protected]"
end
end
# app/services/widget_creator.rb
FinanceMailer.high_priced_widget(widget).deliver_now
end
→ if widget.manufacturer.created_at.after?(60.days.ago)
→ AdminMailer.new_widget_from_new_manufacturer(widget).
→ deliver_now
→ end
→
Result.new(created: widget.valid?, widget: widget)
end
The rest of the changes are on the Widget class. We’ll add a greater_than
attribute for validating the price, but we’ll also add a custom validator,
high_enough_for_legacy_manufacturers:
307
# app/models/widget.rb
}
validates :manufacturer_id, { presence: true }
validates :price_cents,
→ numericality: {
→ less_than_or_equal_to: 10_000_00,
→ greater_than: 0
→ },
→ high_enough_for_legacy_manufacturers: true
normalizes :name, with: ->(name) { name.blank? ? nil : name. . .
end
If you haven’t used custom validators before, you can implement them as a
class that extends ActiveModel::EachValidator, like so:
# app/models/widget.rb
}
end
belongs_to :widget_status
→
→ class HighEnoughForLegacyManufacturersValidator <
→ ActiveModel::EachValidator
→ def validate_each(record, attribute, value)
→ return if value.blank?
→ if value < 100_00 &&
→ record.manufacturer.created_at.year < 2010
→
→ record.errors.add(attribute,
→ "must be < $100 for legacy manufacturers")
→
→ end
→ end
→ end
validates :name, {
presence: true,
length: { minimum: 5 }
This demonstrates the power of the Rails end-to-end experience and why we
are using its validation system. This would’ve been difficult to implement
308
another way without also having to have custom view code to manage this
particular validation check.
This validation, however, will potentially break our widget factory, because
it doesn’t guarantee a name will be created with five or more characters.
Let’s change it to use Faker::Lorem.words.join(" "), which will create
three words and join them with a space.
# test/factories/widget_factory.rb
FactoryBot.define do
factory :widget do
→ name { Faker::Lorem.unique.words.join(" ") }
price_cents { Faker::Number.within(range: 1..10_000_00) }
manufacturer
widget_status
# Running:
...........
Of course, we’ve likely broken the system tests we wrote in earlier chapters.
Both rate_widget_test.rb and view_widget_test.rb expected faked-out
data. Let’s fix them as well, so we have a clean build by the end of all this.
First, rate_widget_test.rb (in test/system) needs to create a widget using
FactoryBot and not assume there is one with the id 1234:
# test/system/rate_widget_test.rb
309
test "rating a widget shows our rating inline" do
→ widget = FactoryBot.create(:widget)
→ visit widget_path(widget)
click_on "2"
# app/controllers/widgets_controller.rb
def show
@widget = Widget.find(params[:id])
end
def index
× # @widgets = [
× # OpenStruct.new(id: 1234, name: "Stembolt"),
× # OpenStruct.new(id: 2, name: "Flux Capacitor"),
× # ]
→ @widgets = Widget.all
end
end
Now, our test should create some widgets to assert on. Note that we’re
hard-coding one of the widgets to have an ID of 1234 so that we can assert
on the id-formatting logic. This could cause a problem if some other widget
actually got that ID, but for now we’ll assume that won’t happen.
# test/system/view_widget_test.rb
widget_name = "stembolt"
310
Let’s now check bin/ci to see if the app is still overall working:
> bin/ci
[ bin/ci ] Running unit tests
Running 16 tests in a single process (parallelization thresh. . .
Run options: --seed 48425
# Running:
................
# Running:
311
Looking at WidgetCreator now, I’m fine with the implementation and don’t
see a reason to refactor it. Although the custom validator is covered by our
test, I might add a more exhaustive test for it in test/widget_test.rb since
it’s fairly complex compared to the other validations. I’ll leave that as an
exercise for you.
312
18
Controllers
If you want to respond to an HTTP request in a Rails app, you pretty much
need to use a controller. That’s why they exist. In this sense, only a controller
can receive an HTTP request, trigger business logic based on it, then send a
response, be that rendering a view or redirecting to another path.
There are four issues around controllers that can cause sustainability prob-
lems:
• Controller code is structured unlike any other code in. . . well. . . any
system I’ve ever seen. It’s not object-oriented, functional, or even
procedural. Controller code can seem quite alien.
• Over-use of callbacks can create situations where code is unnecessarily
spread across several methods, connected only implicitly.
• Controllers are the perfect place to insulate downstream business logic
from the “hashes of strings” API Rails provides for accessing the HTTP
request.
• Unit tests of controllers are often duplicative of tests in other parts of
the system.
313
language, or “internal DSL” (internal because it’s Ruby code and not another
language made just for this purpose). Despite all of its weirdness, it works
really well, as long as you treat it as what it is.
I like to think of it as a very rich configuration language. This prevents
me from putting business logic in the controllers themselves, and helps me
understand the purpose of the code in the controllers.
In the vein of treating Rails for what it is—not what you wish it would
be—do not try to bend controller code into more traditional object-oriented
structures. Embrace the controller code for what it is. Since you are making
heavy use of resources (as discussed in “Don’t Create Custom Actions, Create
More Resources” on page 83), and since you have put your business logic
behind a seam (as discussed frequently, including the previous chapter), you
won’t end up needing much code in your controllers.
By embracing controllers for what they are and how they work, you’ll keep
the code in them minimal, and thus won’t need exhaustive tests for them,
and this all reduces carrying costs (the key to sustainability).
That said, our controllers still do need some code in them, so let’s talk
about what sort of code that is and how to manage it. The biggest source of
confusion in controller code is what we’ll talk about next: callbacks.
def edit
end
def update
if @manufacturer.save
314
redirect_to manufacturer_path(@manufacturer)
else
render :edit, status: :unprocessable_entity
end
end
def show
end
private
def set_manufacturer
@manufacturer = Manufacturer.find(params[:id])
end
end
While this code does consolidate the way in which a Manufacturer is loaded
and exposed to the view, it has created a controller that is unnecessarily
complex - the core part of what show and edit do has been hidden behind
an implicit invocation.
As more callbacks are added, piecing together exactly what happens in these
methods becomes harder, and for what gain? All to consolidate a small
piece of highly stable code. If that code really did need to be extracted to a
single source, a private method would work far better:
def update
@manufacturer = load_manufacturer
if @manufacturer.save
redirect_to manufacturer_path(@manufacturer)
else
render :edit, status: :unprocessable_entity
end
end
def show
@manufacturer = load_manufacturer
end
315
private
def load_manufacturer
Manufacturer.find(params[:id])
end
end
def show
@widget = Widget.find(params[:id])
end
316
This code takes a string containing an identifier that we assume identifies
a widget, and looks it up in the database, passing the actual widget to the
view.
Because HTTP is a text-based protocol, and because Rails provides us only
hashes of strings as an API into it, controllers are in the unique position to
insulate the rest of the codebase from this reality.
This is complicated by the fact that Active Record handles a lot of conversions
for us. For example, find knows to convert the string it was given into a
number to do the database lookup. Active Record can also convert dates
and booleans. For example, you can set a date to the string "2020-05-13"
and Active Record will convert it when it saves to the database.
This isn’t always available to us, as we saw the use of dollars in the UI for a
widget’s price, but the requirement by the back-end to receive cents. And,
if we use custom resources based on Active Model, we can’t access any of
Active Record’s conversions.
Nevertheless, I still believe the controller should handle getting strings into
whatever types they need to be in for the business logic. Just keep in mind
that for Active Records, strings are the type needed. In other words, there is
no value in writing code like this:
# Not needed, since Active Record can convert the string for us
@widget = Widget.find(params[:id].to_i)
This will lead to inconsistency in your controllers, but it’s likely worth it.
You’ll just need to carefully manage the conversion code. This doens’t mean
such code has to be inlined into the controller, however. The controller just
needs to make sure the conversion happens.
For example, we might end up with a lot of dollars-to-cents conversions in
our app. You might make a class like Price:
## app/models/price.rb
class Price
attr_reader :cents
def initialize(dollars)
@cents = if dollars
(BigDecimal(dollars) * 100).to_i
end
end
end
317
The controller would still be responsible for using this class:
widget_params[:price_cents] =
Price.new(widget_params[:price_cents]).cents
(Note that you should not do this unless you need to for managing du-
plication. If the only dollars-to-cents conversion you ever need is in this
controller, you’ll be glad not to have an extra abstraction hanging around.)
In any case, this logic might not be testable from our system test. Thus, it
will need a test. But to test something like this we may end up duplicating
tests we already have.
318
Here’s what the test looks like:
# test/controllers/widgets_controller_test.rb
require "test_helper"
widget = Widget.last
refute_nil widget
assert_redirected_to widget_path(widget)
assert_equal 12345, widget.price_cents
end
end
# Running:
Note that the test ensures the parameters are strings, no matter what. This
is critical, and it’s a failure of Rails that it does not coerce these values to
strings for you. This is because the values in production will always be
strings!
319
I know I’ve made the mistake of posting a boolean to a controller in a test,
only to find that while the test passed, the controller was woefully broken
in production, since the string "false" is a truthy value.
On thing to note is that while this test exists to test the price conversion
logic, we can’t properly test it if widget creation is broken. Rather than
duplicate all of WidgetCreator’s tests, we do a quick check first:
refute_nil widget
assert_redirected_to widget_path(widget)
This is the assertion that tells us if the controller is working or not. The other
two assertions don’t tell us that. Without those other assertions, if widget
creation was broken, the test would fail in an odd way. We’d get something
like NoMethodError: no such method price_cents for NilClass. We’d
expect a failure message for this assertion to be related to the wrong value
for price_cents, not an error.
That’s why I wrote the other two assertions. If widget creation is broken,
we’ll get a failure that the widget was assumed to have been created. If
that assertion fails, we have no confidence in our test at all, because logic it
assumes is working is broken—the test itself can’t technically run.
But it’s hard to know that from looking at the code. We need a way to
leverage the assertion library but also to indicate that some tests are just
performing confidence checks before the actual test assertions execute.
320
We’ll do that by assuming the existence of a method called confidence_check
that takes a block and executes the code inside that block.
# test/controllers/widgets_controller_test.rb
}
}
widget = Widget.last
× # refute_nil widget
× # assert_redirected_to widget_path(widget)
→ confidence_check do
→ refute_nil widget
→ assert_redirected_to widget_path(widget)
→ end
assert_equal 12345, widget.price_cents
end
end
Now the test makes it clear that refute_nil and assert_redirected_to are
only there to double-check that the basics are working before we do the real
assertion, which follows.
In addition to demarcating the code, we need to see a helpful error in our
test output letting us know that the test effectively wasn’t even run because
of factors outside its own control. We’ll augment the exception raised by
the testing framework to put a message indicating the failure is not a test
failure, but a confidence check failure.
Since Ruby doesn’t have a way to modify the message of a thrown exception,
we’ll create our own and delegate all its methods to the exception raised by
the failed assertion.
We can put this in support/confidence_check.rb and require it inside our
base test case, similar to what we did with with_clues in “Cultivate Explicit
Diagnostic Tools to Debug Test Failures” on page 186.
# test/support/confidence_check.rb
module TestSupport
module ConfidenceCheck
class ConfidenceCheckFailed < Minitest::Assertion
def initialize(minitest_assertion)
super("CONFIDENCE CHECK FAILED: #{minitest_assertion.message}")
321
@minitest_assertion = minitest_assertion
end
delegate :backtrace,
:error,
:location,
:result_code,
:result_label,
:backtrace_locations,
:cause, to: :@minitest_assertion
end
# test/test_helper.rb
config.test_id = "data-testid"
end
→ require "support/confidence_check"
→
module ActiveSupport
class TestCase
# Run tests in parallel with specified workers
# test/test_helper.rb
322
module ActiveSupport
class TestCase
→ include TestSupport::ConfidenceCheck
→
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
Up Next
When you organize code the way I’m suggesting, your controllers end up
being pretty basic. That’s a good thing! Where controllers process web
requests, there is another construct most Rails apps need that process
requests asynchronously: jobs.
1 https://github.com/sustainable-rails/confidence-check
323
19
Jobs
One of the most powerful tools to make your app high-performing and fault-
tolerant is the background job. Background jobs bring some complexity
and carrying cost to the system, so you have to be careful not to swap one
sustainability problem for another.
This chapter will help you navigate this part of Rails. We’ll start by un-
derstanding exactly what problems background jobs exist to solve. We’ll
then learn why you must understand exactly how your chosen job backend
(Sidekiq, Resque, etc.) works. We’ll set up Sidekiq in our example app, since
Sidekiq is a great choice if you don’t have specific requirements otherwise.
We’ll then learn how to use, build, and test jobs. After all that we’ll talk
about a big source of complexity around background jobs, which is making
them idempotent. Jobs can and will be automatically retried and you don’t
usually want their effects to be repeated. Achieving idempotency is not easy
or even possible in every situation.
Both of these situations amount to deferring code that might take too long
to a background job to run later. The reason this is important has to do with
how your Rails app is set up in production.
325
19.1.1 Web Workers, Worker Pools, Memory, and Compute
Power
In development, your Rails app uses the Puma1 web server. This server
receives requests and dispatches them to your Rails app (this is likely how it
works in production as well). When a request comes in, Puma allocates a
worker to handle that request. That worker works on only that request until
a response is rendered—it can’t manage more than one request at a time.
When the response is rendered, the worker can work on another request.
Puma keeps these workers in a pool, and that pool has a finite limit. This
is because each worker consumes memory and CPU (even if it’s not doing
anything) and, because memory and CPU are finite resources, there can
only be so many workers per server.
What if all workers are handling requests? What happens to a new request
that comes in when there is no worker to handle it?
It depends. In some configurations, the new request will be denied and
the browser will receive an HTTP 503 (resource unavailable). In other
configurations that request will be placed in a queue (itself a finite resource)
to be handled whenever a worker becomes available. In this case the request
will appear to be handled more slowly than usual.
While you can increase the overall number of workers through complex
mechanisms such as load balancers, there is always going to be a finite
amount of resources to process requests. Often this limit is financial, not
technical, since more servers and more infrastructure cost more money and
it may not be worth it.
Another solution to the problem of limited workers is to reduce the amount
of work those workers have to do. If your controller initiates a business
process that takes 500ms normally, but can be made to defer 250ms of that
process into a background job, you will have doubled your worker capacity2 .
One particular type of code that leads to poor performance—and thus is
a good target for moving to a background job—is code that interacts with
third party APIs, such as sending email or processing payments.
326
Of course, network calls that fail don’t fail immediately. They often fail after
an interminable amount of time. Or not. Sometimes the network is just
slow and a successful result eventually comes back.
Background jobs can help solve this problem. The figure below outlines how
this works.
In the figure, you can see that the initial POST to create an order causes
the controller to insert an order into the database then queue a background
job to handle communicating with the payment processor. While that’s
happening, the controller returns the order ID to the browser.
The browser then uses Ajax to poll the controller’s show method to check
on the status of the order. The show method will fetch the order from the
database to see if it’s been processed. Meanwhile, the background job waits
for the payment processor until it receives a response. When it does, it
updates the order in the database. Eventually, the browser will ask about
the order and receive a response that it’s completed.
This may seem complex, but it allows the web workers (which are executing
only the controller code in this example) to avoid waiting on the slow
payment provider.
This design can also handle transient errors that might happen when commu-
nicating with the third party. The job can be automatically retried without
having to change how the front-end works.
327
19.1.3 Network Calls and Third Parties are Flaky
Network calls fail. There’s just no way to prevent that. The farther away
another server is from your server, the more likely it is to fail, and even at
small scale, network failures happen frequently.
In most cases, network failures are transient errors. Retrying the request
usually results in a success. But retrying network requests can take a while,
since network requests don’t fail fast. Your background jobs can handle this.
The figure below shows how this might work.
When our job encounters a network error, it can retry itself. During this
retry, the front-end is still diligently asking for an update. In this case it
waits a bit longer, but we don’t have to re-architect how the entire feature
works.
This might all seem quite complex and, well, it is. The rest of this chapter
will identify sources of complexity and strategies to work around them, but
it’s important that you use background jobs only when needed.
328
Thus, your use of background jobs should be when you cannot tolerate these
failures at whatever level you are seeing them.
This can be hard to judge. A guideline that I adopt is to always communicate
with third parties in a background job, because even at tiny scale, those
communications will fail.
For all other code, it’s best to monitor its performance, set a limit on how
poor the performance is allowed to get, and use background jobs when
performance gets bad (keeping in mind that background jobs aren’t the
only solution to poor performance). For example, you might decide that the
90th percentile of controller action response times should always be under
500ms.
When you are going to use background jobs, you need to understand how
the underlying system actually works to avoid surprises.
329
internal queue and execute it. If you use Sidekiq, the job goes into Redis.
The job class and the arguments passed to it are converted into JSON before
storing, and converted back before the job runs.
It’s important to know where the jobs are stored so you can accurately
predict failure modes. In the case of Sucker Punch, if your app’s process
dies for some reason, any unprocessed job is gone without a trace.
In the case of Sidekiq (or Resque), you may lose jobs if Redis goes down,
depending on how Redis is configured. If you are also using that Redis for
caching, you then run the risk of using up all of the storage available on
caching and will be unable to queue jobs at all.
You also need to know the mechanism by which the jobs are stored wherever
they are stored. For example, when you queue a job for Sidekiq, it will store
the name of the job class as a string, and all of the arguments as an array.
Each argument will be converted to JSON before being stored. When the
job is executed, those JSON blobs will be parsed into hashes.
This means that if you write code like this:
ChargeMoneyForWidgetJob.perform_async(widget)
330
It’s common for job backends to integrate with exception notification services
like Bugsnag or Rollbar. You need to understand exactly how this integration
works. For example, Resque will notify you once before placing the job in
the failed queue. Sidekiq will notify you every time the job fails, even if that
job is going to be retried.
I can’t give specific advice, because it depends on what you have chosen,
but you want to arrange for a situation in which you are notified when a
job that should complete has failed and won’t be retried. You don’t want
notification when a job fails and will be retried, nor do you need to know if
a job fails whose failure doesn’t matter.
Failure is a big part of the next thing you need to know, which is how to
observe the behavior of the job backend.
331
You are likely to encounter Sidekiq in the real world, and you are very likely
to encounter a complex job backend configuration.
First, we’ll add the Sidekiq gem to Gemfile:
# Gemfile
We will also need to create the binstub so we can run it if we need to:
# .env.development
DATABASE_URL="
postgres://postgres:postgres@db:5432/widgets_development"
→ SIDEKIQ_REDIS_URL=redis://redis:6379/1
332
The value redis for the host comes from the key used in the
docker-compose.yml file to set up Redis. For the test environment,
we’ll do something similar, but instead of /1 we’ll use /2, which is a different
logical database inside the Redis instance.
# .env.test
DATABASE_URL=postgres://postgres:postgres@db:5432/widgets_tes. . .
→ SIDEKIQ_REDIS_URL=redis://redis:6379/2
Note that we put “SIDEKIQ” in the name to indicate the purpose of this
Redis. You should not use the same Redis instances for both job queueing
and caching if you can help it. The reason is that it creates a single point of
failure for two unrelated activities. You don’t want a situation where you
start aggressively caching and use up your storage preventing jobs from
being queued.
Now, we’ll create an initializer for Sidekiq that uses this new environment
variable:
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.redis = {
url: ENV.fetch("SIDEKIQ_REDIS_URL")
}
end
Sidekiq.configure_client do |config|
config.redis = {
url: ENV.fetch("SIDEKIQ_REDIS_URL")
}
end
Note that we used fetch because it will raise an error if the value
SIDEKIQ_REDIS_URL is not found in the environment. This will alert us if we
forget to set this in production.
We don’t need to actually run Sidekiq in this chapter, but we should set it
up. This is going to require that bin/run start two simultaneous processes:
the Rails server we are already using and the Sidekiq worker process. To
333
do that we’ll use Foreman3 , which we’ll add to the development and test
sections of our Gemfile:
# Gemfile
Foreman uses a “Procfile” to know what to run. The Procfile lists out all
the processes needed to run our app. Rather than create this file, I prefer
to generate it inside bin/dev. This centralizes the way we run our app to
a single file, which is more mangeable as our app gets more complex. I
also prefer to name this file Procfile.dev so it’s clear what it’s for (services
like Heroku use Procfile to know what to run in production). Let’s replace
bin/run with the following:
# bin/dev
#!/usr/bin/env bash
set -e
334
echo "# This is generated by bin/dev. Do not edit" > Procfile.dev
echo "# Use this via bin/dev" >> Procfile.dev
# We must bind to 0.0.0.0 inside a
# Docker container or the port won't forward
echo "web: bin/rails server --binding=0.0.0.0" >> Procfile.dev
echo "sidekiq: bin/sidekiq" >> Procfile.dev
# .gitignore
Now, when we run our app with bin/dev, Sidekiq will be started as well and
any code that requires background job processing will work in development.
Let’s talk about how to queue jobs and how to implement them.
Once you know how your job backend works and when to use a background
job, how do you write one and how do you invoke it?
Let’s talk about invocation first.
19.4.1 Do Not Use Active Job - Use the Job Backend Directly
Active Job was added to Rails in recent years as a single abstraction over
background jobs. This provides a way for library authors to interact with
background jobs without having to know about the underlying backend.
335
Active Job does a great job at this, but since you aren’t writing library code,
it creates some complexities that won’t provide much value in return. Since
Active Job doesn’t alleviate you from having to understand your job backend,
there isn’t a strong reason to use it.
The main source of complexity is the way in which arguments to jobs are
handled. As discussed above, you need to know how those arguments are
serialized into whatever data store your job system is using. Often, that
means JSON.
This means that you can’t pass an Active Record directly to a job since it
won’t serialize/de-serialize properly:
> bin/rails c
rails-console> require "pp"
rails-console> widget = Widget.first
rails-console> pp JSON.parse(widget.to_json) ; nil
{"id"=>1,
"name"=>"Stembolt",
"price_cents"=>102735,
"widget_status_id"=>2,
"manufacturer_id"=>11,
"created_at"=>"2020-05-24T22:02:54.571Z",
"updated_at"=>"2020-05-24T22:02:54.571Z"}
=> nil
Before Active Job, the solution to this problem was to pass the widget ID to
the job, and have the job look up the Widget from the database. Active Job
uses globalid4 to automate this process for you. But only for Active Records
and only when using Active Job.
That means that when you are writing code to queue a job, you have to
think about what you are passing to that job. You need to know what type
of argument is being passed, and whether or not it uses globalid. I don’t like
having to think about things like this while I’m coding and I don’t see a lot
of value in return for doing so.
Unless you are using multiple job backends—which will create a sustain-
ability problem for you and your team—use the API of the job backend you
have chosen. That means that your arguments should almost always be
basic types, in particular database identifiers for Active Records.
Let’s see that with our existing widget creation code. We’ll move the
logic around emailing finance and admin to a background job called
PostWidgetCreationJob, which we’ll write in a moment. We’ll use it like so:
4 https://github.com/rails/globalid
336
# app/services/widget_creator.rb
widget.save
if widget.invalid?
return Result.new(created: false, widget: widget)
end
× # if widget.price_cents > 7_500_00
× # FinanceMailer.high_priced_widget(widget).deliver_now
× # end
# XXX
× # if widget.manufacturer.created_at.after?(60.days.ago)
× # AdminMailer.new_widget_from_new_manufacturer(widget).
× # deliver_now
× # end
# XXX
× # Result.new(created: widget.valid?, widget: widget)
→ PostWidgetCreationJob.perform_async(widget.id)
→ Result.new(created: widget.valid?, widget: widget)
end
class Result
337
# app/jobs/application_job.rb
Now, any job we create that extends ApplicationJob will be set up for
Sidekiq and we won’t have to include Sidekiq::Worker in every single
class. We could customize the output of bin/rails g job by creating the
file lib/templates/rails/job/job.rb.tt, but we aren’t going to use this
generator at all. The reason is that our job class will be very small and we
won’t write a test for it.
Here’s what PostWidgetCreationJob looks like:
# app/jobs/post_widget_creation_job.rb
# app/services/widget_creator.rb
→ def post_widget_creation_job(widget)
→ if widget.price_cents > 7_500_00
→ FinanceMailer.high_priced_widget(widget).deliver_now
→ end
→
338
→ if widget.manufacturer.created_at.after?(60.days.ago)
→ AdminMailer.new_widget_from_new_manufacturer(widget).
→ deliver_now
→ end
→ end
→
class Result
attr_reader :widget
def initialize(created:, widget:)
Our app should still work, but we’ve lost the proof of this via our tests. Let’s
talk about that next.
In the previous section, I said we wouldn’t be writing a test for our Job.
Given the implementation, I find a test that the job simply calls a method
to have low value and high carrying cost. But, we do need coverage that
whatever uses the job is working correctly.
There are three approaches to take regarding testing code that uses jobs,
assuming your chosen job backend supports them. You can run jobs syn-
chronously inline, you can store jobs in an internal data structure, executing
them manually inside a test, or you can allow the jobs to actually go into a
real queue to be executed by the real job system.
Which one to use depends on a few things.
Executing jobs synchronously as they are queued is a good technique when
the jobs have simple arguments using types like strings or numbers and
when the job is incidental to the code under test. Our widget creation code
falls under this category. There’s nothing inherent to widget creation that
implies the use of jobs.
Queuing jobs to an internal data structure, examining it, and then executing
the jobs manually is more appropriate if the code you are testing is inherently
about jobs. In this case, the test serves as a clear set of assertions about
what jobs get queued when. A complex batch process whereby you need to
fetch a lot of data, then queue jobs to handle it, would be a good candidate
for this sort of approach.
This approach is also good when your job arguments are somewhat complex.
The reason is that queuing the jobs to an internal structure usually serializes
them, so this will allow you to detect bugs in your assumptions about how
arguments are serialized. It is incredibly common to pass in a hash with
339
symbols for keys and then erroneously expect symbols to come out of the
job backend (when, in fact, the keys will likely be strings).
The third option—using the job backend in a production-like mode—is
expensive. It requires running a worker to process the jobs outside of your
tests (or having your test trigger that worker somehow) and requires that
the job data storage system be running and be reset on each new test run,
just as Rails resets the database for you.
I try to avoid this option if possible unless there is something so specific
about the way jobs are queued and processed that I can only detect it by
running the actual job backend itself.
For our code, the first approach works, and Sidekiq provides a way to do
that. We will require "sidekiq/testing" in test/test_helper.rb and then
call Sidekiq::Testing.inline! around our test.
First, however, let’s make sure our test is actually failing:
# Running:
Failure:
WidgetCreatorTest#test_finance_is_notified_for_widgets_price. . .
Expected: 1
Actual: 0
.F
Failure:
WidgetCreatorTest#test_email_admin_staff_for_widgets_on_new_. . .
Expected: 1
Actual: 0
.....
340
8 runs, 22 assertions, 2 failures, 0 errors, 0 skips
Test Failed
Good. It’s failing in the right ways. You can see that the expected effects
of the code we removed aren’t happening and this causes the test failures.
When we set Sidekiq up to run the job we are queuing inline, the tests
should start passing.
Let’s start with test/test_helper.rb:
# test/test_helper.rb
# test/services/widget_creator_test.rb
We need to undo this setting after our tests run in case other tests are relying
on the default (which is fake!):
341
# test/services/widget_creator_test.rb
FactoryBot.create(:widget_status)
FactoryBot.create(:widget_status, name: "Fresh")
end
→ teardown do
→ Sidekiq::Testing.fake! # the default setting
→ end
test "widgets have a default status of 'Fresh'" do
result = @widget_creator.create_widget(Widget.new(
name: "Stembolt",
# Running:
To use the second testing strategy—allowing the jobs to queue and run-
ning them manually—consult your job backend’s documentation. Sidekiq
provides methods to do all this for you if you should choose.
Now that we’ve seen how to make our code work using jobs, we have to
discuss another painful reality about background jobs, which is retries and
idempotence.
342
configure your jobs to automatically retry all errors (or at least retry them
several time before finally failing).
This means that code executed from a job must be idempotent: it must not
have its effect felt more than once, no matter how many times it’s executed.
Consider this code that updates a widget’s updated_at5
def touch(widget)
widget.updated_at = Time.zone.now
widget.save!
end
Each time this is called, the widget’s updated_at will get a new value. That
means this method is not idempotent. To make it idempotent, we would
need to pass in the date:
Now, no matter how many times we call touch with the same arguments,
the effect will be the same.
The code initiated by our jobs must work similarly. Consider a job that
charges someone money for a purchase. If there were to be a transient
error partway through, and we retried the entire job, the customer could be
charged twice. And we might not even be aware of it unless the customer
noticed and complained!
Making code idempotent is not easy. It’s also—you guessed it—a trade-off.
The touch method above probably won’t cause any problems if it’s not
idempotent. But charging someone money will. This means that you have
to understand what might fail in your job, what might happen if it’s retried,
how likely that is to happen, and how serious it is if it does.
This means that your job is going to be idempotent with respect to some
failure modes, and not to others. This is OK if you are aware of it and make
the conscious decision to allow certain scenarios to not be idempotent.
Let’s examine the job we created in the last section. It’s called
post_widget_creation_job in WidgetCreator, which looks like so:
5 I realize you would never actually write this, but idempotence is worth explaining via a
343
1 def post_widget_creation_job(widget)
2 if widget.price_cents > 7_500_00
3 FinanceMailer.high_priced_widget(widget).deliver_now
4 end
5
6 if widget.manufacturer.created_at.after?(60.days.ago)
7 AdminMailer.new_widget_from_new_manufacturer(widget).
8 deliver_now
9 end
10 end
For example, if line 2 fails, there’s no problem, because nothing has hap-
pened but if line 7 fails—depending on how—we could end up sending the
emails twice.
Another thing I will do is ask myself what might happen if the code is retried
a long time later. For example, suppose line 3 fails and the mail isn’t sent
to the finance team. Suppose that the widget’s price is updated before the
failure is retried. If the price is no longer greater than $7,500, the mail will
never get sent to the finance team!
How we deal with this greatly depends on how serious it is if the code
doesn’t execute or executes many times. It also can depend on how much
control we really have. See the sidebar “Idempotent Credit Card Charging”
on the next page for an example where a third party doesn’t make it easy to
create idempotent code.
Let’s turn our attention to two problems with the code. First is that we
might not send the emails at all if the widget is changed between retries.
Second is that a failure to send the admin email might cause us to send the
finance email again.
You might think we could move the logic into the mailers and have the
mailers use background jobs. I don’t like having business logic in mailers as
we’ll discuss in “Mailers” on page 349, so let’s think of another way.
Let’s use two jobs instead of one. We’ll have one job do the finance check
based on only the price and another do the manufacturer check based on
only the creation date.
344
Idempotent Credit Card Charging
The code to charge customers at Stitch Fix was originally written to
run in the request cycle. It was ported from Python to Ruby by the early
development team and left alone until we all realized it was the source of
double-charges our customer service team identified.
We moved the code to a background job, but knew it had to be idempo-
tent. Our payment processor didn’t provide any guarantees of idempotency,
and would often decline a retried charge that had previously succeeded. We
implemented idempotency ourselves and it was. . . pretty complex.
Whenever we made a charge, we’d send an idempotency key along with
the metadata. This key represented a single logical charge that we would
not want to have happen more than once.
Before making a charge, we would fetch all the charges we’d made to
the customer’s credit card. If any charge had our idempotency key, we’d
know that the charge had previously gone through but our job code had
failed before it could update our system. In that case, we’d fetch the charge’s
data and update our system.
If we didn’t see that idempotency key, we’d know the charge hadn’t gone
through and we’d initiate it. Just explaining it was difficult, and the code
even more so. And the tests! This was hard to test.
> rm app/jobs/post_widget_creation_job.rb
We’ll replace our use of that job in WidgetCreator with the two new jobs
called HighPricedWidgetCheckJob and WidgetFromNewManufacturerCheckJob.
# app/services/widget_creator.rb
end
# XXX
# XXX
→ HighPricedWidgetCheckJob.perform_async(
→ widget.id, widget.price_cents)
→ WidgetFromNewManufacturerCheckJob.perform_async(
→ widget.id, widget.manufacturer.created_at.to_s)
Result.new(created: widget.valid?, widget: widget)
end
345
Note that we are calling to_s on created_at. Sidekiq cannot correctly
serialize a DateTime and will emit a warning if we don’t serialize it explicitly.
We’ll now replace post_widget_creation with two methods that these jobs
will call.
# app/services/widget_creator.rb
widget.id, widget.manufacturer.created_at.to_s)
Result.new(created: widget.valid?, widget: widget)
end
× # def post_widget_creation_job(widget)
× # if widget.price_cents > 7_500_00
× # FinanceMailer.high_priced_widget(widget).deliver_now
× # end
# XXX
× # if widget.manufacturer.created_at.after?(60.days.ago)
× # AdminMailer.new_widget_from_new_manufacturer(widget).
× # deliver_now
× # end
× # end
# XXX
× # class Result
→ def high_priced_widget_check(widget_id, original_price_cents)
→ if original_price_cents > 7_500_00
→ widget = Widget.find(widget_id)
→ FinanceMailer.high_priced_widget(widget).deliver_now
→ end
→ end
→
→ def widget_from_new_manufacturer_check(
→ widget_id, original_manufacturer_created_at)
→ if original_manufacturer_created_at.after?(60.days.ago)
→ widget = Widget.find(widget_id)
→ AdminMailer.new_widget_from_new_manufacturer(widget).
→ deliver_now
→ end
→ end
→ class Result
attr_reader :widget
def initialize(created:, widget:)
@created = created
346
# app/jobs/high_priced_widget_check_job.rb
# app/jobs/widget_from_new_manufacturer_check_job.rb
Our tests should still pass, and give us coverage of the date-parsing we just
had to do6 .
compare the manufacturer created date to another and, even though it was really a string,
the tests all seemed to pass, because I was using < to do the comparison. I changed it to use
before? after some reader feedback and discovered it was a string. Even after understanding
how jobs get queued in detail, and having directly supported a lot of Resque jobs (which do the
same JSON-encoding as Sidekiq) for almost eight years, I still got it wrong. Write tests, people.
347
Running 8 tests in a single process (parallelization thresho. . .
Run options: --seed 59404
# Running:
Up Next
We’re just about done with our tour of Rails. I want to spend the next
chapter touching on the other boundary classes that we haven’t discussed,
such as mailers, rake tasks, and mailboxes.
7 https://pragprog.com/titles/dcsidekiq/ruby-on-rails-background-jobs-with-sidekiq/
348
20
20.1 Mailers
Mailers are a bit of an unsung hero in Rails apps. Styling and sending email
is not an easy thing to do and yet Rails has a good system for handling it. It
has an API almost identical to rendering web views, it can handle text and
HTML emails, and connecting to any reasonable email provider is possible
with a few lines of configuration. And it can all be tested.
There are three things to consider when writing mailers. First is to under-
stand the purpose of a mailer and thus not put business logic in it. Second,
understand that mailers are really jobs, so the arguments they receive must
be considered carefully. Last, you need a way to actually look at your emails
while styling them, as well as while using the app in development mode.
Let’s start with the purpose of mailers.
349
For example, our widget creation code has logic that sends the finance
team an email if the widget’s price is above $7,500. You might think it’s a
good idea to encapsulate the check on the widget’s price in the mailer itself.
There is no real advantage to doing this and it will only create sustainability
problems later.
First, it requires executing the mailer to test your widget creation logic.
Second, it means that if something else needs to happen for a high-priced
widget, you have to move the check back into WidgetCreator anyway. It’s
much simpler if your mailers simply format and send mail.
Ideally, your mailers have very little logic in them at all. If you end up
having complex rendering logic for an email, it could be an indicator you
actually have two emails. In this case, have the business logic trigger the
appropriate email instead of adding logic to the mailer itself.
The next thing to understand is that in most cases, your email is sent from a
job.
If you recall, Active Job uses something called globalid to allow you to safely
serialize Active Records (and only Active Records by default) into and out of
the job backend. This means that our code as it’s written will work correctly
if the email is sent via a job.
If, on the other hand, you send a non-Active Record to your mailer (including
a date!), it may not be serialized and de-serialized correctly (this is why I
recommended using the job backend directly for background jobs).
That said, to send emails using the job backend directly, you’d have to make
your own mailer job or jobs and duplicate what Rails is already doing. My
suggestion is to use Rails to send emails with Active Job, and manage the
inconsistency in how arguments are handled via code review.
You could additionally require that mailer arguments are always simple
values that convert to and from JSON correctly. In any case, make sure
everyone understands the conventions.
350
20.1.3 Previewing, Styling, and Checking your Mail
Testing mailers works like any other class in Rails. The more difficult part
is styling and checking what you’ve done. This is because there are many
different email clients that all have different idiosyncrasies about how they
work, how much CSS they support—if any—and what they do to render
emails.
Fortunately, Rails provides the ability to preview emails in your browser.
Let’s style the finance email.
When we created this mailer with bin/rails g, it created a preview class
for us in test/mailers/previews called finance_mailer_preview.rb.
If you haven’t used mailer previews before, they allow you to create some
test data and render an email in your browser. It’s not exactly like using a
real email client, but it works pretty well. Each method of the preview class
causes a route to be enabled that will call that method and render the email
it returns.
To create the test data, you can rely on whatever you may have put into
db/seeds.rb, or you can use your factories. Let’s use this latter approach.
We’ll replace the auto-generated code with code to create a widget and pass
it to the mailer. We’ll use build instead of create. build won’t save to the
database. For the purposes of our mailer preview, this is fine, and, because
we want to use hard-coded names, it makes things a bit easier. If we saved
these records to our dev database, the first time we refreshed the page, it
would try to save new records with duplicate names and cause an error.
# test/mailers/previews/finance_mailer_preview.rb
end
We can fire up our app with bin/dev, and navigate to this path against your
development server:
351
/rails/mailers/finance_mailer/high_priced_widget
You should see our very un-exciting email rendered, as in the screenshot
below.
Since this is an email to our internal finance team, there’s no need for it to
be fancy, but it should look at least halfway decent. Let’s try to create an
email that looks like so:
We want to use our design system (as discussed in “Adopt a Design System”
on page 140), but we can’t use CSS since few email systems support it. This
is a good reminder that our design system is a specification, not an implemen-
tation. Our CSS strategy and related code is one possible implementation,
but we can also use inline styles in our mailer views to implement the design
system as well. To do that, we need to know the underlying spacing and
font size values.
We know the font sizes already from when set up our style guide. For
example, to get the third-largest font size, we can use a style like font-size:
2.8rem. For padding and other sizing, we’ll need to look at how our CSS is
implemented to get the specific sizes. In our case, we’ll only need two of the
spacings, specifically 0.25rem and 0.5rem.
352
And, since we can’t rely on floats, flexbox, or other fancy features of CSS,
we’ll create the two column layout with tables. . . just like the olden days.
Other than that, we’ll still use semantic HTML where we can. This all goes
in app/views/finance_mailer/high_priced_widget.html.erb:
353
In order to use styled_widget_id helper, we need to use the mailer method
to bring in the methods in ApplicationHelper:
# app/mailers/finance_mailer.rb
If you reload your preview, the email now looks like it should, though it
certainly feels underwhelming given all the markup we just wrote. See the
screenshot below.
We should make the plain text version work, too. Let’s avoid any ASCII-art
and just do something basic.
354
This can also be previewed and should look like the screenshot below.
Note that you can use partials and View Components to create re-usable
components, just as we did with web views. You may want to place partials
somewhere like app/views/mailer_components or namespace View Com-
ponents in a mailers directory to make it clear they are intended for mail
views only.
For helpers, you can use the helpers in ApplicationHelper using the helper
method, but you can make your own mail-specific helpers. I recommend
again somewhere obvious like app/helpers/mailer_helpers.rb, so no one
mistakenly uses them in web views.
Lastly, if you are going to be creating a lot of emails in your app, you should
consider augmenting your style guide to show both CSS and inline styles so
355
that you can easily apply the design system to your emails.
In addition to previewing emails for styling, you may want to see them
delivered in development.
services:
mailcatcher:
image: sj26/mailcatcher
ports:
- "9998:1080"
This YAML snippet shows that MailCatcher will expose its web UI (running
on port 1080) to your local machine’s port 9998. Thus, you can access
MailCatcher’s UI at http://localhost:9998. Your Rails app would need
to connect to an SMTP server running on port 1025 (the default) of the
host mailcatcher (which is derived from the service name in the YAML file).
MailCatcher is nice to have setup for doing end-to-end simulations or demos
in your development environment.
While mailers respond to business logic by sending email, Rake tasks initiate
business logic, so let’s talk briefly about those.
1 https://guides.rubyonrails.org/action_mailer_basics.html
2 https://mailcatcher.me
356
20.2 Rake Tasks
Sometimes you need to initiate some logic without having a web view to
trigger it. This is where Rake tasks come in. There are two problems in
managing Rake tasks: naming/organizing, and code. Before that, let’s talk
briefly about what should be in a Rake task.
3 https://github.com/moove-it/sidekiq-scheduler
357
Rake tasks are also a good tool for performing one-off actions where you
need some sort of auditable “paper trail”. If you are in a heavily audited
environment, such as one that must be Sarbanes-Oxley (SOX) compliant,
you may not be able to simply change production data arbitrarily. But you
will need to change production data sometimes to correct errors. A Rake
task checked into your version control system can provide documentation
of who did what, when, even if the Rake task is only ever executed once.
So, how should you organize these tasks?
It might seem like overkill, but this will scale very well and no one is going to
complain that they can easily figure out where a task is defined by following
a convention. I’ll also point out that your Rails app has no limit on the
number of source files it can contain—there’s plenty to go around4 .
Beyond this, you will need to think about the information architecture of
your Rake tasks. This is not easy. My suggestion is the same one I’ve given
many other times in this book, which is to look for a pattern to develop and
form a convention around that.
4 Yes, I know there is a real limit, but it’s like in the billions. If you have a Rails app with
358
As an example, here is how the lib/tasks directory is structured in an app
I’m working on right now (I’m using the tree5 command that will make
ASCII art of any directory structure):
359
# lib/tasks/change_approved_widgets_to_legacy.rake
Given the current state of the app, placing this code in WidgetCreator
doesn’t make much sense, so we’ll make a new class. If our task was to
perform some sort of follow-up to created widgets, it might make sense to
go in WidgetCreator, but since this is about old widgets, we’ll make a new
class.
This Rake task doesn’t need to be tested. We’ll run it locally to make sure
there are no syntax errors, and that should be sufficient. It’s unlikely to ever
change again and there is no value in asserting that we’ve written a line of
code correctly by reproducing that line of code in a test.
Let’s create the new class:
# app/services/legacy_widgets.rb
class LegacyWidgets
def change_approved_widgets_to_legacy
# Implementation here...
end
end
This class is unremarkable. It’s like any other code we’d write, and we can
implement it by writing a test, watching it fail, and writing the code. Or
whatever you do. The point is that the Rake task’s implementation is in a
normal Ruby class.
Let’s consider a much different task. Suppose we have added a validation
that all widget prices must end in .95, for example $14.95. We can en-
force this for new widgets via validations, but all the existing ones won’t
necessarily have valid prices.
We need to make a one-time change to fix these. Because the way we fix
them could be complicated and because we want to review and audit this
change, we won’t make the change in the database directly. We need some
code.
Let’s make the rake task. The task we just created is already in lib/tasks,
but this new task is different. If we put our new task alongside it in
360
lib/tasks, it could be confusing, since our new task is intended to run only
one time, whereas change_approved_widgets_to_legacy is intended to run
regularly.
Let’s make that distinction clear by creating a namespace called
one_off, meaning our task will go in lib/tasks/one_off. We’ll call it
fix_widget_pricing:
# lib/tasks/one_off/fix_widget_pricing.rake
namespace :one_off do
desc "Fixes the widgets created before the switch to 0.95 validation"
task fix_widget_pricing: :environment do
# ???
end
end
# app/services/one_off/widget_pricing.rb
module OneOff
class WidgetPricing
def change_to_95_cents
Widget.find_each do |widget|
# Whatever logic is needed to update the price
end
end
end
end
# lib/tasks/one_off/fix_widget_pricing.rake
namespace :one_off do
361
desc "Fixes the widgets created before the switch to 0.95 v. . .
task fix_widget_pricing: :environment do
→ OneOff::WidgetPricing.new.change_to_95_cents
end
end
Why go through the hassle of having our Rake task defer to a class in
app/services that is clearly not designed to be used more than once?
Doesn’t this make things more complicated than they need to be?
It depends. Yes, to accomplish this particular task requires writing six
additional lines of code compared to in-lining change_to_95_cents in the
Rake task itself. The problem with in-lining is that it creates a decision-point
for all Rake tasks. Should the task’s code go into app/services or directly
into the Rake file?
Decisions like this have a carrying cost, and the inconsistency is just not
worth it. It’s more sustainable to reduce this carrying cost by creating an
architecture that minimizes the number of decisions that need to be made.
One common use of Rake tasks that you should be wary of, however, is for
development environment automation.
362
create your automation in bin/ and document its existence in bin/setup or
your README.
Before we leave this chapter, I want to briefly touch on some of Rails’ other
boundary classes.
363
20.3.3 Active Storage
Active Storage is a feature that abstracts access to cloud storage services
like Amazon’s S3. It is a technology I very much wish had existed years ago,
because we wrote our own janky version of this at my last job and it was a
pain to deal with.
I have not used Active Storage in production, and don’t have a lot of
deep thoughts about it. My guess is that it won’t save you from having to
understand how the backing store works. But, since it’s part of Rails, it
should be reliable and supported. It also serves a much more common use
case than Action Cable, meaning you are likely to get better support for it if
you run into trouble.
Up Next
This completes our tour of the various parts of Rails and how I believe you
can work with them sustainably. The rest of the book will focus on patterns
and techniques that are more broad and cross-cutting. The next chapter will
talk about something that’s not part of Rails but that most Rails apps need:
authentication and authorization.
364
PART
III
beyond rails
21
Authentication and
Authorization
One of the most common cross-cutting concerns in any app is the need to
authenticate users and authorize the actions they may take in an app. Rails
does not include any facility for managing this, since the way authentication
is handled is far less common than, say, the way code accesses a database.
This gap requires that you do some up-front thinking and design for how
you want to handle this important part of your app. For authentication,
there are two common gems that handle most common cases, and we’ll talk
about which situations are appropriate for which. These gems—Devise and
OmniAuth—allow you to avoid the difficult and error-prone task of rolling
your own authentication system.
For authorization—controlling who can do what in your app—the situation
is more difficult. There just aren’t as many commonalities across apps
related to role-based access control, so you can’t pick a solution and go.
We’ll talk about using the popular Cancancan gem to define and manage
roles, but it’ll still be up to you to design a role-based system that meets
your needs.
And, of course, you’ll need to test your authentication and authorization
systems. Remember that tests are a tool for mitigating risk, and they can
work well for mitigating the risks of unauthorized access to your app. But
they don’t come for free.
Let’s talk about authentication first, which is the way in which we know who
a user accessing our website is. The two most common gems that provide
this are Devise1 and OmniAuth2 .
367
reverse-engineering the algorithm used for generating random numbers on
your server and using that to guess passwords more efficiently.
Security is one of those areas where leaning heavily on expertise and expe-
rience will pay off far better than learning it from first principles. When it
comes to user management, I’m almost certain that you, dear reader, are
not the expert that, say, Google’s entire security team is. And that’s OK.
When it comes to user management, you want to ideally allow someone
you trust to handle as much of the authentication as you can, be that the
combined 546 contributors to Devise, or the team at Google that manages
their OAuth implementation.
The simplest way that reduces risk—assuming it meets all your
requirements—is to allow a third party service like Google or GitHub to
manage authentication. OmniAuth can handle much of the integration for
you if you go this route.
368
Figure 21.1: OmniAuth Authentication Flow
The main consequence of using OmniAuth is that you require your users
to have an account with a trusted third-party. It’s important to understand
what “trusted” means in this context. A third party I trust for my app, might
not be worthy of your trust for your app.
For example, if you are working on the website for the United States Internal
Revenue Service (responsible for collecting taxes in the US), you probably
don’t want to allow a private company to even know who is logging into
your service. It’s not a slight on Google, but the IRS shouldn’t trust Google
with this information.
If you either cannot trust the third parties where your users have accounts,
or your users don’t have accounts with third parties you do trust, you’ll need
to build authentication into your app. For that, you should use Devise.
369
User Active Record, backed by the users database table (these names are
configurable).
The User model can be configured with Devise-provided modules to give
your authentication process whatever features it needs. For example, you
can allow users to reset their passwords using the Recoverable module. You
can lock accounts after a certain number of failed attempts by using the
Lockable module. There are many more.
Devise also provides a user interface for you. The views it provides are
bare-bones, so you’ll likely need to make use of your design system (as
discussed in “Adopt a Design System” on page 140) to make them look good.
I’m not going to walk through setting up Devise as this would be duplicative
of the great documentation it already has. My suggestions for using Devise
are to go through the “Getting Started” part of its documentation in your
app. Then, take a look at the configurable modules and bring in those that
you need. You can bring others in later.
Note that you can combine both OmniAuth and Devise to allow multiple
forms of authentication. This can complicate your overall authentication
strategy and will reduce the security of your site, since each method of
authentication is a potential attack vector. But it’s an option you have if you
need it.
Once you have authentication sorted out, you are likely to need some form
of authorization to control which users are allowed to perform which actions
in the app.
370
the list of IAM Roles is massive. You simply can’t consult a list of them to
decide which are the right ones for a given task.
To further complicate the task of authorization design, whatever you come
up with has to be easily auditable. In other words, you need to create
a system in which you can easily answer the question “What is this user
allowed to do?” and prove that you have implemented this correctly to
someone else.
371
features that will complicate your application if you aren’t careful in how
you use them.
class Ability
include CanCan::Ability
def initialize(user)
if user.present?
if user.department == "customer_service"
can [ :index, :show ], Refund
This only defines the permissions. You still need to check them. You can
use authorize_resource to apply a permissions check to all the standard
controller actions:
372
You can use rescue_from to control the user experience when that happens,
for example:
This all works based on the assumption that current_user returns an object
representing who is logged in. How this is defined depends on your authen-
tication scheme, but it’s typical to store the user’s ID in the session, and
implement current_user in ApplicationController to examine the session
and fetch the user record:
def current_user
@current_user ||= User.find_by(id: session[:user_id])
end
end
Note that if you are using OmniAuth, you will need to store some record in
your database when the user successfully authenticates so you can associate
them with roles. This would happen in step 5 from the figure “OmniAuth
Authentication Flow” on page 369.
Cancancan will also allow you to call authorize! in a controller method
to authorize more explicitly, but you will find it much simpler to rely on
authorize_resource and a properly-configured Ability class.
To restrict content in your views based on roles, you can use the method
can?. While excessive use of this can create complicated view code, it’s often
handy when you want to omit links the user shouldn’t see. For example,
this will show the “Create Refund” link only to a user authorized to create
refunds:
373
Cancancan is more flexible than this, but using this flexibility will likely
make your authorization system more confusing.
374
I would highly recommend a thorough testing of all authentication flows no
matter what. This is particularly important if you are using Devise, since
Devise outputs code you have to maintain yourself.
As for testing authorizations, this can be trickier. It requires a solid under-
standing of why your authorization configuration is the way it is. What
problems are being solved by restricting access to various parts of the sys-
tem? What is the consequence of an unauthorized person gaining access to
a feature they aren’t supposed to access? If that happened, would you know
it had happened?
The answers to these questions can help you know where to focus. For
example, if you can’t tell who performed a critical action that is restricted to
certain users, you should thoroughly test the access controls to that action.
You also want to make it as easy as possible for developers to test the
authorizations around new features or to test changes to authorizations.
There are two things you can do to help. The first is to make sure you
have a wide variety of test users that you can create with a single line of
code in a test. The second is to cultivate re-usable test code to setup for an
authorization-related test or verify the results of one (or both).
The way to cultivate both of these is to start writing your system tests and
look for patterns. If you followed my advice in “Models, Part 2” on page 262,
you should have a factory to create at least one user. As you write system
tests using different types of users, extract any that you use more than once
into a factory. This allows future developers—yourself included—to quickly
create a user with a given role.
You will also notice patterns in how you set up your test or perform asser-
tions. Extract those when you see them. The mechanism for this depends
on your testing framework. For Minitest, you can follow the pattern we
established with with_clues and confidence_check, by creating modules
in test/support:
## test/support/authorization_system_test_support.rb
module TestSupport
module AuthorizationSystem
def login_as(user_factory_name)
user = FactoryBot.create(user_factory_name)
def assert_no_access
# assert whatever the UX is
# for users being denied access
end
375
end
end
## test/system/create_manufacturer_test.rb
require "test_helper"
require "support/authorization_system_test_support"
assert_no_access
end
end
If using Rspec, you can use this pattern for setup code, but you will likely
want to make custom matchers for assertions.
If you do have security or compliance people on your team or at your
company, you should use them to help think through what should and
should not be tested. Most security professionals understand the concept of
risk and understand the trade-offs between exhaustively testing everything
and being strategic. In fact, they are better at this than most, since it’s a
critical part of their job. Avail yourself of their expertise.
Up Next
Continuing our discussion of sustainability issues beyond the Rails applica-
tion architecture, let’s talk about JSON APIs next.
376
22
API Endpoints
Rails is a great framework for making REST APIs, which are web services
intended to be consumed not by a browser, but by another programmer.
Even if your app is not explicitly an API designed for others to consume, you
might end up needing to expose endpoints for your front-end or for another
app at your company to consume.
The great thing about APIs in Rails is that they can be built pretty much
like regular Rails code. The only difference is that your APIs render JSON
(usually) instead of an HTML template. Still, developers do tend to over-
complicate things when an API is involved, and often miss opportunities to
keep things sustainable by leveraging what Rails gives you.
That’s what this chapter is about. It’s not about designing, building, and
maintaining a complex web of microservices, but instead just about how to
think about JSON endpoints you might use for programmatic communica-
tion between systems.
Here’s what we’ll cover:
As always, we start with what problem we’re trying to solve with our
hypothetical API.
377
will have incurred both massive opportunity costs and large carrying costs
without benefit.
Before navigating the complex world of strategies around APIs—from au-
thentication to data serialization—you should be honest about what your
API is actually for. Write out the use cases and identify who will be using
the API. It’s OK to suppose some reasonable future uses and consumers, but
don’t let flights of fancy carry you away.
Just because you might think it would be cool to have the world’s preeminent
Widget API doesn’t mean it will happen. And if it did happen, the best way
to prepare for it is to minimize carrying costs around the features you do
need to build. This is where a keen understanding of your product roadmap
and overall problems your app solves are critical.
For the rest of this chapter I’m going to assume you need an API for some-
thing simple, such as consumption by your own front-end code via Ajax
calls, or lightweight app-to-app integration inside your team or organization.
A public-facing API that is part of your product is a different undertaking.
Keep the details about why you are building an API at the top of your mind.
Developers will propose a lot of different solutions in the name of security,
scalability, and maintainability. Being able to align on the actual needs of
the API can help drive those conversations productively. For example, Ajax
calls within your Rails app really don’t require JWTs vended by a separate
OAuth flow, even if such an architecture might be more scalable.
Once you understand what your API is for, you next need a general strategy
for implementing it. The basis of that strategy is to adopt the same conven-
tions we’ve discussed in this book: working resource-oriented, following
Rails conventions, and embracing Rails for what it is—not what you wish it
might be.
22.2 Write APIs the Same Way You Write Other Code
Ideally, a controller that powers an API should look just as plain as any other
controller:
def create
378
widget = Widget.create(widget_params)
if widget.valid?
render json: { widget: widget }, status: 201
else
render json: { errors: widget.errors }, status: 422
end
end
end
You may not want exactly this sort of error-handling, but you get the idea.
There’s rarely a reason to do anything different in your API controller
methods than in your non-API methods.
You would be well-served to create a separate routing namespace and thus
controller namespace for your API calls. This means that while a browser
might navigate to /widgets/1234 to get the view for widget 1234, an API
client would access /api/widgets/1234.json to access the JSON endpoint.
The reason for this is to build in from the start a notion of separation that
you might need later. For example, if you eventually need to serve your API
from another app, your front-end infrastructure can route /api to a different
back-end app. If both a browser and an API client used /widgets/1234, this
will be harder to pull apart.
There’s also little advantage in mixing the browser and API code in the same
controller. Often there are little differences, and you don’t always have an
API endpoint for each browser-facing feature (or vice-versa). If you have
duplicated code, you can share it with modules or classes.
You should also create a base controller for all your API endpoints.
This allows you to centralize configuration like authentication or
content-negotiation without worrying about your web-based endpoints.
Let’s see both of these in action by creating an endpoint for widgets. We’ll
skip authentication and versioning for now—we’ll talk about those in a bit.
First, we’ll create the base controller, called ApiController and place it in
app/controllers/api_controller.rb:
# app/controllers/api_controller.rb
Next, we’ll create a route for our API endpoint, and use the api namespace:
379
# config/routes.rb
# app/controllers/api/widgets_controller.rb
We’ll write a test for this later, but hopefully you can see that your API
controllers can—and should—be written just like any other. You will still
defer business logic to the service layer, and still approach your design by
identifying resources. Concerns like authentication, versioning, and serial-
ization formats can all be handled as controller callbacks or middleware.
Let’s talk about those next, because you have to sort these issues out before
building your API. First, we’ll talk about authentication.
380
22.3 Use the Simplest Authentication System You Can
https://username:[email protected]/api/widgets.json
For example, in our base ApiController, you could do something like this:
You don’t have to use a single set of hard-coded set of credentials, either.
See the Rails documentation1 for examples of more sophisticated setups
that allow multiple credentials.
A second almost-as-simple mechanism is to use the HTTP Authorization
header2 . Despite its name, this header is used for authentication and can
encode an API key. Setting HTTP headers is, like Basic Auth, something any
HTTP client library can do, and can be done with any command-line HTTP
client, such as curl. This, too, is something Rails provides support for3 .
1 https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Basic.html
2 https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization
3 https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token.html
381
I would recommend these mechanisms if you don’t have specific require-
ments that preclude their use. Many high-traffic, public APIs use these
mechanisms and have for years, so there is no inherent issue with scalability.
They also have the virtue of being easy for any developer of any level of
experience to understand quickly.
Let’s set up token-based authentication for our API. Rather than hard-code
a single key, let’s create a database table of keys instead. This way, we can
give each known client their own key, which helps with auditing. We’ll also
allow for keys to be de-activated without being deleted.
For the stability of this book, I’m going to rename the migration file. You
don’t have to do this.
> mv db/migrate/*create_api_keys.rb \
db/migrate/20210102000000_create_api_keys.rb
Now, we’ll create the table. It will have a key, a created date, a client name,
and a deactivation date.
# db/migrate/20210102000000_create_api_keys.rb
382
t.timestamps
end
# db/migrate/20210102000000_create_api_keys.rb
There are a few other things we need, too. First, the API keys should be
unique, so we’ll need an index to enforce that constraint. Second, we don’t
want any client to have more than one active API key. We can achieve this
with a Postgres conditional index. This is an index that only applies when
the data matches a given WHERE clause, which we can specify to rails using
the where: option of add_index.
# db/migrate/20210102000000_create_api_keys.rb
383
We’ll run the migration:
> bin/db-migrate
[ bin/db-migrate ] migrating development schema
== 20210102000000 CreateApiKeys: migrating =================. . .
-- create_table(:api_keys, {:comment=>"Holds all API keys fo. . .
-> 0.0081s
-- add_index(:api_keys, :key, {:unique=>true, :comment=>"API. . .
-> 0.0051s
-- add_index(:api_keys, :client_name, {:unique=>true, :where. . .
-> 0.0018s
== 20210102000000 CreateApiKeys: migrated (0.0151s) ========. . .
Let’s create the model and a test for that partial index, since this is somewhat
complex and could be a surprising implementation to developers unfamiliar
with Postgres.
First, the model, which is just two lines of code:
# app/models/api_key.rb
# test/factories/api_key_factory.rb
FactoryBot.define do
factory :api_key do
key { SecureRandom.uuid }
client_name { Faker::Company.unique.name }
384
end
end
We can now create the test of the model, which will exercise the partial
index.
# test/models/api_key_test.rb
require "test_helper"
exception = assert_raises do
ApiKey.create!(
key: SecureRandom.uuid,
client_name: "Cyberdyne"
)
end
assert_nothing_raised do
ApiKey.create!(
key: SecureRandom.uuid,
client_name: "Cyberdyne"
)
end
end
end
385
This test should pass:
# Running:
..
With that in place, we can now use this table to locate API keys for authenti-
cation.
In our ApiController, we’ll create a callback:
# app/controllers/api_controller.rb
We’ll see this in action when we write our test, but you can try it locally by
using curl to access your endpoint and see that you get an HTTP 401. If
you create a record in the api_keys table, then use that key with curl, it
should work. For example:
Once you have authentication set up, you’ll need some sort of content
negotiation.
386
22.4 Use the Simplest Content Type You Can
The HTTP Accept header allows for a wide variety of configurations for
how a client can tell the API what sort of content type it wants back (the
Content-Type header is for the server to specify what it’s sending). You can
ignore it altogether and always serve JSON, or you could require the content
type to be application/json, or you could create your own custom content
type for all your resources, or even make a content type for each resource.
The possibilities—and associated carrying costs—are endless.
I would not recommend ignoring the Accept header. It’s not unreasonable
to ask clients to set it, it’s not hard for them to do so, and it allows you to
serve other types of content than JSON from your API if you should need it.
I would discourage you from using custom content types unless there is
a very specific problem you have that it solves. When we discuss JSON
serialization, I’m going to recommend using to_json and I’m not going to
recommend stuff like JSON Schema, as it is highly complex. Thus, a content
type of application/json would be sufficient.
That said, if you decide you need to use more advanced tooling like JSON
Schema, a custom content type could be beneficial, especially if you have
sophisticated tooling to manage it. If you have to hand-enter a lot of
custom types and write custom code to parse out the types, you are probably
over-investing.
While you should examine the Accept header, there’s no reason to litter your
API code with respond_to calls that will only ever respond to JSON. Thus,
you can have a single check in ApiController for the right content type.
Rails provides the request method that encapsulates the current request.
It has a method format that returns a representation of what was in the
Accept header. That representation can respond to json? to tell us if the
request was a JSON request.
We can use this and, if the request is not JSON, return an HTTP 406 (which
indicates that the app doesn’t support the requested format). First, we’ll
specify a callback. We want it after the authentication callback since there’s
no sense checking the content of an unauthorized request.
# app/controllers/api_controller.rb
387
private
# app/controllers/api_controller.rb
def show
respond_to do |format|
format.json do
# ...
end
format.pdf do
# ...
end
end
end
private
def require_json_or_pdf
if !request.format.json? &&
388
!request.format.pdf?
head 406
end
end
end
Note that to make code like this work, you’ll need to register the PDF mime
type. See the documentation on Mime::Type4 for more details.
Once you’ve added code for content types, you next need to decide how you
will handle versioning, even though you might never need it.
Nothing gets a debate going around API design quite like versioning. Ver-
sioning is when you decide that you need to change an existing endpoint,
but maintain both the original and the changed implementations.
There are two decisions you have to make around versioning. First is to
decide what constitutes a new version. Second is how to model that in your
API.
I would highly recommend you adopt a simplified semantic versioning policy
for your APIs. Semantic Versioning5 states that a version is three numbers
separated by dots, for example 1.4.5. The first is the major version and
when this changes, it indicates breaking changes to the underlying API.
Code that worked with version 1 should expect to not work with version
2. Changes to the other two numbers (called minor and patch) indicate
backwards compatible changes. Code that works with version 1.3.4 should
work with 1.4.5.
For your API, don’t track or worry about minor versions and patches—only
track major versions. If you make backwards-compatible changes to an
endpoint, leave the current version as it is. Only when you need to make a
backwards-incompatible change should you bump the version number of
the API.
I would make a few additional recommendations:
389
• Version your endpoints, not your entire API. For example, if you decide
you need a new version of the widgets API, do not also make your
manufacturers API version 2. Doing this will create a version explosion
in your API that will be hard to manage.
• Adopt a deprecation policy as well, so you can remove old versions.
Once you’ve adopted a versioning policy, you next need to decide how this
gets implemented in your API. There are three common mechanisms for
this:
# config/routes.rb
####
390
> mkdir app/controllers/api/v1 ; mv \
app/controllers/api/widgets_controller.rb \
app/controllers/api/v1
# app/controllers/api/v1/widgets_controller.rb
Now, our URLs and classes match precisely, and the way versioning works is
pretty obvious. These are good things!
391
22.6 Use .to_json to Create JSON
Your data model has been (presumably) carefully designed to ensure cor-
rectness, reduce ambiguity, and model the data that’s important to your
business. Your app’s various endpoints are all resourceful, using Active
Model to create any other domain concepts you need that aren’t covered by
the Active Records.
It therefore stands to reason that your API’s JSON should mimic these
carefully-designed data structures. If your API must be so different from
your domain model or database model that you need a separate set of classes
to create the needed JSON, something may be wrong with your modeling.
This isn’t to say that your JSON payloads won’t need additional metadata,
but if a widget in the database has a name, it will make the most sense to
everyone if the JSON representation contains a key called "name" that maps
to the widget’s name, just like it does in the database and code.
Of course, it’s possible as time goes by that there is some drift, but in my
experience this is unlikely. Thus, the way you should form JSON should be
to call to_json on an Active Record or Active Model, like so:
def show
widget = Widget.find(params[:id])
# Note that Rails automatically calls to_json for you
render json: { widget: widget }
end
end
392
22.6.1 How Rails Renders JSON
The standard library’s JSON package adds the method to_json to pretty
much every class, but it doesn’t work quite the way Rails wants, nor the way
we want for making an API. Rails changes this in Active Support6 .
Rails does this by creating a protocol for objects to turn themselves into
hashes, which Rails then turns into actual JSON. The method that does this
is as_json. All objects return a reasonable value for as_json. For example:
> bin/rails c
console> puts Widget.first.as_json
=> {
"id"=>1,
"name"=>"Stembolt",
"price_cents"=>747894,
"widget_status_id"=>2,
"manufacturer_id"=>11,
"created_at"=>"2020-06-20T20:01:22.687Z",
"updated_at"=>"2020-06-20T20:01:22.687Z"
}
This even works for non-Active Records in the way you’d expect:
You are asking Rails to turn the hash { widget: widget } into JSON. It will
recursively turn the contents into JSON as well, meaning to_json is called
on widget, and the implementation of to_json calls as_json.
Of course, the JSON Rails produces might not be exactly what you want.
Because of the as_json protocol, you can customize what happens.
6 https://github.com/rails/rails/blob/7-1-stable/activesupport/lib/active_support/cor
e_ext/object/json.rb
393
22.6.2 Customizing JSON Serialization
The as_json method takes an optional argument called options. Every
object in your Rails’ app will respect two options passed to as_json, which
are mutually exclusive:
For example:
For example:
console> Widget.first.as_json(
methods: [ :user_facing_identifier ],
except: [ :widget_status_id ],
include: [ :widget_status ]
)
=> {
"id"=>1,
394
"name"=>"Stembolt",
"price_cents"=>747894,
"manufacturer_id"=>11,
"created_at"=>"2020-06-20T20:01:22.687Z",
"updated_at"=>"2020-06-20T20:01:22.687Z",
"user_facing_identifier"=>"1",
"widget_status"=>{
"id"=>2,
"name"=>"facere",
"created_at"=>"2020-06-20T20:01:22.677Z",
"updated_at"=>"2020-06-20T20:01:22.677Z"
}
}
Active Models don’t get these extra options by default. To grant them
such powers requires mixing in ActiveModel::Serializers::JSON and im-
plementing the method attributes to return a hash of all the model’s
attributes and values.
Now that we know how JSON serialization can be customized how should
we customize it?
def show
widget = Widget.find(params[:id])
render json: {
widget: widget.as_json(
methods: [ :user_facing_identifier ],
except: [ :widget_status_id ],
include: [ :widget_status ]
)
}
end
395
messaging system, you might encode data in JSON to send into that system.
There’s no reason to use a different encoding, so how do you centralize the
way widgets are encoded in JSON?
The simplest way is to override as_json in the Widget class itself. Doing
that would ensure that anyone who called to_json on a widget would get
the single serialization format you’ve designed.
This might feel uncomfortable. Why are we giving our models yet another
responsibility? What if we really do want a different encoding sometimes?
Shouldn’t we separate concerns and have serialization live somewhere else?
These are valid questions, but we must again return to what Rails and Ruby
actually are and how they actually work. Rails provides a to_json method
on all objects. There are several places in Rails where an object is implicitly
turned into JSON using that method. That method is implemented using
as_json, which is also on every single object.
Given these truths, it makes the most sense to override as_json to explicitly
define the default encoding of an object to JSON. If you do have need for a
second way of encoding—and you should be very careful if you think you
do—you can always call as_json with the right options.
Let’s see how to write an as_json implementation to address all of our
needs. We’ll make options an optional argument, and for each option we
want to set, we’ll only set it if the caller has not.
# app/models/widget.rb
},
high_enough_for_legacy_manufacturers: true
normalizes :name, with: ->(name) { name.blank? ? nil : name. . .
→ def as_json(options={})
→ options[:methods] ||= [ :user_facing_identifier ]
→ options[:except] ||= [ :widget_status_id ]
→ options[:include] ||= [ :widget_status ]
→
→ super(options)
→ end
end
You could also only set default options if options is empty. Either way, adopt
one policy and follow that whenever you override as_json. I would also
recommend a test for this behavior. I do want to stress the point about
centralizing this in the model itself. This is, like many parts of Rails, a good
default. You can override this when needed, but a good default makes things
396
easier for everyone. It’s easier for the team to get right, easier for others
doing code review, and it matches the way Rails and Ruby actually are.
One last thing about JSON encoding is the use of top-level keys.
{
"id": 1234,
"name": "Stembolt",
"price_cents": 12345
}
There are two minor problems with this as the way your API renders JSON.
The first is that you cannot look at this JSON and know what it is without
knowing what produced it. That’s not a major issue, but when debugging
it’s really nice to have more explicit context if it’s not too much hassle to
provide.
The second problem is the potential need to include metadata like page
numbers, related links, or other stuff that’s particular to your app and not
something that should go into an HTTP header. In that case, you’d need to
merge the object’s keys and values with those of your metadata. This will
be confusing and potentially have conflicts.
A better solution is to include a top-level key for the object that contains the
object’s data. Our code does that by rendering { widget: widget }, which
produces this:
{
"widget": {
"id": 1234,
"name": "Stembolt",
"price_cents": 12345
397
}
}
Now, if you have this JSON you have a good idea what it is. If you also need
to include metadata, you can include that as a sibling to "widget": and
keep it separated.
The problem that this solution creates is that you have to remember to set
the top level key in your controllers.
I would not recommend doing this in as_json, because you wouldn’t do this
for an array. If you had an array of widgets, you’d want something like this:
{
"widgets": [
{
"id": 1234,
"name": "Stembolt",
"price_cents": 12345
},
{
"id": 2345,
"name": "Thrombic Modulator",
"price_cents": 9876
}
]
}
Just as you’d test a major user flow (discussed in “Understand the Value
and Cost of Tests” on page 181), you should test major flows around your
398
API. At the very least, each endpoint should have one test to make some
assertions about the format of the response. While inadvertent changes to a
UI can be annoying for users, such changes could be catastrophic for APIs.
A test can help prevent this.
Your test should also use the authentication mechanism and content nego-
tiation headers. Let’s write a complete set of tests for all this against our
widgets endpoint.
The tests of the API should be integration tests, which means they
should be in test/integration. To keep them separated from any
normal integration tests we might write, we’ll use the same name-
spaces we used for the routes and controllers, and place our test in
test/integration/api/v1/widgets_test.rb.
# test/integration/api/v1/widgets_test.rb
require "test_helper"
We’ll need to insert an API key into the database, then perform a get passing
that key in the appropriate header, along with setting the Accept: header.
Here’s how that looks.
# test/integration/api/v1/widgets_test.rb
require "test_helper"
399
→ }
→
→ assert_response :success
→
→ parsed_response = JSON.parse(response.body)
→
→ refute_nil parsed_response["widget"]
→
→ assert_equal widget.name, parsed_response.dig("widget",
→ "name")
→ assert_equal widget.price_cents,
→ parsed_response.dig("widget", "price_cents")
→ assert_equal widget.user_facing_identifier,
→ parsed_response.dig("widget",
→ "user_facing_identifier")
→ assert_equal widget.widget_status.name,
→ parsed_response.dig("widget",
→ "widget_status",
→ "name")
→ end
end
Whew! One thing to note is that we aren’t testing all the fields that would
be in the response as implemented. I would likely build this API by writing
this test first, and then implement as_json to match the output.
It also depends on how strict you want to be. For JSON endpoints consumed
by a JavaScript front-end in the app itself, it’s probably OK if the payload
has extra stuff in it. The more widely used the endpoint, the more beneficial
it is to have exactly and only what is needed. You need to consider the
carrying and opportunity costs to make sure you aren’t over-investing.
We also need four more tests:
We could put them in the existing widgets_test.rb, but this would imply
that each endpoint would require these four tests of what is essentially
configuration inside ApiController. Let’s instead create two more tests, one
for authentication and one for content negotiation.
First, let’s create test/integration/api/content_negotiation_test.rb:
400
# test/integration/api/content_negotiation_test.rb
require "test_helper.rb"
widget = FactoryBot.create(:widget)
get api_v1_widget_path(widget),
headers: {
"Accept" => "text/plain",
"Authorization" => authorization
}
assert_response 406
end
test "missing Accept header gets a 406" do
api_key = FactoryBot.create(:api_key)
authorization = ActionController::
HttpAuthentication::
Token.encode_credentials(api_key.key)
widget = FactoryBot.create(:widget)
get api_v1_widget_path(widget),
headers: {
"Authorization" => authorization
}
assert_response 406
end
end
If we end up with more nuanced content negotiation, tests for it can go here.
Next, we’ll test authentication in api/authentication_test.rb:
# test/integration/api/authentication_test.rb
401
require "test_helper.rb"
get api_v1_widget_path(widget),
headers: {
"Accept" => "application/json",
}
assert_response 401
end
widget = FactoryBot.create(:widget)
get api_v1_widget_path(widget),
headers: {
"Accept" => "application/json",
"Authorization" => authorization
}
assert_response 401
end
widget = FactoryBot.create(:widget)
get api_v1_widget_path(widget),
headers: {
"Accept" => "application/json",
"Authorization" => authorization
}
assert_response 401
end
402
end
# Running:
......
One issue that will come up if we add more API endpoints is duplication
around setting up an API key and setting all the headers when calling
the API from a test. As I’ve suggested in several other places, watch for a
pattern and extract some better tooling. It’s likely you’ll want a base ApiTest
that extends ActionDispatch::IntegrationTest that all your API tests then
extend, but don’t get too eager making abstractions until you see the need.
Up Next
Next, we’ll move even farther outside your Rails app to talk about some
workflows and techniques to help with sustainability, such as continuous
integration and generators.
403
23
The techniques here are some I’ve used in earnest on both small and large
teams and they should provide you value as well. Of course, there are many
other techniques, workflows, and processes to make your team productive
and development sustainable. Hopefully, learning about these processes can
inspire you to prioritize team and process sustainability.
Let’s start off with one that you might already be doing: continuous integra-
tion.
The risks mitigated by tests only happen if we are paying attention to our
tests and fixing the code that’s broken. Similarly, the checks we put into
bin/ci for vulnerabilities in dependent libraries and analysis of the code we
wrote only provide value if we do something about them.
The best way to do all that is to use a system for deployment that won’t
deploy code if any of our quality checks are failing. This creates a virtuous
cycle of incentives for us developers. We want our code in production doing
what it was meant to do. If the only way to do that is to make sure the tests
are passing and there are no obvious security vulnerabilities, we’ll address
that.
The most common way to set all this up is to set up continuous integration,
or CI.
405
23.1.1 What is CI?
The conventional meaning of CI is a system that runs all tests and checks of
every branch pushed to a central repository1 . When the tests and checks pass
on some designated main branch, that branch is deployed to production.
This enables a common workflow as outlined in the figure “Basic CI Work-
flow” on the next page. This workflow allows developers to create branches
with proposed changes and have bin/ci execute on the CI server to make
sure all tests and checks pass. The team can do code reviews as necessary.
When both bin/ci and code reviews are good, the change can be merged
onto the main branch for deployment. bin/ci is run yet again to make sure
the merged codebase passes all tests and checks and, if it does, the change
is deployed to production.
This is a sustainable workflow, and I daresay it’s not terribly new or contro-
versial. What I want to talk about is how to make sure this process continues
to be sustainable.
main trunk of development to avoid too many diversions and conflicts within the code. The
phrase “continuous integration” has somewhat lost this original meaning, with some teams
using the term trunk-based development instead. When I talk about CI, I’m talking about using
a central repository to run tests and deploy. This is the value I’m discussing. I can’t speak to
trunk-based development as I’ve never done that in a team-based environment.
406
Figure 23.1: Basic CI Workflow 407
about this up front as it can feel daunting. But explicit configuration is
sustainable.
CI is something you don’t want to have to constantly manage, so it makes
sense to spend as much time as you need up front creating a sustainable,
explicit configuration. The reason is that the configuration inevitably breaks,
meaning your app is working properly, but you can’t prove it on CI because
of a problem with the CI configuration itself.
When this happens, one or more developers will have to debug the configu-
ration. If that configuration is verbose, clear, explicit, and well-documented,
developers can quickly get up to speed on learning what might be a com-
pletely new set of tools for the first time.
Said another way, an explicit configuration means that more team members
will be able to modify it when needed, and this contributes to an overall
cultural value that maintaining this configuration is important. Make it clear
to the team that this configuration, since it is the automation for production
deploys, is just as critical as any feature of the app. Any work needed around
CI should be prioritized and completed quickly.
A great way to address all of this is to use your development environment
scripts in bin/ as part of the CI configuration.
408
development environment works, so you can manage this by creating
.env.development.local and .env.test.local in bin/setup. To detect if
your script is running locally or on the CI server, most CI servers set an
environment variable called CI. We’ll assume that is the case here.
Here’s an example of how to make bin/setup work on both local develop-
ment and on the CI server:
# bin/setup
require "optparse"
def setup
→ if ENV["CI"] == "true"
→ log "Running in CI environment"
→
→ log "Creating .env.development.local"
→ File.open(".env.development.local","w") do |file|
→ file.puts "REDIS_URL=redis://ci-redis:3456/1"
→ end
→
→ log "Creating .env.test.local"
→ File.open(".env.test.local","w") do |file|
→ file.puts "REDIS_URL=redis://ci-redis:3456/2"
→ end
→ elsif ENV["CI"] != nil
→ # Detect if what we believe to be true about the CI env var
→ # is, in fact, still the case.
→ fail "Problem: CI is set to #{ENV['CI']}, but we expect " +
→ "either 'true' or nil"
→ else
→ log "Assuming we are running in a local development environment"
→ end
log "Installing gems"
# Only do bundle install if the much-faster
# bundle check indicates we need to
Because you’ve configured your app with environment variables, this tech-
nique can handle most needs to customize behavior in CI. That said, you
are going to be much better off if you can directly configure CI to use your
settings.
If changing the environment doesn’t fix an issue with inconsistent behavior,
you can always use the environment variable check in bin/setup to do
409
further customizations. Be careful with this as it means that any code you
aren’t running in CI won’t get executed frequently.
Another issue with CI that can happen as your app ages is that the test
suite becomes longer and it takes longer to do deploys. Throughput is a key
metric for many teams that illustrates how effective they are in delivering
value. In times of stress, teams can “solve” this problem by disabling tests in
CI or simply skipping tests entirely. This will absolutely destroy team morale
over time and lead to lower productivity. It can be extremely hard to recover
from. Never do this.
You can certainly try to make your tests faster, but this can be time consuming
and not terribly fruitful. Most CI services allow you to split your tests and
checks and run them in parallel. One way to do this is to run system tests—
which are typically quite slow—in parallel to your other tests. In our app,
we might want to run system tests and unit tests in parallel and, in a third
workstream, run our JS tests followed by all the security audits (Brakeman
and bundle audit).
To do that without duplicating any code, we could break up our bin/ci
script into sub-scripts. For example, bin/ci might look like this:
##!/usr/bin/env bash
set -e
bin/unit-tests
bin/system-tests
bin/security-audits
Each of these new scripts would contain the commands previously in bin/ci:
set -e
set -e
410
echo "[ bin/ci ] Running system tests"
bin/rails test:system
set -e
411
Figure 23.2: Parallel Testing With Scripts
team driving customer value directly than spending over a year upgrading a
piece of technology. While the team did a lot of hard and amazing work, the
decisions that lead to needing that work at all weren’t made in the interest
of sustainability.
One way to avoid this is to update dependencies frequently and try to stay
up-to-date.
412
this once, and it required rewriting a gem we used from scratch because it
had not been updated for the version of Rails we had to upgrade to.
Being on the latest version of your tools has many other benefits. Potential
team members are much more excited to use the latest versions of tools
than have to deal with out-of-date versions. If you have a security team,
their job becomes much easier and you’ll have a much better relationship
with them. And, of course, you get access to new features of the tools you
are using relatively quickly.
The hardest part of this process is managing it as the size of the team grows.
The reason is that it’s hard to put incentives in place to prevent teams from
skipping these updates. Part of this is because the updates—and fixes they
often require—aren’t free and aren’t always enjoyable work. There’s not a
natural short-term incentive for engineers to do this or for their managers
to prioritize it (which is why having it as part of the culture can help).
You can ensconce this cultural value in your tools. Depending on the so-
phistication of your deployment toolchain, you can bake minimum required
versions into it. For example, at Stitch Fix, our deployment tools would not
work with any version of Ruby other than the most recent two versions. If
you fell behind on updates, you couldn’t deploy. It’s not the most pleasant
motivator, but it did work.
Outside of this, it really is a cultural value you have to bake into the team.
Frequently explaining the need for it helps. Empathizing with how unpleas-
ant it can be helps, too, and equitably rotating who’s responsible each month
can create some camaraderie on the team while avoiding the work always
falling to the same person.
To help codify this value, you should create a basic versioning policy. Here
is one that I recommend and that will serve you well.
• Use only the latest two minor versions of Ruby. Each December, when
Ruby is updated, schedule time in January to update any apps on
what is the third most-recent version. For example, in December of
2021, Ruby 3.1 was released, and so all apps using 2.7 would’ve been
updated to at least 3.0.
• Use this exact same policy for Rails. All apps should be on the latest or
second-latest version. Rails releases are less regular, but teams should
budget some time each year to doing an upgrade of a minor version
of Rails.
413
• Use this exact same policy for NodeJS, if you are using it.
• In your Gemfile, specify a pessimistic version constraint for Rails to
keep it on the current minor version. Running a bundle update and
getting a new minor version of Rails is not a great surprise. You want
to control when the Rails version is updated.
• For as many other dependencies as you can, set no version constraint
whatsoever. Let Bundler sort out the version that goes with your
version of Rails.
• Note that if you are using NodeJS, there may be dependencies between
some gems and some modules in package.json. Because JSON does
not allow for comments, write comments in Gemfile that indicate
dependencies between gems and Node modules.
• For any gem you must pin to a particular version, write a code com-
ment in the Gemfile about why you have done this, and under what
circumstances you should remove the pin. Don’t let Agile Thought
Leaders tell you that comments are bad. Write a novel if you have to
to explain what’s going on and how to tell if the reason for pinning
the version still exists.
Once you have your policy, and you’ve set expectations with teams to do
updates, there’s just no getting around the difficulty of doing the actual
updates and fixing whatever the break. You can make the process a bit
easier by providing some automation.
414
issue and could unpin them. The script will then run bin/ci to see if the
updates have broken anything.
# bin/update
#!/bin/sh
set -e
Let’s run it. I’m going to include the massive output for this run so you can
see what it looks like. All the tools that are brought together create a real
hodge-podge of messy output. bundle outdated will say something like “No
vulnerabilities detected” to indicate success. There may also be some odd
git-related messages due to how I’m running this for the book. You may not
see those, but if you do, they can be ignored.
> bin/update
[ bin/update ] Updating Ruby gems
415
Fetching gem metadata from https://rubygems.org/..........
Resolving dependencies...
Bundle updated!
[ bin/update ] Checking for outdated gems
Fetching gem metadata from https://rubygems.org/..........
Resolving dependencies...
Bundle up to date!
[ bin/update ] If anything is outdated, you may have
[ bin/update ] overly conservative versions pinned
[ bin/update ] in your Gemfile
[ bin/update ] You should remove these pins if possible
[ bin/update ] and see if the app works with the
[ bin/update ] latest versions
[ bin/update ] Running bin/ci
[ bin/ci ] Running unit tests
Running 25 tests in a single process (parallelization thresh. . .
Run options: --seed 40731
# Running:
# Running:
416
Updating ruby-advisory-db ...
From https://github.com/rubysec/ruby-advisory-db
* branch master -> FETCH_HEAD
Already up to date.
Updated ruby-advisory-db
ruby-advisory-db:
advisories: 827 advisories
last updated: 2023-11-30 12:36:04 -0800
commit: d821bf162550302abd1fa1fe15007f3012b76f32
No vulnerabilities found
[ bin/ci ] Done
In addition to shell scripts that automate common tasks, there are some
other techniques around automation that I want to talk about next. The first
is using templates and generators to create boilerplate code.
• Creating new files in your Rails app, like we did with View Components
• Creating RubyGems to manage shared code across apps
• Creating entirely new Rails apps
417
API, which is based on Thor4 . The API used by generators is based around
searching and replacing strings in files, either by regular expression or exact
matches.
For example, you might write code like so to add a require statement
to config/routes.rb. This code says to search the file config/routes.rb
for the string "Rails.application.routes.draw do" and insert the string
"require \"sidekiq/web\"\n\n" before it.
insert_into_file "config/routes.rb",
"require \"sidekiq/web\"\n\n",
before: "Rails.application.routes.draw do"
The problem is if the string isn’t found in the file, Thor does not consider
this an error. The generator will not report any problem and continue with
its operation, leaving you with the impression that the generator worked
when, in reality, it absolutely did not.
You can monkey-patch Thor to get around this issue, and if you make heavy
use of generators, I suggest you do this. You can add this code anywhere
before your generators run:
require "thor"
class Thor::Actions::InjectIntoFile
protected
418
File.open(destination, "wb") { |file| file.write(content) }
end
end
end
module Thor::Actions
# Copied from lib/thor/actions/file_manipulation.rb
def gsub_file(path, flag, *args, &block)
return unless behavior == :invoke
config = args.last.is_a?(Hash) ? args.pop : {}
unless options[:pretend]
content = File.binread(path)
# BEGIN CHANGE
result = content.gsub!(flag, *args, &block)
if result.nil?
raise "Regexp didn't match #{flag}:\n#{content}"
end
# END CHANGE
# ORIGINAL CODE: content.gsub!(flag, *args, &block)
File.open(path, "wb") { |file| file.write(content) }
end
end
end
With this change, any time an attempt to replace code in a file using a
regular expression fails, Thor will raise an error instead of doing nothing.
Despite this issue, generators are superior to documentation, since the
execute your architectural and design decisions.
For Ruby Gems or entirely new Rails apps, you could also use generators,
but I would recommend template repositories instead.
419
While bundle gem has improved over the years, and does provide some
flexibility, you’ll still need to document which command line flags your team
should use and this tends to eliminate many of the gains you get from code
generation.
Rails provides “app templates” that work similarly to create a new Rails
app. You can give rails the --template flag that will expect to contain
many calls to Thor’s API to create a Rails app. This suffers all the problems
of using Thor we discussed above, but is exceedingly hard to test, with
behavior that’s hard to predict and control.
The solution to both of these issues is to use template repositories. This
means that you’d use bundle gem or rails new --template to create an
example gem or app, then manually tweak it how you like it. When a
developer needs to create a new gem or Rails app, they clone that template
as a starting point.
Template repositories aren’t an amazing solution, but they offer you more
predictability and control than using bundle gem or Rails app templates.
At Stitch Fix, we used a Rails app template for all Rails apps and it was
perpetually in a state of being only kindof working. A template repository
would’ve been easier to use and maintain.
This section is a bit of a warning, but any automation is better than docu-
mentation. Documentation gets out of date quickly and can be extremely
hard to follow, even for the most conscientious developer.
Speaking of Ruby Gems and Rails apps, if you do end up using multiple
Rails apps (which we’ll discuss in more detail in “Monoliths, Microservices,
and Shared Databases” on page 453), it will be advantageous to share
configuration across those apps. You can do this via RubyGems and Railties.
420
for customizing Rails’ initialization procedure. By putting a Railtie inside a
Ruby gem, we can automatically insert configuration into any Rails app that
bundles that gem.
Let’s see how it works by creating an exception-handling gem that configures
and sets up Bugsnag, a common exception-handling service. Exception-
handling services like Bugsnag receive reports about any exception that your
app doesn’t explicitly handle. These reports can alert an on-call engineer to
investigate what could be a problem with the app (Airbrake and Rollbar are
two other examples you may have heard of).
This example is going to be a bit contrived, because we only have one Rails
app in our running example, and in the real world you would configure
Bugsnag in the one and only app you have. But, to demonstrate the point,
we’ll imagine that we have several Rails apps that all use Bugsnag and that
we want to have a common configuration.
First, let’s see what this configuration is that we want to share. Let’s suppose
in our case, we want to configure:
Without using our to-be-implemented gem that uses Railties, the config-
uration would live in config/initializers/bugsnag.rb and look like so
(assuming we are hosted on Heroku):
## config/initializers/bugsnag.rb
Bugsnag.configure do |config|
config.api_key = ENV.fetch("BUGSNAG_API_KEY")
config.app_version = ENV.fetch("HEROKU_RELEASE_VERSION")
config.notify_release_stages = ["production"]
This is the configuration we want to share. Don’t worry too much if you
don’t know what’s going on here. The point is that we don’t want each
application to have to duplicate this information or, worse, do something
different. See the sidebar “Every Environment Variable is Precious” below
for an example of what happens if you don’t manage environment variable
names.
421
Every Environment Variable Name is Precious
At Stitch Fix, there was a point where the team was around 50 developers
and we had around 30 Rails apps in production as part of a microservices
architecture. We had a gem that was used for consuming microservices, but
the gem failed to bake in a convention about how to name the environment
variable that held the API key.
The result was that some apps would use SHIPPING_SERVICE_PASSWORD,
some SHIPPING_API_KEY, some SHIPPING_SERVICE_KEY, and others
SHIP_SVC_APIKEY. It was a mess. But, microservices did allow this mess to
not affect the team as a whole. Until we needed to rotate all of these keys.
A third party we used had a major security breach and there was a
possibility that our keys could’ve been leaked. Rather than wait around
to find out, we decided to rotate every single internal API key. If the
environment variables for these keys were all the same, it would’ve taken a
single engineer a few hours to write a script to do the rotation.
Instead, it took six engineers an entire week to first make the variables
consistent and then do the rotation. According to Glassdoor, an entry-level
software engineer makes $75,000 a year, which meant this inconsistency
cost us at least $9,000. The six engineers that did this were not entry-level,
so you can imagine the true cost.
Inconsistency is not a good thing. The consistency we paid for that week
did, at least, have a wonderful return when we had to tighten our security
posture before going public. The platform team was able to leverage our
new-found consistent variable names to script a daily key rotation of all keys
in less time and fewer engineers than it took to make the variable names
consistent.
I’m not going to show all the steps for making a Ruby gem, but let’s look at
the gemspec we would have, as well as the main source code for the gem to
see how it fits together.
First we have the gemspec, which brings in the Bugsnag gem:
## example_com_bugsnag.gemspec
## NOTE: this file is not in a rails app!
422
# This assumes you are using Git for version control
s.files = `git ls-files`.split("\n")
s.test_files =
`git ls-files -- {test,spec,features}/*`.split("\n")
s.require_paths = ["lib"]
s.add_dependency("bugsnag")
end
Since we used add_dependency for the Bugsnag gem, that means when an
app installs this gem, the Bugsnag gem will be brought in as a transitive
dependency. In a sense, this gem we are creating owns the relationship
between our apps and Bugsnag—our apps don’t own that relationship
directly.
What we want is to have the above configuration executed automatically
just by including the example_com_bugsnag gem. We can do this using two
different behaviors of a Rails codebase. The first is Bundler, which will
auto-require files for us.
When we put this into our Gemfile:
## Gemfile
gem "example_com_bugsnag"
## config/application.rb
Bundler.require(*Rails.groups)
423
If we put the following code in lib/example_com_bugsnag.rb, it will tell
Rails to run this code as if it were in config/initailizers.rb:
## lib/example_com_bugsnag.rb
class ExampleComBugsnag < Rails::Railtie
initializer "example_com_bugsnag" do |app|
Bugsnag.configure do |config|
config.api_key = ENV.fetch("BUGSNAG_API_KEY")
config.app_version = ENV.fetch("HEROKU_RELEASE_VERSION")
config.notify_release_stages = ["production"]
This will register the block of code passed to initializer with Rails and,
whenever Rails loads the files in config/initializers, it will also execute
this block of code, thus configuring Bugsnag. This means that with a
single line of code in the Gemfile, any Rails app will have the canonical
configuration for using Bugsnag.
And, if this configuration should ever change, you can change it, release
a new version of the gem, and then, because teams are doing frequent
dependency updates as discussed on page 411, the configuration update
will naturally be applied to each app as the team does their updates.
This technique allows you to centralize a lot of configuration options across
many apps without complex infrastructure and without a lot of documenta-
tion or other manual work. We used this technique at Stitch Fix to manage
shared configuration for over 50 different Rails apps, including rolling out a
highly critical database connection update in a matter of hours.
Up Next
There are likely many more workflows and techniques for sustainable devel-
opment than the ones I’ve shared here. While these specific techniques do
work well, your team should explicitly prioritize looking for new techniques
and workflows to automate. The opportunity cost of creating shared gems,
scripts, or other automation can really reduce carrying costs over time. It’s
a worthwhile investment.
The next chapter will be about considerations for actually operating your
app in production, namely how to consider things like monitoring, logging,
and secrets management.
424
24
Operations
I’ve alluded to the notion that code in production is what’s important, but
I want to say that explicitly right now: if your code is not in production it
creates a carrying cost with nothing to offset it—an unsustainable situation.
However, being responsible for code running in production is a much dif-
ferent proposition than writing code whose tests pass and that you can use
in your development environment. Seeing your code actually solve real
users’ problems and actually provide the value it’s meant to provide can be
a sometimes harrowing learning experience about what it means to develop
software. Of course, it’s also extremely rewarding.
That’s what this chapter is about. Well, it’s really a paltry overview of what
is a deep topic, but it should give you some areas to think about and dig
deeper into, along with a few basic tips for getting started.
Like may aspects of software development, production operations is a matter
of a people and priorities: do you have the right people given the right
priorities to make sure the app is operating in production in all the ways you
need? For a small team just starting out, the answer is “no”. Surprisingly,
for larger teams, the answer might still be still “no”! I can’t help you solve
that.
What I’m going to try to help with in this chapter is understanding what
aspects are important and what techniques are simplest or cheapest to do to
get started. These techniques—like logging and exception management—
will still be needed on even the most sophisticated team, so they’ll serve you
well no matter what.
As context, production operations should be driven by observability, which
is your ability to understand the behavior of the system
425
The term observability (as it applies to this conversation) originates in control
theory, as explained in the Wikipedia entry1 :
Based on this definition, what I’m saying about JavaScript is that it’s hard to
understand what it actually did or is doing based just on what information
gets sent back to our server (or can be examined in our browser). Even
for backend code, it’s not clear how to do this. Can you really look at your
database and figure out how it got into that state?
Charity Majors has been largely responsible for applying the term “observ-
ability” to software development and I highly suggest reading in detail
how she defines observability in software2 . Her definition sets a very high
bar that few teams—even highly sophisticated ones—operate the way she
defines it. That’s OK. As long as you start somewhere and keep improving,
you’ll get value out of your operations efforts.
The way I might summarize observability, such that it can drive our decision-
making, is that observability is the degree to which you can explain what the
software did in production and why it did that. For example, in “Understand
What Happens When a Job Fails” on page 330, we discussed the notion of
background jobs being automatically retried when they fail. If you notice an
hourly job has not updated the database, how will you know if that job is
going to be retried or simply failed?
The more aspects of the system you can directly examine and confirm, the
more observable your system is, and this applies from low levels such as job
control to high levels such as user transactions and business metrics. The
more you can observe about your app’s behavior, the better.
The reason is that if there is a problem (even if it’s not with your app),
someone will notice and eventually come calling wanting an explanation.
From “the website is slow” to “sales are down 5% this month”, problems
will get noticed and, even if your app is running perfectly, you need to be
able to actually know that.
For example, if the marketing team sees a dip in signups, and you can
say, with certainty, that every single sign up attempt in the last month was
successful, that helps marketing know where to look to explain the problem.
If, on the other hand, you have no idea if your sign up code is working at
all, you now have to go through the process of trying to prove it has been
working. . . or not!
What all this says to me is that production operations and the ability to
observe your app in production is as important—if not more important—
than test coverage, perfect software architecture, or good database design.
1 https://en.wikipedia.org/wiki/Observability
2 https://charity.wtf/2020/03/03/observability-is-a-many-splendored-thing/
426
If you have done the best job anyone could ever do at those things yet be
unable to explain the app’s behavior in production, you are in a very bad
place.
Remember, techniques like software design, testing, and observability are
tools to reduce risk. A lack of observability carries a great risk, just like
shipping untested code to production does.
Fortunately, there are a few low-cost, low-effort techniques that can provide
a lot of observability for you that just about any engineer on your team can
understand and apply. Before we talk about them, we need to understand
what we need to monitor to know if the app is experiencing a problem.
What we need to monitor is not usually technical. Instead, we want to
monitor business outcomes.
427
you then will need to know how the parts of the system behave (or behaved)
in order to explain why business outcomes aren’t being achieved.
What all this means is that your perfectly crafted, beautiful, elegant,
programmer-happy codebase is going to become littered with droppings
to allow you to properly monitor your app in production. Ruby and Rails
allow you to manage this sort of code in a mostly clean3 way, but there’s no
avoiding it entirely.
Fortunately, there are a few cheap and easy techniques that can get you
pretty far. The first one is the venerable Rails logger.
Way back at the start of the book, in “Improving Production Logging with
lograge” on page 52, we set up lograge to change the format of our logs. The
reason is that almost every tool for examining logs assumes one message
per line, and that’s not how Rails logs by default.
Often, when there is a problem in production that no one can explain, the
solution is to add more logging, deploy the app, and wait for the problem to
happen again so you can get more data. This might be rudimentary, but it’s
still powerful!
3 I struggled with what word to use here, because to many, “clean code” is some moralistic
nonsense proselytized by members of the agile software community. That is not what I mean
here. What I mean is that when code contains only what it needs to function, it’s clean—free
of dirt, marks, or stains. When we add log statements, metrics tracing, or performance spans,
we add code that’s not needed to make the app work and it gunks up our code. Thus, it’s a bit
dirtier than before. Nothing moral about it.
428
That said, not all log messages are equally effective, so you want to make
sure that you and your team are writing good log messages. Consider this
code:
## app/services/widget_creator.rb
class WidgetCreator
def create_widget(widget)
widget.widget_status =
WidgetStatus.find_by!(name: "Fresh")
widget.save
if widget.invalid?
return Result.new(created: false, widget: widget)
end
→ Rails.logger.info "Saved #{widget.id}"
The code might look obvious, but the log message will look like so:
If you came across this log statement, you would have no idea what was
saved. If you were searching for confirmation that widget 1234 was saved,
could you be absolutely certain that this log message confirmed that? What
if the code to save manufacturers used a similar log message?
Consider the two primary use-cases of logs.
• Search the logs to figure out what happened during a certain request
or operation.
• Figure out what code produced a log message you noticed but weren’t
searching for.
There are four techniques you should apply to your log messages to make
these two use-cases easy:
429
24.3.1 Include a Request ID in All Logs
Many hosting providers or web servers generate a unique value for each
request and set that value in the HTTP header X-Request-Id. If that happens,
Rails can provide you with that value. Each controller in a Rails app exposes
the method request, which provides access to the HTTP headers. Even
better, you can call the method request_id on request to get the value
of the X-Request-Id header or, if there is no value, have Rails generate a
unique request ID for you.
If you include this value in all your log statements, you can use the request
ID to correlate all activity around a given request. For example, if you see
that widget 1234 was saved as part of request ID 1caebeaf, you can search
the log for that request ID and see all log statements from all code called as
part of saving widget 1234. This is extremely powerful!
The problem is that Rails doesn’t automatically include this value when
you call Rails.logger.info. The default logging from Rails controllers does
include this value, but lograge removes it, for whatever reason. Let’s add
that back and then discuss how to include the request ID in log messages
that aren’t written from your controllers.
First, we’ll modify ApplicationController to include the request ID in
a hash that lograge will have access to. We can do that by overriding
the method append_info_to_payload, which Rails calls to allow inserting
custom information into a special object used for each request.
# app/controllers/application_controller.rb
# config/initializers/lograge.rb
else
config.lograge.enabled = false
end
→ config.lograge.custom_options = lambda do |event|
430
→ {
→ request_id: event.payload[:request_id]
→ }
→ end
end
With this in place, all logs originating from the controller layer will include
this request ID. You can fire up the app yourself and try it out. Don’t forget
to use LOGRAGE_IN_DEVELOPMENT, as instructed by bin/setup help.
Logging from anywhere else in the app won’t have access to this value. This
is because the request is not available to, for example, your service layer
or Active Records. To make it available, we’ll use a feature of Rails called
current attributes. This is wrapper around thread local storage, which is an
in-memory hash that can store data global to the current thread (but, unlike
a true global variable, isolated from other threads).
To use current attributes, you define a class, usually in app/models, that
extends ActiveSupport::CurrentAttributes. We’ll follow the Rails API
docs4 and call it Current.
# app/models/current.rb
# app/controllers/application_controller.rb
431
→
def append_info_to_payload(payload)
super
payload[:request_id] = request.request_id
To put this in our logs is. . . a bit complicated. There is not a handy gem to
do this that I have found, and the Rails logger is not sophisticated enough
to allow some configuration to be set that automatically includes it. Instead,
let’s create a small wrapper around Rails.logger that our code will use.
This wrapper will assemble a log message by accessing Current to get the
request ID and prepending it to our actual log message.
It works like so:
First, we’ll create a module in lib that will wrap calls to Rails.logger.info
and fetch the request ID:
# lib/logging/logs.rb
module Logging
module Logs
def log(message)
request_id = Current.request_id
Rails.logger.info("request_id:#{request_id} #{message}")
end
end
end
Because it’s in lib/, we have to require it explicitly, so, for example, in our
WidgetCreator:
# app/services/widget_creator.rb
→ require "logging/logs"
→
→ class WidgetCreator
432
→ include Logging::Logs
def create_widget(widget)
widget.widget_status =
WidgetStatus.find_by!(name: "Fresh")
# app/services/widget_creator.rb
end
# XXX
# XXX
→ log "Widget #{widget.id} is valid. Queueing jobs"
HighPricedWidgetCheckJob.perform_async(
widget.id, widget.price_cents)
WidgetFromNewManufacturerCheckJob.perform_async(
If you fire up your app now and create a widget, you should see that the
Rails controller logs include a request id, but that same ID is prepended to
the log message you just added.
That you have to go through these hoops isn’t ideal. Rails logging is a pretty
big mess and I have not found a good solution. At Stitch Fix we had a
custom logging system that handled this, but it was highly dependent on
undocumented Rails internals and tended to break with each new version
of Rails. It was also extremely difficult for most developers to understand
and modify, so it created a carrying cost that I wouldn’t incur again.
To make it easy to use this new module in our non-controller code, we could
include it in ApplicationModel, ApplicationJob, and other base classes.
We might even create ApplicationService for our service-layer classes to
extend and include this module there. Once we start using it ubiquitously,
we can get the end-to-end request tracing discussed above.
Of course, if you are looking at logs but don’t have a request ID, you will
often want to know what code produced the log message you are seeing.
Further, if a log message references a specific object or database row, you
need more than just an ID to know what it means.
433
where the log message originated so you can dial into what code was acting
on what piece of data.
It would be nice if you could get this for free by calling inspect and having
the Rails logger figure out what class called the log method:
Unfortunately, this doesn’t work the way we want. First, deriving the class
name of the caller isn’t a feature of the logger. Second, calling inspect on an
Active Record will output all of its internal values. This can be overwhelming
when trying to debug, and can expose potentially sensitive data to the log.
Most of the time, you really just need the class name and its ID.
You could have the team try to remember to include all this context, like so:
The team will not remember to do this consistently and it will be tedious to
try to manage with code review.
Instead, let’s enhance our abstraction that wraps the Rails logger. We can
make it more useful by printing out the class name it was included into as
well as accepting an optional argument of a record as context.
Let’s modify Logging::Logs so that log accepts either one or two parameters.
If we pass one, it behaves like it currently does—prepending the request
ID to the parameter, which is assumed to be a message. If we pass two
parameters, we’ll assume the first is some object whose class and ID we
want to include in the message and the second parameter is the message.
Further, because Logging::Logs is a module, we can include the class name
of whatever class is including it in the log message as well.
This means that code like this:
434
request_id: 1caebeaf [WidgetCreator] (Widget/1234) updated
Here’s how we can do that. First, we’ll allow two parameters to log:
# lib/logging/logs.rb
module Logging
module Logs
→ def log(message_or_object,message=nil)
request_id = Current.request_id
Rails.logger.info("request_id:#{request_id} #{message}". . .
end
Next, we’ll create the log message with both the class name where Logs was
included as well as the class and ID of the message_or_object if message
is present. Note that we need to be a bit defensive around the type of
message_or_object in case it doesn’t respond to id. If it doesn’t, we’ll
include its class and its string representation.
# lib/logging/logs.rb
module Logs
def log(message_or_object,message=nil)
request_id = Current.request_id
→ message = if message.nil?
→ message_or_object
→ else
→ object = message_or_object
→ if object.respond_to?(:id)
→ "(#{object.class}/#{object.id} #{message}"
→ else
→ "(#{object.class}/#{object} #{message}"
→ end
→ end
→ Rails.logger.info("[#{self.class}] " \
→ "request_id:#{request_id} " \
→ "#{message}")
end
end
end
435
Now, developers can log a ton of context with not very much code. Granted,
they have to provide an object as context and remember to do that, but this
will be much easier to both remember and catch in a code review. Because
Ruby is such a dynamic language, you can do much more here to magically
include context without requiring it in the API.
If you like this approach, the log_method gem5 was extracted from this book
as well as several running codebases and provides even more useful logging
features from the same basic log method.
Another bit of context that can be extremely helpful—and sometimes re-
quired by company policy—is the user who is performing or initiating actions
in the app.
436
avoid creating confusion later when you have to diagnose a real failure. For
example, if you communicate with a third party API, you will certainly get a
handful of network timeouts. As mentioned in “Network Calls and Third
Parties are Flaky” on page 328, your jobs will retry themselves to recover
from these transient network errors. You don’t need to be alerted when this
happens.
Tracking unhandled exceptions isn’t something your Rails app can do on
its own. While the log will show exceptions and stacktraces, the log isn’t
a great mechanism for notifying you when exceptions occur, or allowing
you to analyze the exceptions that are happening over time. You need an
exception handling service.
There are many such services, such as Airbrake, Bugsnag, or Rollbar. They
are all more or less equivalent, though there are subtle differences that might
matter to you, so please do your research before choosing one (though the
only wrong choice is not to use one). Most of these services require adding
a RubyGem to your app, adding some configuration, and placing an API key
in the UNIX environment.
They tend to work by registering a Rails Middleware that catches all un-
handled exceptions and notifies the service with relevant information. This
information can be invaluable, since it can include browser user agents,
request parameters, request IDs, or custom metadata you provide. Often,
you can view a specific exception in the service you’ve configured, find the
request ID, then look at all the logs related to the request that lead to the
exception.
I can’t give specific guidance, since it will depend on the service you’ve
chosen, but here are some tips for getting the most out of your exception
handling service:
• Learn how the service you’ve chosen works. Learn how they intend
their service to be used and use it that way. While the various services
are all mostly the same, they differ in subtle ways, and if you try to
fight them, you won’t get a lot of value out of the service.
• Try very hard to not let the “inbox” of unhandled exceptions build
up. You want each new exception to be something you both notice
and take action on. This will require an initial period of tuning your
configuration and the service’s settings to get it right, but ideally
you want a situation where any new notification from the service is
actionable and important.
• If the service allows it, try to include additional metadata with un-
handled exceptions. Often, you can include the current user’s ID,
the request ID we discussed above, or other information that the
exception-handling service can show you to help figure out why the
exception happened.
• Intermittent exceptions are particularly annoying because you don’t
necessarily need to know about each one, but if there are “too many”,
437
you do. Consult your service’s documentation for how to best handle
this. You need to be very careful to not create alert fatigue by creating
a situation where you are alerted frequently by exceptions that you
can ignore.
Donald Knuth, Turing Award winner and author of the never-ending “Art
of Computer Programming” book series, is famous for this quote about
performance:
The real problem is that programmers have spent far too much time
worrying about efficiency in the wrong places and at the wrong times;
premature optimization is the root of all evil (or at least most of it) in
programming.
This is often quoted when developers modify code to perform better but
have not taken the necessary step of understanding the current performance
and demonstrating why the current level of performance is insufficient. This
implies that you must measure performance before you can improve it.
Measuring the performance of your app can also help direct any conversation
or complaint about the app being slow. This is because the cause of app
slowness is not always what you think, and if you aren’t measuring every
aspect of the apps’ behavior, you may end up optimizing the wrong parts
of the app without making it perform better. See the sidebar “The App is
Only as Fast as Wi-Fi” on the next page for an example of how performance
measurement can lead to the right area of focus.
You need to be careful not to over-measure at first, because the code you
must write to measure certain performance details has a carrying cost. For
example, here is how you would measure the performance of an arbitrary
block of code using Open Telemetry (which is a standard for application
performance monitoring supported by several vendors):
438
The App is Only as Fast as Wi-Fi
One of the apps we built at Stitch Fix—called S PECTRE—provided tools
for associates in our warehouse to do their jobs. This app wasn’t part
of stitchfix.com and was only used from specific physical locations with
Internet connections we controlled.
Over time, we’d get an increasing number of complaints that the app
was slow. We had set up New Relic, which allowed us to understand the
performance of every controller action in the app. Even the 95th percentile
performance was good, with the average performance being great.
Since we controlled the Internet connection to the warehouse, we were
able to access performance monitoring of the network in the warehouse
itself. While the connection to the warehouse was great—fast, tons of band-
width, tons of uptime—the computers connecting via wi-fi were experiencing
inconsistent performance.
It was these users that were experiencing slowness, and it was because
of the wi-fi network, not the app itself. Of course, to the users, the wi-fi
connection was part of the app, and it didn’t matter if the controllers were
returning results quickly.
We didn’t have the capital or expertise to update the network hardware
to provide consistent wi-fi performance throughout the warehouse, so we
modified the front-end of the feature that required wi-fi to not require as
much bandwidth, as described in “Single Feature JAM Stack Apps at Stitch
Fix” on page 166.
If we hadn’t been measuring the whole system’s performance, we
could’ve spent time creating caching or other performance improvements
that would’ve both created a carrying cost for the team and also not solved
the actual performance problem.
class WidgetCreator
def create_widget(widget)
→ OpenTelemetry.tracer_provider.
→ tracer('tracer').
→ in_span("WidgetCreator/create_widget/db_operations") do
widget.widget_status =
WidgetStatus.find_by!(name: "Fresh")
widget.save
if widget.invalid?
return Result.new(created: false, widget: widget)
end
→ end
HighPricedWidgetCheckJob.perform_async(
439
widget.id, widget.price_cents)
WidgetFromNewManufacturerCheckJob.perform_async(
widget.id, widget.manufacturer.created_at)
Result.new(created: widget.valid?, widget: widget)
end
end
At a larger scale, this sort of code can be mentally exhausting to write, read,
and manage.
Instead, choose a technique or tool that can automatically instrument parts
of your app. For example OpenTelemetry will automatically track and
measure the performance of every controller action, URL, and background
job without you having to write any code at all.
This default set of measurements gives you a baseline to help diagnose a
slow app. If the defaults don’t show you what is performing poorly, then you
can add code to measure different parts of your codebase.
If you need to add code to enable custom measurements, do so judiciously
and don’t be afraid to remove that code later if it isn’t needed or didn’t
provide the information you wanted. Look for patterns in how you write this
code and try to create conventions around it to allow the team to quickly
measure code blocks as needed.
Before we leave this chapter, I want to step back from observability and talk
about a more tactical issue which is how to manage secret values like API
keys.
440
company, the risks are low, so a low-cost solution will work. For a huge
public company, the calculus is different. Either way, you should constantly
re-evaluate your strategy to make sure it’s appropriate and the trade-offs
are correct.
Evaluating the trade-offs is critical. It might seem easy to install something
like Hashicorp’s Vault7 , which is highly secure and packed with useful
features. Operating Vault is another story. It’s extremely complicated and
time-consuming, especially for a team without the experience of operating
systems like Vault in production. A poorly-managed Vault installation will
be a far worse solution than storing your secrets in 1Password and manually
rotating them once a quarter.
Don’t be afraid to adopt a simple solution that your team can absolutely
manage, even if it’s not perfect (no solution will be, anyway). If someone
brings up an attack vector that’s possible with your proposed solution, quan-
tify the risk before you seriously consider mitigating that vector. Engineers
are great at imagining edge cases, but it’s the level of risk and likelihood
that matters most.
The End!
And that’s it! We’ve covered a lot of ground in this book. Each technique
we’ve discussed should provide value on its own, but hopefully you’ve come
to appreciate how these techniques can reinforce each other and build on
each other when used in combination.
I should also point out that, no matter how hard you try, you won’t be able
to hold onto each technique in this book—or any book—throughout the life
of your app. You’ll model something wrong, use the wrong name, miss a
tiny detail, or have an assumption invalidated by the business at just the
wrong time. Or, you’ll find that at some scale, the basic techniques here
don’t work and you have to do something fancier. It happens. That’s why
we tend to work iteratively.
The most sustainable way to build software is to embrace change, minimize
carrying costs, tame opportunity costs, and generally focus on problems you
have, treating your tools for what they are. Try not to predict the future, but
also don’t be blind to it.
7 https://www.vaultproject.io
441
PART
IV
appendices
A
All the code written in this book, and all commands executed, are run inside
a Docker container. Docker provides a virtual machine of sorts and allows
you to replicate, almost exactly, the environment in which I wrote the code
(see the sidebar “Why Docker?” on the next page). If you don’t know
anything about Docker, that’s OK. You should learn what you need to know
here.
While the main point of Docker is to create a consistent place for us to work,
it does require installing it on whatever computer you are using, and that is
highly dependent on what that computer is!
Rather than try to capture the specific instructions now, you should head to
the Docker Desktop page1 which should walk you through how to download,
install, and run Docker on your computer.
1 https://www.docker.com/products/docker-desktop
445
Why Docker?
I’m the co-author of Agile Web Development With Rails 6a and have
worked on two editions of that book. Each new revision usually wreaks
havoc with the part of the book that walks you through setting up your
development environment. Between Windows, macOS, and Linux, things
are different and they change frequently.
While a virtual machine like Virtual Boxb can address this issue, Docker
is a bit easier to set up, and I find it useful to understand how Docker works,
because more and more applications are deployed using Docker.
Docker also has an ecosystem of configurations for other services you
may need to run in development, such as Postgres or Redis. Using Docker to
do this is much simpler than trying to install such software on your personal
computer.
a https://pragprog.com/book/rails6/agile-web-development-with-rails-6
b https://www.virtualbox.org
Image A Docker image can be thought of as the computer you might boot.
It’s akin to a disk image, and is the set of bytes that has everything
you need to run a virtual computer. An image can be started or run
with docker start or docker run.
Host You’ll often see Docker documentation refer to “the host”. This is your
computer. Wherever you are running Docker, that is the host.
446
To tie all this together (as in the figure “Docker Concepts” below), a
Dockerfile is used to build an image, which is then started to become
a container running on your host.
There is a README there you can use as a reference, but here is how the
system works:
2 https://github.com/sustainable-rails/sustainable-rails-dev
447
1. You’ll build an image that contains the basic software you need to do
Ruby on Rails development. You’ll use this image in a later step to
create a container in which to work.
2. You’ll start up three container using Docker Compose: the container
using the image mentioned above, a container running Postgres, and
a container running Redis (which is used for Sidekiq).
3. You can use a script to execute commands inside your dev container.
The simplest command is bash, which will give the appearance of
having logged into your dev container.
> dx/build
[+] Building 70.8s (19/19) FINISHED
=> [internal] load build definition from Dockerfile.dx ...
=> => transferring dockerfile: 4.84kB ...
=> [internal] load .dockerignore ...
=> => transferring context: 58B ...
=> [internal] load metadata for docker.io/library/ruby:3.2 ...
=> CACHED [ 1/14] FROM docker.io/library/ruby:3.2 ...
=> [internal] load build context ...
=> => transferring context: 121B ...
=> [ 2/14] RUN apt-get update -yq && apt-get install -y ...
=> [ 3/14] RUN apt-get update -qy && apt-get install -qy lsb ...
=> [ 4/14] RUN sh -c 'echo "deb http://apt.postgresql.org/pu ...
=> [ 5/14] RUN apt-get -y install chromium chromium-driver ...
=> [ 6/14] RUN echo "gem: --no-document" >> ~/.gemrc && ...
=> [ 7/14] RUN apt-get update -q && apt-get install -qy ...
=> [ 8/14] COPY dx/show-help-in-app-container-then-wait.sh / ...
=> [ 9/14] RUN apt-get install -y openssh-server ...
=> [10/14] RUN mkdir /var/run/sshd && echo 'root:passwor ...
=> [11/14] RUN echo "# Set here from Dockerfile so that ssh' ...
=> [12/14] RUN mkdir -p /root/.ssh && chmod 755 /root/.ssh ...
=> [13/14] COPY authorized_keys /root/.ssh/ ...
=> [14/14] RUN chmod 644 ~/.ssh/authorized_keys ...
=> exporting to image
=> => exporting layers
448
=> => writing image sha256:21c9f171e3eaec00bae0d0f24d8fe73b7 ...
=> => naming to docker.io/davetron5000/sustainable-rails-dev ...
[ dx/build ] Your Docker image has been built tagged 'davetro ...
[ dx/build ] You can now run dx/start to start it up, though ...
Your output may be slightly different, but the final two messages prefixed
with [ dx/build ] should indicate that everything worked. You can also
verify this by running docker image ls like so:
> dx/exec ls -l
[ dx/exec ] Running 'ls -l' inside container with service name 'sust. . .
total 264
-rw-r--r-- 1 root root 4798 Oct 31 21:37 Dockerfile.dx
-rw-r--r-- 1 root root 5585 Nov 1 14:31 README.md
-rw-r--r-- 1 root root 230956 Nov 1 14:25 SocialImage.jpg
-rw-r--r-- 1 root root 1032 Oct 31 21:39 docker-compose.dx.yml
drwxr-xr-x 10 root root 320 Nov 1 17:03 dx
449
by virtue of a bind mount set up inside docker-compose.dx.yml (there is a
comment inside there that can provide more info).
This means that you can edit files locally, using whatever editor you like,
and anything you run via dx/exec will see those changes. For example, if
you added a new test in test/models/widgets_test.rb, you can run that
test like so:
This will run a test inside the container against the test file you changed.
You could also use bash as the command to run inside the container, which
would provide a persistent command-line to run commands without needing
dx/exec each time:
alias ls=exa
Let’s assume you have this in your home directory as the file .bashrc.
450
include a link to where you found the installation instructions as a comment
before the RUN directive.
In the case of exa, we’ll add this to the end of Dockerfile.dx
# Based on https://the.exa.website/install/linux
RUN apt-get install -qy exa
The -qy tells apt-get to answer “yes” to any question, and to reduce extra-
neous output.
However the files get there, they should be inside your dev environment.
You can then use COPY to get them into the image. Since you want to append
dotfiles/.bashrc to whatever’s there, we’ll copy the files somewhere, then
append them using a RUN directive. The WORKDIR directive creates and
changes to a directory inside the container, so we’ll use that to come up with
this addition to the Dockerfile.dx:
451
Because of the use of WORKDIR, the subsequent COPY and RUN directives
execute from /root/dotfiles.
Once you do this, run dx/build, hit Ctrl-C wherever you run dx/start,
re-run dx/start, and finally run dx/exec bash, then use ls to see that it’s
respecting your alias:
If you have any issues using these scripts, please reach out or open an issue
on the repo.
452
B
Monoliths, Microservices,
and Shared Databases
There wasn’t an easy way to put this into the book, but since we discussed
APIs in “API Endpoints” on page 377, there is an implicit assumption you
might have more than one Rails app someday, so I want to spend this
appendix talking about that briefly.
When a team is small, and you have only one app, whether you know it or
not, you have a monolithic architecture. A monolithic architecture has a lot
of advantages. Starting a new app this way has a very low opportunity cost,
and the carrying cost of a monolithic architecture is quite low for quite a
while.
The problems start when the team grows to an inflection point. It’s hard
to know what this point is, as it depends highly on the team members, the
scope of work, the change in the business and team, and what everyone
is working on. Most teams notice this inflection point months—sometimes
years—after they cross it. Even if you know the day you hit it, you still
have some decisions to make. Namely, do you carry on with a monolithic
architecture? If not, what are the alternatives and how do you implement
them?
In this section, I want to try to break down the opportunity and carrying
costs of:
453
So, I would strongly encourage you to understand monolithic architectures,
microservices, and shared databases as techniques to apply if the situation
calls for it. It’s also worth understanding that any discussion of what
a system’s architecture is has to be discussed in a context. It’s entirely
possible to have 100 developers working on 30 apps and, some of which
are monolithic. . . within a given context.
Let’s start with monolithic architectures.
454
Related, a monolith can present particular challenges staying up to date
and applying security updates, because the monolith is going to have a lot
of third-party dependencies. You will need to ensure that any updates all
work together and don’t create inter-related problems. This can be hard to
predict.
An oft-cited solution to these problems is to create a microservices architec-
ture. This trades some problems for new ones.
455
what if we made a widget data service that stored all data about a widget.
When our widget shipping team added its new status, that would have to
be added to the widget data service. These two services are now too tightly
coupled to be managed independently.
Second, you must have more sophisticated tooling to make all the services
run and operate. As we discussed in “Use the Simplest Authentication
System You Can” on page 381, your microservices need authentication. That
means something, somewhere, has to manage the API keys for each app to
talk to each other. That means that something somewhere has to know how
one app locates the other to make API calls.
This implies the need for more sophisticated monitoring. Suppose a cus-
tomer order page is not working. Suppose the reason is because of a failure
in the widget shipping service. Let’s suppose further that the website uses
an order service to render its view and that order service uses the widget
shipping service to get some data it needs to produce an order for the web-
site. This transitive chain of dependencies can be hard to understand when
diagnosing errors.
If you don’t have the ability to truly observe your microservices architecture,
your team will experience incident fatigue. This will become an exponen-
tially increasing carrying cost as time is wasted, morale lowers, and staff
turnover ensues.
You should almost never start with microservices on day one. But you should
be aware of the carrying costs of your monolith and consider a transition if
you believe they are getting too high. You need to think about an inflection
point at which your monolith is costlier to maintain than an equivalent
microservices architecture, as shown in the figure “Graph Showing the Costs
of a Monolith Versus Microservices Over Time” on the next page.
One way to address the problems of the monolith without incurring the
costs—at least initially—of microservices is to use a shared database.
456
Figure B.1: Graph Showing the Costs of a Monolith Versus Microservices
Over Time
457
Instead of putting both of these features in one app, and also instead of
extracting shared services to allow them to be developed independently, a
third strategy is to create a second system for customer support and have
it share the database with the website, as shown in the figure “Sharing a
Database” below.
As you discover more isolated needs, either from user groups needing their
own user interface or isolated system requirements, you can add more apps
and point them to the shared database as in the figure “Sharing a Database
with More Apps” on the next page.
The most immediate carrying cost with this approach is maintaining the
database migrations and the requisite Active Record models. Because of how
we are writing our code—not putting business logic in the Active Records—
these can be put into a gem that each app uses and that gem should not
change often.
See the figure “Managing the Shared Database” on the next page for how
this might look.
458
Figure B.4: Sharing a Database with More Apps
459
Sharing the database doesn’t abdicate your responsibility for managing code
across boundaries, but it does reduce what must be managed to the database
schema only. And since you are putting constraints and other data integrity
controls directly into the database (as outlined in “The Database” on page
213), you won’t have much risk of one app polluting the data needed by
other apps.
If you are careful with changes, the overall carrying costs of this architecture
can be quite low and can surpass a monolithic architecture, as shown in the
figure “Graph Showing the Costs of Sharing the Database” below.
Of course, this architecture will eventually cause problems. When you have
a lot of apps sharing a database, you can certainly cause contention and
locking that can be hard to predict or observe. That’s what happened in the
anecdote in the sidebar “A Single Line of Code Almost Took Us Down” on
the next page.
The database schema will eventually become difficult to manage, as you
end up with either tables that have too many concepts embedded in them or
a bunch of tables that exist only for the private use of a single app. It’s also
possible that you may need one app to trigger logic that lives in another app
and have no easy way to do so. You will likely need to do a microservices
transition.
If you use a shared database, however, you can significantly delay your
460
microservices transition—if you ever need one—and you can reduce the cost
of doing so because you will have already done a lot of work on identifying
domain boundaries.
Navigating the evolution of your architecture is difficult. The fact is, your
architecture is never done. There is no end state you should aim for and no
point at which you stop evolving. Evolution may slow at times, but it won’t
stop, and if your approach to architecture is to design it and build it, you
will fail. Instead, you need principles to guide you and competent technical
leadership.
461
C
Technical Leadership is
Critical
At times in this book I’ve referenced code reviews, or vague “managing” of
changes. Getting a team to work consistently, follow conventions, and also
respond to change is difficult. It requires leadership.
Leadership is a deep topic. A leader isn’t just in charge, and often great
leadership comes from people who don’t have any real authority over others.
The most effective leadership I have experienced is where leaders organize
everyone around shared values.
463
values and help them apply those values to this decision. Does the team
value consistency? If so, this decision does not conform to that value. What
if the team also values innovation? Using something new and exciting might
conform to that value.
By re-framing the discussion about the team’s shared values and how the
decision relates to them, the team can arrive at a decision that more or less
everyone agrees with. . . without being told what to do. The great thing
about this is that anyone on the team can show leadership by using this
framing. Anyone can say “we all value consistency, right? So doesn’t using
Que make our app less consistent?”.
There is still a reality about leadership and building software to consider,
which is that some people on the team are more accountable for the team’s
output than others.
464
from time to time. You could use phrases like “veto power” or “51% of the
vote” to communicate this concept, but the team must understand that if
they make a decision that is, in your judgement, not the right one, you may
decide to overrule them.
Of course, you should do this as infrequently as you can, as it removes
agency from the team. This makes you a less effective leader in the long
run.
To make matters more complicated, accountability isn’t always explicit.
465
Colophon
There’s a lot of technology involved in producing this book. But let’s start
where everyone that makes it to the colophon wants to start: fonts.
The cover is set in Helvetica Neue. Titles in the book are set in ITC Avant
Garde Gothic with the body text set in Charter. Diagrams use Rufscript and
Inconsolata. Inconsolata is also used to set all the code. The epub versions
largely ignore these fonts and I’m sorry. Beautifully typeset e-books on e-ink
screens are technically possible, but no one cares enough to make it happen.
The book was authored in a modified version of Markdown that executes
code samples, runs CLI commands, and takes screenshots as the files are
processed. It’s managed with a custom toolchain I created that you can read
about on my blog1 .
Most diagrams are created using Graphviz or Mermaid, though some were
created in Omnigraffle and Numbers. Screenshots were generated by a
custom JavaScript command line app that uses Puppeteer.
The cover was created in Pixelmator, based on a photo I took of the House of
Eyrabakki2 in Iceland, which is part of the Byggðasafn Árnesinga, a museum
in Eyrabakki. The photo was taken with an Olympus OM-1n 35mm camera,
using Ilford Delta 3200 film, developed by me, in my basement, at 2400 ISO
using Ilford chemicals. The back cover of the print versions is another shot
of the same house from the same roll of film. Both were lightly edited in
Adobe Lightroom.
All of this is tied together by Pandoc, which also produces the ePub version.
The print and PDF versions are produced via LaTeX. Good ole LaTeX. If
you want proper hyphenation and justification, there’s not really any other
option. I’m sure this book has a lot of overfull hboxes.
I would also be remiss in not pointing out that the entire toolchain is held
together by make, which I don’t think I could live without when trying to
do anything moderately complex. And, of course, all this runs in Docker,
because you can’t do anything these days without Docker.
1 https://naildrivin5.com/blog/2023/02/03/toolchain-for-building-programming-
books.html
2 The House of Eyrabakki is one of the oldest structures in Iceland, made of wood at a time
when houses were made of turf. It was transported to Iceland as a kit, and assembled there.
Thus, it’s a great analogy for sustainable web development with Ruby on Rails.
467
Index
.env.development.local, 39 span tag, 95
.env.development, 38 system
.env.test.local, 39 , 43
.env.test, 38 to_json, 392
.env, 39 with_clues, 186
.env files, ignoring, 39 12-factor app, 35
ActiveSupport
CurrentAttributes, 431 accessibility, 94, 141
ApplicationJob accountability, 464
for Sidekiq, 337 Action Cable, 363
ENV, 36 Action Mailbox, 363
Procfile.dev, 334 Action Mailer, 349
SECRET_KEY_BASE, 35 deliveries method, 301
after_create, 260 actions
as_json, 393 custom, 84
aside tag, 95 patch, 84
authorize_resource, 372 Active Job, 329, 335
bin/ci, 50 trade-offs with sending email,
parallel execution in CI, 410 350
bin/dev, 46 Active Model, 210
bin/rails routes, 74, 77 to_key, 210
bin/setup, 40 alternative to helpers, 134
customizing for CI, 408 unique identifier for, 210
maintaining, 408 validations, 258
br tag, 97 Active Record, 203
bundle gem, 419 callbacks, 259
current_user, 106 database logic vs. business
example implementation, logic, 208
373 instance methods, 208
div tag, 95 relationships, 205
html_safe, 129 scopes, 206, 261
load_and_authorize_resource, types of code needed, 204
374 validations, 224, 255
log, 43 bypassing, 257
normalizes, 259 with Active Model, 258
perform_async, 337 Active Records
rails new, 33 as compared to services, 244
rescue_from, 316 Active Storage, 364
set -e, 46 APIs, 377
469
authentication, 381 does not go in callbacks, 260
base controller, 379 example, 295
code, 378 trade-offs with validations,
content types, 387 258
JSON serialization, 392 business outcomes, 427
routing namespace, 379
testing, 398 callbacks
versioning, 389 controller, 314
app README, 54 cancanca, 372
app templates, 420 carrying cost, 15
architecture churn, 58
consistency, 212 clear fix, 98
microservices, 455 command pattern, 251
monolithic, 454 comments
unnecessary decisions, 77, 85 .gitignore, 39
ARIA Roles, 141 Gemfile, 38
assistive devices, 94 bin/ scripts, 42
authenticated user, 106 configuration, 218
authentication
database, 230
APIs, 381
in migrations, 383
in-app, 369
pinned dependencies, 414
multiple mechanisms in one
regarding missing code, 206
app, 370
when using html_safe, 130
token-based, 382
comments,config/routes.rb, 81
using a third party, 368
component, 142
authorization, 370
configuration, runtime, 35
auditing, 371
consistency, 14
checking, 372
custom actions, 374 constraints
defining, 372 testing, 237
testing, 374 continues integration
using job title and depart- configuration, 406
ment, 371 using bin/setup, 408
with cancancan, 372 continuous integration, 50, 405
with OmniAuth, 373 parallel testing, 411
controllers
background jobs, see jobs APIs, 379
bang methods, 297 instance variables, 100
behavior-revealing code, 242 multiple instance variables,
BEM, 143 104
Bootstrap, 142 testing, 318
Brakeman, 49 type conversions, 317
Bulma, 142 CSS, 139
Bundler atomic, 144
auto-require, 423 custom properties, 147
bundler-audit, 49 framework, 142
business logic, 57, 241 functional, 144
470
compared to inline styles, dependencies,considerations
145 when choosing, 177
downsides, 146 dependency injection, 252
relationship to JavaScript, deploying, 405
173 design system, 140, 279
object-oriented, 142 in emails, 352
pseudo elements, 145 style guide, 147
semantic, 141 developer workflow, 33
sheer volume, 139 development environment
specificity, 145 connect to database, 228
strategies, 141 port, 48
variables, 147 running app, 46
Current Attributes, 431 running multiple processes
custom URLs, 79 from bin/dev, 333
sending emails, 356
data setup, 40
importance of, 213 tests, 50
database Devise, 369
constraint usage guidelines, distributed tracing, 430
222 Docker, 446
constraints, 222 domain modeling, 120
foreign key constraints, 223 dotenv, 36
local maintenance, 40 DRY
logical model, 214 not repeating yourself
example, 215 repetition of, 137
lookup tables, 223 DSL
physical model, 213, 217 internal, 313
sharing between apps, 456
types, 220 ERB, 117, 161
booleans, 221 error reporting, 159
dates, 221 example feature, 65, 271
enums, 221 exceptions
rational numbers, 221 unhandled, 436
strings, 221 extending Rails, 219
timestamps, 219, 221
uniqueness modeling, 216 factories, 262
database integrity, 217, 255 linting, 264
database migrations validations, 309
SQL schema, 218 Factory Bot, 263
database normalization, 220 Faker, 263
decision aid, 177 fan-in, 60
dependencies fan-out, 60
automating updates, 414 fixtures, 262
carrying cost, 169 flash message, 95
minimizing, 118 floats, 96
updating, 412 clear fix, 98
versioning, 413 clearing, 97
471
foreign key constraints, 223, 232 plain, 169
Foreman, 333 problems with using multiple
frameworks, 178
gemspec, 422 runtime environment, 158
generators, 417 source maps, 159
globalid, 336 job backends, 329
jobs, 325
HAML, 118 code, 337
helpers, 119 defer execution, 326
banning, 119 failure handling, 330
generating HTML with, 128 generator, 338
modular per-controller files, idempotent, 342
127 mailers, 350
preventing per-controller observability, 331
files, 126 parameter serialization, 330,
rendering inline components 336
with, 124 queuing mechanism, 329
HTML retrying flaky code, 328
data attributes, 141, 170 Sidekiq, 331
escaping, 129 testing strategies, 339
escaping with content_tag, wrap network calls, 327
129 JSON serialization, 392
semantic, 93, 124, 274 customizing, 394
HTTP services, see APIs in Rails, 393
top-level key, 397
i2n, see i1n
i32n, see i2n Law of Demeter, 101
idempotency, 342 leadership, 463
import maps, 174 logging, 52
indexes current user ID, 436
conditional, 383 helpful details, 433
unique, 230, 257 request IDs, 430
internal DSL, 313 techniques, 429
internationalization, 276 use-cases, 429
internationalization configura- lograge, 52, 160
tion, see i32n adding request ID, 430
lookup tables, 223
JAM Stack, 161, 163
downsides, 163 Mailcatcher, 356
JavaScript, 157 mailers, 349
carrying costs, 157 previewing, 351
ecosystem, 160 sending in dev, 356
framework considerations, major user flow, 182
176 example, 284
locating markup with, 170 metaprogramming
observability hacky (as if there is another
lack thereof, 158 kind), 121, 269
472
microservices, 455 Redis
Migrations development and test data-
applying, 227 bases, 333
iterative construction, 230 isolated uses, 333
rolling back, 227 reference data, 106
transactional, 225 regular expressions
monitoring as content assertions, 185
business outcomes, 427 case-insensitive, 185
performance, 438 resource focused design, 85
monoliths, 454 rich result objects, 247
routes, 73
namespacing, 89 Active Model, 211
network calls avoiding redirects, 82
flakiness of, 328 based on resources, 74
slowness, 326 custom, 81
new app, 34 defining with get, 76
development only, 150
observability, 425 eight automatic, 74
OmniAuth, 368 namespaces, 89
OOCSS, 142 namespaces for APIs, 379
downsides, 144 nested, 87
OpenStruct, 75, 150 redirecting, 80
opportunity cost, 15 restricting with except:, 78
OWASP Top Ten, 128 restricting with only:, 77
partials SASS
locals as parameters, 108 import, 148
strict locals, 109 default values, 149
default values, 110 variables, 149
perforamnce, 438 secrets
port 9999, 48 managing, 440
presenters, 104 storing in development, 39
problems, 133 security vulnerabilities, 128
Procfile, 334 seed data, 273
Selenium, 196
rack test, 182 Semver, 389
Rails architecture, 19 Server-rendered views, 161
Railties, 420 downsides, 162
example, 423 service classes, 243
for sharing configuration, anti-patterns, 249
420 dependent objects, 245
rake tasks return values, 247
code, 359, 361 testing, 298
organizing, 358 service layer, 69, 241, 243
purpose, 357 example, 291
testing, 360 service objects, see command pat-
reality, 253, 256 tern
473
service-oriented architecture, 455 mocks versus database asser-
Sidekiq, 331 tions, 318
singleton pattern, 250 models, 262
Slim, 118 purpose of, 181
SMACCS, 143 rack test for system tests, 182
SOLID Principles, 243 rake tasks, 360
spoons routing, 323
existence of, 159 Selenium WebDriver, 196
SQL service class, 298
existential importance of, system test carrying cost, 186
220 system test strategy, 182
SQL schema, 218 system tests using a browser,
setup, 218 196
stringly-typed, 319 trade-offs with content and
style guide data attributes, 195, 286
for emails, 355 using data attributes, 194
living, 147 validations, 262
sustainability, 9 waiting for DOM elements,
200
text vs varchar, 204
Tachyons, 145
Thor, 417
Tailwind, 145
time zones
TDD
eternal frustration attributed
challenges for system tests,
to, 219
190
including with timestamps,
technical leadership, 463
219
template repositories, 419
trust
testing
third party authentication,
accessing browser console,
368
201
Turbo, 166
APIs, 398
progress bar, 166
asserting on markup, 185
authorization, 374 UNIX Environment
callbacks, 262 accessing with ENV.fetch,
confidence checks, 320 333
database constraints, 237, UNIX environment, 35
262, 384
diagnosing failures, 186, 201 vanity URLs, 79
duplicate coverage, 323 versioning policy, 413
fake test data, 263 View Component, 111
headless Chrome, 196 example, 113
helpers, 130 View Components
integration alternative to helpers, 136
strings, 319 example, 280
JavaScript, 178 view concerns, 104, 120
jobs, 338, 339
managing support code, 186 Web services, see APIs
474
web workers, 326
Webpack, 174
Webpacker, 174
475