A core mantra for Ruby on Rails development is "convention over configuration". While Rails provides conventions for structuring your code, there are still a lot of scope for making poor choices in how you structure your Rails codebase that can make it harder to maintain and scale.
A common convention is 'skinny controllers, fat models'. This is a good approach to keep your controllers focused but what happens when you have a model that has a lot of methods and logic? They become bloated, hard to maintain and impenetrable to new developers onboarded to the project. Enter Model Concerns. Concerns are a great way to keep your models clean and focused.
As a Ruby on Rails development agency that's been building complex Rails applications for over a decade, we've experimented with various approaches to keeping our code organised and maintainable. In our experience, one of the most effective ways to structure your Rails codebase is to embrace model concerns. Model Concerns are one of Rails' most powerful features - a tool that many developers overlook but we've found invaluable in our projects at Add Jam.
What Are Model Concerns and why should you care?
Rails Concerns are modules that allow you to extract and reuse common code patterns across your application. They're a powerful way to keep your models slim and focused while sharing functionality between different parts of your application. We've found them particularly valuable for maintaining clean, having focused models and implementing repeated functionality across multiple models without duplication (i.e. keeping our code DRY).
Real-world examples from our Rails projects
Let's look at some Concerns we've implemented across multiple Ruby on Rails projects. These examples come from real-world applications we've built and maintained.
1. The Linkable Concern
Starting with a really basic Model concern. Say we have multiple models with a link
attribute users can set. Its reasonable to want validation and formatting to make links consistent for example to add the protocol or maybe we want a utility function to access the domain name. This is a perfect scenario for some functionality that makes sense to write once, test once and avoid repeating ourselves. Let's write our basic concern:
module Linkable
extend ActiveSupport::Concern
included do
before_validation :smart_add_link_protocol
validates :link,
presence: true,
format: {
with: URI::DEFAULT_PARSER.make_regexp(%w[http https]),
message: "must be a valid URL"
}
end
def smart_add_link_protocol
return if link.blank?
return if link_protocol_present?
self.link = "https://#{link}"
end
private
def link_protocol_present?
link[/\Ahttp:\/\//] || link[/\Ahttps:\/\//]
end
def valid_link?
begin
uri = URI.parse(link)
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
rescue URI::InvalidURIError
false
end
end
def domain
return nil unless valid_link?
URI.parse(link).host.downcase
end
end
This Model Concern provides URL validation, automatic protocol addition, and some helpful utility methods. Concern written we now can do two things:
- Test our concern in isolation
- Use it across various models that need to handle external links
Let's start with the test and we're assuming we're using RSpec for our tests:
require "rails_helper"
RSpec.describe Linkable do
let(:test_class) do
Class.new do
include ActiveModel::Model
include Linkable
attr_accessor :link
end
end
subject { test_class.new(link: link) }
describe "validations" do
context "when link is not present" do
let(:link) { nil }
it "is not valid" do
expect(subject).not_to be_valid
expect(subject.errors[:link]).to include("can't be blank")
end
end
context "with invalid URLs" do
["not a url", "http:/missing-slashes"].each do |invalid_url|
it "is not valid with #{invalid_url}" do
subject.link = invalid_url
expect(subject).not_to be_valid
expect(subject.errors[:link]).to include("must be a valid URL")
end
end
end
context "with valid URLs" do
[
"https://example.com",
"http://test.co.uk",
"https://sub.domain.org/path"
].each do |valid_url|
it "is valid with #{valid_url}" do
subject.link = valid_url
expect(subject).to be_valid
end
end
end
end
describe "#smart_add_link_protocol" do
context "when link is not present" do
let(:link) { nil }
it "does not change the link" do
subject.valid?
expect(subject.link).to be_nil
end
end
context "when link is present and with protocol" do
["http://example.com", "https://example.com"].each do |url|
it "does not change #{url}" do
subject.link = url
subject.valid?
expect(subject.link).to eq(url)
end
end
end
context "when link is present and without protocol" do
let(:link) { "example.com" }
it "prepends https://" do
subject.valid?
expect(subject.link).to eq("https://example.com")
end
end
end
describe "#valid_link?" do
context "with valid URLs" do
[
"https://example.com",
"http://test.co.uk/path?query=true"
].each do |url|
it "returns true for #{url}" do
subject.link = url
expect(subject.send(:valid_link?)).to be true
end
end
end
context "with invalid URLs" do
["not-a-url", "ftp://example.com", nil].each do |url|
it "returns false for #{url}" do
subject.link = url
expect(subject.send(:valid_link?)).to be false
end
end
end
end
describe "#domain" do
context "with valid URLs" do
{
"https://EXAMPLE.com/path" => "example.com",
"http://sub.domain.co.uk" => "sub.domain.co.uk",
"https://test.org" => "test.org"
}.each do |url, expected_domain|
it "returns #{expected_domain} for #{url}" do
subject.link = url
expect(subject.send(:domain)).to eq(expected_domain)
end
end
end
context "with invalid URLs" do
["not-a-url", nil, ""].each do |url|
it "returns nil for #{url}" do
subject.link = url
expect(subject.send(:domain)).to be_nil
end
end
end
end
end
Quite a thorough test with over 100 lines. Consider if we didn't have the Linkable Model Concern and we had this functionality defined in the model itself. Not only would the model have the bloat of these methods but we would be massively bloating our model spec too.
Now that we have the Model Concern written and well tested we can add the concern to any applicable models with a simple include
statement:
class Profile < ApplicationRecord
include Linkable
# link field stores website URL
end
class SocialMedia < ApplicationRecord
include Linkable
# link field stores social media profile URL
end
class Resource < ApplicationRecord
include Linkable
# link field stores documentation URL
end
In our example we're using our concern across three models. If we didnt have the concern yet wanted the logic applied each model what would we do? Would we have to write the same validation and formatting logic in each model? And then have all those tests in multiple files.
Our simple model concern has greatly reduced duplication and maintenance headaches.
2. The Taggable Concern
A more complex example of a model concern is our Taggable concern. Say we want to add tags to our models that can ultimately help with querying and categorisation, that's a common use case in a web app right? Assuming we don't want to add a dependency to our project like acts_as_taggable we can instead use polymorphic relationships to add Tags to our models through a Taggings table and by using a concern we can extract the logic out of the model and into a single concern. This makes it super easier to add tags to our models across our codebase as needed.
module Taggable
extend ActiveSupport::Concern
included do
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
scope :with_tags, ->(tags) {
joins(:tags).where(tags: {id: tags}).distinct
}
end
end
This gives us a clean way to add tagging functionality to any model simply by including the Taggable concern.
class User < ApplicationRecord
include Linkable # assuming we also want our Linkable concern
include Taggable
end
class Product < ApplicationRecord
include Taggable
end
class Article < ApplicationRecord
include Taggable
end
Once again we've created a concern that's managed to take a lot of code out our models, into a single concern. And once again we can test our concern in isolation reducing even more bloat from our project.
Best practices we've learned
Through our experience building Rails applications for clients across various industries, we've developed these best practices for using Model Concerns:
-
Keep Concerns Focused Each Concern should handle one specific aspect of functionality. This makes them more reusable and easier to maintain.
-
Make Them Testable Write dedicated tests for your Concerns. We typically place these in
spec/models/concerns/
ortest/models/concerns/
. -
Document Your Concerns Clear documentation helps team members understand how and when to use each Concern.
-
Use Meaningful Names Name your Concerns after the functionality they provide, making their purpose immediately clear.
Common pitfalls to avoid
While Concerns are powerful, there are some common mistakes to watch out for:
- Overloading Concerns: Don't try to pack too much functionality into a single Concern. If it's doing too much, split it up.
- Circular Dependencies: Be careful about Concerns that depend on each other, as this can create maintenance headaches.
- Inappropriate Use: Not everything needs to be a Concern. Sometimes a simple module or service object is more appropriate.
When to use Concerns
Based on our experience developing Rails applications, Concerns are ideal when:
- Multiple models share similar functionality
- You need to keep models focused and maintainable
- You want to make functionality easily testable
- You're implementing polymorphic behaviour
Concerns don't even need to be all about reusability. You can have model-specific concerns that are only used in one specific model and in doing so remove methods and logic out of the model itself. We've all encountered those bloated models with hundreds of methods and complex logic that become a nightmare to maintain and test. In our experience 'fat models' are a bad smell for Rails projects often leading to poor test coverage which snowballs into poor code quality and more bugs.
By moving methods and logic into focused concerns, you can keep your models clean and maintainable while keeping your tests focused. Each concern can be tested in isolation, which leads to better test coverage, improved code quality and ultimately fewer bugs in your software.
Implementing Model Concerns
Implementing Model Concerns effectively is just one aspect of building maintainable Rails applications. Concerns can also be used in controllers and other parts of your application. We'll be looking at how to use Concerns in controllers in a future blog post.
At Add Jam, we specialise in helping businesses build and scale their Ruby on Rails applications using best practices like these.
Whether you're starting a new Rails project or looking to improve an existing application, our team of experienced Rails developers can help. We offer:
- Custom Ruby on Rails development
- Code audits and reviews
- Performance optimisation
- Technical consulting
Ready to build better Rails applications? Get in touch with our Rails development team to discuss your project.
Add Jam is a leading Ruby on Rails development agency based in Glasgow, Scotland. We serve clients across the UK and globally. We specialise in building robust, scalable applications using Ruby on Rails, React, and React Native.