The Joy of a Single-Purpose Class: From String Mutation to Message Composition
… and check why 5600+ Rails engineers read also this
The Joy of a Single-Purpose Class: From String Mutation to Message Composition
Recently I started the process of upgrading rather big Rails application to latest Ruby 3.4. I noticed a lot of warnings related to string literal mutation:
warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)
Ruby has both mutable and immutable strings
Let’s read fxn’s explanation on this:
In Ruby 3.4, by default, if a file does not have the magic comment and a string object that was instantiated with a literal gets mutated, Ruby still allows the mutation, but it now issues a warning
I was able to notice this early since my colleague Piotr took care about not tuning out the Ruby deprecation warnings.
This article won’t be about benefits of freezing string literals, but if you’re curious about this topic, you should read this gist by fxn with care and the article about Past, Present and Future of Frozen String Literal by byroot if you want a deep dive into details.
Problem with string literal mutation in our code
I noticed that there’s noticeable amount of deprecation messages related to modifying future frozen string literals coming from one module. It was the one responsible for producing and delivering Slack messages related to customer support, billing, frauds, etc. All the things that improve day-to-day operations in a serious business. 100+ methods representing messages to be delivered to various channels.
The messages were composed in a few ways:
module Slack
module Billing
BILLING_CHANNEL_NAME = 'billing'.freeze
extend self
def invoice_sent(invoice)
message = ':postbox: *Invoice sent to customer*'
message << " | #{invoice.customer_name}"
message << " | #{invoice.customer_email}"
message << " | <#{inovice.url}|#{invoice.number}>"
send_message(BILLING_CHANNEL_NAME, message)
end
def payment_received(payment, locale)
message = payment_text(payment, locale)
message.push("\n Invoice: #{payment.invoice_number}")
message.push("\n Customer: #{payment.customer_name}")
send_message(BILLING_CHANNEL_NAME, message)
end
private
def payment_text(payment, locale)
text = ':moneybag: *Payment Received*'
text << " | #{format_amount(payment.amount, locale)}"
text << " | #{payment.channel}"
text
end
def format_amount(amount, locale)
number_to_currency(amount, locale: locale)
end
def send_message(channel_name, message)
Client.deliver_message(channel: channel_name, message: message)
end
end
end
Respective messages produced would be:
:postbox: | *Inovice sent to customer* | Jane Doh | jan.doh@example.com | <https://fancyurl.example.com|KAKADUDU123>
and
:moneybag: *Payment Received* | $123.45 | Credit card
Invoice: KAKADUDU123
Customer: Jane Doh
Noticing the pattern
After reviewing around 100 methods delivering different messages, I instantly noticed the pattern and thought: Ok, I can deal with that easily with a help of Array and improve this repeatable, manual text decorations like " | " or "\n".
+ # frozen_string_literal: true
+
module Slack
module Billing
BILLING_CHANNEL_NAME = 'billing'
extend self
def invoice_sent(invoice)
- message = ':postbox: *Invoice sent to customer*'
- message << " | #{invoice.customer_name}"
- message << " | #{invoice.customer_email}"
- message << " | <#{inovice.url}|#{invoice.number}"
+ message = [':postbox: *Invoice sent to customer*']
+ message << "#{invoice.customer_name}"
+ message << "#{invoice.customer_email}"
+ message << "<#{inovice.url}|#{invoice.number}>"
- send_message(BILLING_CHANNEL_NAME, message)
+ send_message(BILLING_CHANNEL_NAME, message.join(" | "))
end
def payment_received(payment, locale)
- message = payment_text(payment, locale)
- message.push("\n Invoice: #{payment.invoice_number}")
- message.push("\n Customer: #{payment.customer_name}")
+ message = [payment_text(payment, locale)]
+ message.push("Invoice: #{payment.invoice_number}")
+ message.push("Customer: #{payment.customer_name}")
- send_message(BILLING_CHANNEL_NAME, messsage)
+ send_message(BILLING_CHANNEL_NAME, message.join("\n"))
end
private
def payment_text(payment, locale)
- text = ':moneybag: *Payment Received*'
- text << " | #{format_amount(payment.amount, locale)}"
- text << " | #{payment.channel}"
+ text = [':moneybag: *Payment Received*']
+ text << "#{format_amount(payment.amount)}"
+ text << "#{payment.channel}"
- text
+ text.join(" | ")
end
def format_amount(amount, locale)
number_to_currency(amount, locale: locale)
end
def send_message(channel_name, message)
Slack::Client.deliver_message(channel: channel_name, message: message)
end
end
end
What we’ve gained by this refactoring:
- No string literal mutation, so there will be no warnings on Ruby 3.4 and potential issues in the future
- Less repeatable code, no artisanal text delimiter crafting
- We still used the same methods for composing message as both
StringandArrayprovide<<andpushmethods. I wanted to keep this code similar to previous implementation without any radical changes so other maintainers would be instantly familiarized with it.
Improve the code
I’m sick of primitive obsession in the codebase. I don’t like all those Array related internals exposed, irrelevant in the context of building a message. We operate on a very simple example here, multiply this 50 times, add even more complex methods to that.
What if we introduced dedicated object which:
- produces strings in an immutable manner
- hides all the separator plumbing as most of the 100+ messages use
|to separate message parts - deals with empty strings
- has API similar to current implementation
- allows composition like current implementation
Let’s look at the implementation:
# frozen_string_literal: true
module Slack
class Message
DELIMITER = ' | '
def initialize(*parts, delimiter: DELIMITER)
@delimiter = delimiter
@message = parts
end
def <<(message_part) = @message << message_part
def to_s = @message.compact_blank.join(@delimiter)
alias_method :to_str, :to_s
alias_method :push, :<<
end
end
Benefits of ActiveSupport
What’s important to notice is the fact that it’s a Rails and we benefit from ActiveSupport` here, specifically:
compact_blankin an explicit manner- and
blank?in an implicit way asObjectextension
Otherwise we would need to put a bit more effort into our class:
def to_s
@message
.compact
.reject { |part| part.respond_to?(:empty?) && part.empty? }
.join(@delimiter)
end
It could be split into private method, but I like the explicitness compact_blank provides and I’m fine with using it.
Pass a single string or multiple as an argument
The *parts parameter leverages Ruby’s splat operator to automatically collect all positional arguments into an array under @messages. This gives us a flexible constructor without forcing callers to wrap arguments in array literals.
Slack::Message.new('kaka').to_s
=> "kaka"
Slack::Message.new('kaka', 'dudu').to_s
=> "kaka | dudu"
Append our message using different methods
message = Slack::Message.new('kaka')
message << 'dudu'
message.to_s
=> "kaka | dudu"
message = Slack::Message.new('kaka')
message.push 'dudu'
message.to_s
=> "kaka | dudu"
Default delimiter, but still customizable
Slack::Message.new('kaka', 'dudu').to_s
=> "kaka | dudu"
Slack::Message.new('kaka', 'dudu’, delimiter: „\n”).to_s
=> "kaka\ndudu"
Compose various Slack::Message object with different delimiters
Slack::Message.new('kaka', Slack::Message.new('dudu', 'foo', delimiter: " — ")).to_s
=> "kaka | dudu — foo"
msg = Slack::Message.new('kaka', delimiter: "\n")
msg << Slack::Message.new('dudu', 'foo', delimiter: ' ~ ')
msg.to_s
=> "kaka\ndudu ~ foo"
The magic happens through the to_str alias. When @message.join(@delimiter) is called, Ruby’s Array#join implicitly calls to_str on each element (it would fallback to to_s if not defined). Since to_str is aliased to to_s, nested Slack::Message objects get automatically stringified.
This recursive flattening happens transparently because to_str signals to Ruby that our object can be treated as a string in implicit contexts.
Final refactoring
# frozen_string_literal: true
module Slack
module Billing
BILLING_CHANNEL_NAME = 'billing'
extend self
def invoice_sent(invoice)
- message = [':postbox: *Invoice sent to customer*']
- message << "#{invoice.customer_name}"
- message << "#{invoice.customer_email}"
- message << "<#{inovice.url}|#{invoice.number}>"
+ message = Message.new(':postbox: *Invoice sent to customer*')
+ message << "#{invoice.customer_name}"
+ message << "#{invoice.customer_email}"
+ message << "<#{inovice.url}|#{invoice.number}"
- send_message(BILLING_CHANNEL_NAME, message.join(" | "))
+ send_message(BILLING_CHANNEL_NAME, message)
end
def payment_received(payment, locale)
- message = [payment_text(payment, locale)]
- message.push("Invoice: #{payment.invoice_number}")
- message.push("Customer: #{payment.customer_name}")
+ message = Message.new(payment_text(payment, locale), delimiter: "\n")
+ message.push("Invoice: #{payment.invoice_number}")
+ message.push("Customer: #{payment.customer_name}")
- send_message(BILLING_CHANNEL_NAME, messsage.join("\n"))
+ send_message(BILLING_CHANNEL_NAME, message)
end
private
def payment_text(payment, locale)
- text = [':moneybag: *Payment Received*']
- text << "#{format_amount(payment.amount, locale)}"
- text << "#{payment.channel}"
+ text = Message.new(':moneybag: *Payment Received*')
+ text << "#{format_amount(payment.amount, locale)}"
+ text << "#{payment.channel}"
- text.join(" | ")
+ text
end
def format_amount(amount, locale)
number_to_currency(amount, locale: locale)
end
def send_message(channel_name, message)
- Slack::Client.deliver_message(channel: channel_name, message: message)
+ Slack::Client.deliver_message(channel: channel_name, message: message.to_s)
end
end
end
I can’t remember the last time I had so much joy introducing such a simple, single-purpose class. In such moments you rediscover the true beauty of Ruby.
Summary
- The class name
Messagenicely reveals its intent. We’re composing some message here. - There’s no need for artisanal delimiter orchestration
- Our object composes nicely from different pieces
- Output is predictable and immutable
frozen string literalwarnings are gone