How to use Rails Model Concerns

Learn how to use Ruby on Rails model concerns to build maintainable, scalable applications. Practical examples, best practices and code snippets from our decade of Rails development experience.

Monday February 10th 2025 | 9 min read

How to use Rails Model Concerns blog post header image

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:

  1. Keep Concerns Focused Each Concern should handle one specific aspect of functionality. This makes them more reusable and easier to maintain.

  2. Make Them Testable Write dedicated tests for your Concerns. We typically place these in spec/models/concerns/ or test/models/concerns/.

  3. Document Your Concerns Clear documentation helps team members understand how and when to use each Concern.

  4. 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:

  1. Overloading Concerns: Don't try to pack too much functionality into a single Concern. If it's doing too much, split it up.
  2. Circular Dependencies: Be careful about Concerns that depend on each other, as this can create maintenance headaches.
  3. 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.

Michael Hayes's avatar

Michael Hayes

Co-founder

Recent case studies

Here's a look at some of products we've brought to market recently

Educational Intelligence: Money Matters

Educational Intelligence: Money Matters

How Add Jam partnered with Educational Intelligence to create Money Matters, a digital platform addressing the UK's financial literacy crisis where 12.9 million adults struggle with money management.

Great Glasgow Coffee Run

Great Glasgow Coffee Run

Celebrating Glasgow's vibrant coffee culture and running community through an interactive digital experience that maps out the perfect coffee-fuelled running route through the city.

One Walk A Day

One Walk A Day

During lockdown we rapidly prototyped a health and wellbeing app using React Native then expanded on the concept and redeveloped using SwiftUI

We take products from an idea to revenue

Add Jam is your plug in team of web and mobile developers, designers and product managers. We work with you to create, ship and scale digital products that people use and love.

Hello, let's chat 👋