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.