Advanced Domain Modeling Techniques for Ruby on Rails – Part 2
Polymorphism with Strategies
One of the most frequent code smells in Rails is an excessive use of inheritance. A serious drawback from using inheritance to achieve polymorphism is the implicit coupling it creates between parent and child classes.
Consider the following example, a range of messaging campaigns:
class Campaign
def deliver
addressees.find_each do |addressee|
transport.deliver(to: addressee, body: prepare_message_body)
end
end
private
def prepare_message_body
# e.g. process merge tags etc.
end
def transport
raise NotImplementedError, "Subclasses of Campaign must implement #transport"
end
end
class Campaigns::Email < Campaign
def transport = EmailService::Client
def prepare_message_body = to_html(super)
end
class Campaigns::Sms < Campaign
def transport = SmsService::Client
def prepare_message_body = to_plain_text(super).truncate(SMS_CHAR_LENGTH)
end
At first glance, this is a reasonable Template Method setup: the base class orchestrates delivery, subclasses fill in the details.
Now imagine a new requirement: SMS messages must be split into chunks before sending (for compliance, gateway constraints, or pricing reasons). Suddenly Campaigns::Sms has to override deliver itself:
class Campaigns::Sms < Campaign
def transport = SmsService::Client
def prepare_message_body = to_plain_text(super).truncate(SMS_CHAR_LENGTH)
def deliver
addressees.find_each do |addressee|
prepare_message_body.chars.each_slice(SMS_CHAR_LENGTH).map(&:join).each do |chunk|
transport.deliver(to: addressee, body: chunk)
end
end
end
end
This override is the warning sign. We’re duplicating orchestration from the parent just to change one part of the behavior.
In other words, our hierarchy is now trying to model several independent concerns at once:
- What if we want to exchange behavior at runtime, for example to send the same campaign content via multiple transports?
- We introduced coupling by pushing shared behavior into a private
prepare_message_bodymethod. - We’re already reaching into that method from a subclass (truncation in the SMS case), which is a subtle design smell.
- If delivery mechanics diverge (chunked SMS, batched email, retries), we have to override orchestration and duplicate code.
- As soon as formats overlap across multiple transports, the class tree explodes.
Imagine just a few realistic constraints:
- Telegram and WhatsApp accept Markdown
- SMS and Signal accept plain text only
- Email accepts HTML or plain text
Even before you model output formats, you already need a class per transport:
class Campaigns::Email ...
class Campaigns::Telegram ...
class Campaigns::Whatsapp ...
class Campaigns::Sms ...
class Campaigns::Signal ...
Once you add the overlaps above, you quickly end up with Campaigns::Email::HTML, Campaigns::Telegram::Markdown, Campaigns::Sms::Plain, and friends. Once those concerns vary independently, inheritance becomes rigid and leaky. You get the point.
Mixins to the Rescue?
Mixins (modules) have the problem that they need to be defined before runtime. Yes, you can do MyClass.include(MyModule) at runtime, but there’s no simple way to un-include a module other than creating a new instance, so typically you end up with a lot of prefabricated classes including different modules. This will DRY up your code, but lead to the same jungle of classes we encountered above.
Enter Strategies
What can be done to remedy this? The answer, as many of you might have presumed, is composition. But there are many compositional patterns like decorators, strategies, state, proxies - which one to choose? In this article, we’ll concentrate on strategies, because they are an especially lightweight realization of the compositional philosophy.
Let’s think about the Campaign class again and reframe its objective. It shouldn’t engender a dynasty of child classes, but employ collaborators (“tools”, so to speak), to fulfill its purpose.
So let’s start by constructing these tools. We begin with the campaign transports:
class Transport::Base
# @abstract
def deliver(campaign)
raise NotImplementedError, "Subclasses of Transport::Base must implement #deliver"
end
end
class Transport::Email < Transport::Base
def deliver(campaign)
campaign.addressees.find_each do |addressee|
EmailService::Client.deliver(to: addressee, body: campaign.body)
end
end
end
class Transport::Sms < Transport::Base
def deliver(campaign)
campaign.addressees.find_each do |addressee|
campaign.body.chars.each_slice(3).map(&:join).each do |chunk|
SmsService::Client.deliver(to: addressee, body: chunk)
end
end
end
end
class Transport::Telegram < Transport::Base
def deliver(campaign)
campaign.addressees.find_each do |addressee|
TelegramService::Client.deliver(to: addressee, body: campaign.body)
end
end
end
class Transport::Whatsapp < Transport::Base
def deliver(campaign)
campaign.addressees.find_each do |addressee|
WhatsappService::Client.deliver(to: addressee, body: campaign.body)
end
end
end
class Transport::Signal < Transport::Base
def deliver(campaign)
campaign.addressees.find_each do |addressee|
SignalService::Client.deliver(to: addressee, body: campaign.body)
end
end
end
Next, let’s think about output formats. Each of them will get a class with a format transformation method:
class Output::Base
# @abstract
def format(content)
raise NotImplementedError, "Subclasses of Output::Base must implement #format"
end
end
class Output::Short < Output::Base
def format(content) = content.truncate(SMS_CHAR_LENGTH)
end
class Output::Plain < Output::Base
def format(content) = content
end
class Output::Markdown < Output::Base
def format(content) = content.to_markdown
end
class Output::HTML < Output::Base
def format(content) = content.to_html
end
We’ll stop here because otherwise the scope would get too large for this article, but you see what I am getting at, because now a Campaign can “hire” any of these tools:
class Campaign
attr_writer :transport, :output
attr_reader :body
def initialize(transport, output, body)
@transport = transport
@output = output
@body = body
end
def process
@body = @output.format(@body)
@transport.deliver(self)
end
end
Now we can instantiate a campaign and send it:
body = "Lorem ipsum ..."
c = Campaign.new(Transport::Email.new, Output::HTML.new, body)
c.process
What if we want to send the same campaign via SMS and the short format though? Easy:
c.output = Output::Short.new
c.transport = Transport::Sms.new
c.process
Or, if we want to send it as Markdown over WhatsApp:
c.output = Output::Markdown.new
c.transport = Transport::Whatsapp.new
c.process
You might wonder why I am not using
Strategyin the class names. That’s intentional, to show you that they are just POROs certain behaviors are delegated to. A design pattern is just a way of describing a way objects collaborate with each other.
Strategies vs Lambdas?
You could make an argument that most of the classes above are obsolete in the light that you could replace the methods that are being called on them by simple lambdas, e.g.:
body = "Lorem ipsum ..."
c = Campaign.new(
lambda (campaign) do
campaign.addressees.find_each do |addressee|
EmailService::Client.deliver(to: addressee, body: campaign.body)
end
end,
->(content) { content.to_html },
body
)
c.process
We’d only have to make a minimal change to the Campaign class:
class Campaign
attr_writer :transport, :output
attr_reader :body
def initialize(transport, output, body)
@transport = transport
@output = output
@body = body
end
def process
- @body = @output.format(@body)
+ @body = @output.call(@body)
- @transport.deliver(self)
+ @transport.call(self)
end
end
We can then exchange algorithms just as easy:
c.output = ->(content) { content.truncate(SMS_CHAR_LENGTH) }
c.transport = lambda(campaign) do
campaign.addressees.find_each do |addressee|
SmsService::Client.deliver(to: addressee, body: campaign.body)
end
end
c.process
This is a valid objection and can indeed be an option in many cases. It is more a matter of style and what intents you want to communicate, if any. Class names can be such communicators. Furthermore, in our example here we implemented only trivial business logic in our strategy classes, but of course in reality this tends to be much more complex. So the boundaries are fluid, and the decisive question is how verbose you want to be about your design goals. My rule of thumb is: The more central the respective code is to my application’s core business logic, the more likely I am to extract an actual class.
Bonus - Client Side Strategies
You might object that you’re only building CRUD-style apps, and that Rails defaults are plenty. That’s a valid point in many cases, but you’d be surprised that there are many more possible applications for this pattern you might encounter.
Let me outline a specific frontend situation for a change. As you might be aware, Turbo 8 has introduced “InstantClick” behavior. In essence, this results in prefetch requests being issued whenever a user hovers over a link for longer than 100 milliseconds. By default this is enabled on all links, but you can disable it with a data attribute:
<a href="/about" data-turbo-prefetch="false">About</a>
This is fine, but you might want to reject prefetching in a more descriptive than this default imperative manner. Consider that you might want to opt out
- when a certain data attribute is set on the link element
- when a certain condition is met on the parent element
- when a certain regular expression is fulfilled
As it turns out, strategies are the perfect choice for this undertaking. But first of all, let’s look at some boilerplate. The programmatic way to opt out of prefetching is to prevent the default turbo:before-prefetch event:
document.addEventListener('turbo:before-prefetch', (event) => {
event.preventDefault();
});
Now we need a way to query all the conditions outlined above. We are going to add a PrefetchCondition class for this:
class PrefetchCondition {
constructor() {
this.conditionStrategies = [];
}
addStrategy(strategy) {
this.conditionStrategies.push(strategy);
}
shouldPreventDefault(event) {
return this.conditionStrategies.some((strategy) => strategy(event));
}
}
As you can see it contains an array of conditionStrategies as an instance variable. Each of these strategies has to be a callable function returning true or false. The shouldPreventDefault method then checks if any of these strategies matches, so we can do this:
const prefetchCondition = new PrefetchCondition();
document.addEventListener('turbo:before-prefetch', (event) => {
if (prefetchCondition.shouldPreventDefault(event)) {
event.preventDefault();
}
});
The requirements mentioned above can be implemented as follows:
const autoSaveStrategy = (event) => 'autoSave' in event.target.dataset;
const matchAdminStrategy = (event) =>
event.target.href && event.target.href.match(/^\/admin\//);
const wrappedInCarouselStrategy = (event) => 'swiper' in event.target.parentElement.dataset;
Finally, we just need to assemble everything:
const prefetchCondition = new PrefetchCondition();
prefetchCondition.addStrategy(matchAdminStrategy);
prefetchCondition.addStrategy(autoSaveStrategy);
prefetchCondition.addStrategy(wrappedInCarouselStrategy);
document.addEventListener('turbo:before-prefetch', (event) => {
if (prefetchCondition.shouldPreventDefault(event)) {
event.preventDefault();
}
});
Voilà, you can dynamically control prefetching across your application, and you can even change that behavior on every route if you like.
When To Use It?
Composition vs. Inheritance or Mixins
The boundary to draw can be fuzzy, but you can use a simple rule of thumb (shoutout to Sandi Metz) to decide which path to follow:
Use inheritance only when you have a clear “is-a” relationship (a
Caris aVehicleetc.) and genuinely need polymorphic substitution across a stable type hierarchy.Mixins work well for cross-cutting concerns (“acts-as-a”, a good example is
ActionText::Attachable) that don’t fit cleanly into your inheritance tree, but they still create coupling and can lead to fragile linearization problems.Favor composition when you need flexible runtime behavior and want to avoid tight coupling (a “uses-a” relationship) — it lets you assemble objects from smaller parts without locking into rigid class hierarchies.
In short: if you’re tempted to inherit just to reuse code, compose instead; if you’re modeling true taxonomic relationships, inherit; if you need to sprinkle behavior across unrelated classes, consider mixins cautiously.
Strategies Specifically
Reach for the Strategy pattern when you have a family of interchangeable behaviors and want to make them pluggable at runtime—think notification transports, file storage backends, or export formats.
It’s particularly valuable when you’d otherwise end up spreading conditionals in your models or controllers switching between implementation variants, or when you want to keep your Active Record models lean by delegating complex logic to dedicated objects.
The pattern shines when behaviors are genuinely independent and don’t require deep knowledge of your model’s internal state. If they need to access half your model’s attributes and associations, you’re just adding indirection for no gain (also called “Feature Envy”).
Use it when you need real behavioral flexibility—like swapping between email, native push or SMS notifications—not when you’re just trying to avoid a simple case statement in a controller action.
Conclusion
Strategies offer a pragmatic escape hatch from inheritance hell. By favoring composition over rigid class hierarchies, you gain runtime flexibility, reduced coupling, and code that’s easier to test and reason about.
The pattern scales elegantly—from backend domain logic to frontend concerns. Start with simple collaborator objects, and let complexity emerge only when your business logic demands it.
Remember: inheritance locks you in at class definition time. Strategies keep your options open until the last responsible moment. That’s not just good OOP—it’s adaptable software design.
At some point of any Ruby on Rails project's life the codebase starts to get messy, and you sense that you have to address this before the tech debt pile reaches unmanageable proportions.