Ruby string literals VS Value objects. Overengineering?

A guide on how to get rid of string literals in Ruby with Rails 5 Attributes API

Im my previous article I showed how you can use Rails 5 Attributes API with JSONB and value objects to improve the design of your application.

Today I want to show you how Attributes API can be applied to refactor Primitive Obsession anti pattern.

Primitive data types are the basic built-in building blocks of a language. They're usually typed as int, string, or constants. As creating such fields is much easier than making a whole new class, this leads to abuse. Therefore, this makes this antipattern one of the most common ones.

  • Using primitive data types to represent domain ideas. For example, using an integer to represent an amount of money or a string for a phone number.

  • Using variables or constants for coding information. An oft-encountered case is using constants for referring to users roles or credentials (like USER_ADMIN = 1;).

  • Using strings as field names in data arrays.

Lets take a look at this code.

 class Plan::Channel < ApplicationRecord CALCULATION_METHODS = %w( sliding_scale_net_origination_fee sliding_scale_origination_volume flat spread ).freeze TYPES = %w( direct broker correspondent bdr non_funded_origination ).freeze self.inheritance_column = nil belongs_to :plan TYPES.each do |type| scope type, -> { where(type: "#{type}") } end attribute :tiers, Plan::Channel::Tiers::Type.new end 

Constant CALCULATION_METHODS is defined as a frozen array of strings and represents how the channel should be calculated. At the beginning everything looks pretty good.

 class Plan::CreatePlanForm < BaseForm # … collection :channels, populator: ChannelsPopulator do property :type property :rate, type: ::Commissions::Types::Percentage property :calculation_method validation with: { form: true } do required(:type) { filled? & included_in?(Plan::Channel::TYPES) } required(:calculation_method) { filled? & included_in?(Plan::Channel::CALCULATION_METHODS) } end collection :tiers, populate_if_empty: Plan::Channel::Tier do property :rate, type: ::Commissions::Types::Percentage property :amount, type: ::Commissions::Types::Currency end end # … end 

But suddenly…

 class Commission::Calculator::SlidingScale < Commission::Calculator # … def total_cumulative_amount case channel.calculation_method when 'sliding_scale_net_origination_fee' group.net_origination_fee_amount when 'sliding_scale_origination_volume' group.total_amount end end # … end 

And again …

 class Commission::Calculator # … class << self def calculator(preimage) case preimage.channel.calculation_method when 'sliding_scale_net_origination_fee', 'sliding_scale_origination_volume' Commission::Calculator::SlidingScale when 'flat' Commission::Calculator::Flat when 'spread' Commission::Calculator::Spread end end end # … end 

This really looks awful to me. If we change values in CALCULATION_METHODS there is a big chance that we will simply forget to update them in a case-when operators. It would be better to avoid using such unsafe code.

Primitive obsessions are born in moments of weakness. "Just a field for storing some data!" the programmer claimed. Creating a primitive field is so much easier than making a whole new class, right? And so it was done. Then another field was needed and added in the same way.

Primitives are often used to "simulate" types. So instead of a separate data type, you have a set of numbers or strings that form the list of allowable values for some entity. Easy-to-understand names are then given to these specific numbers and strings via constants, which is why they are spread far and wide.

The first approach is to refactor CALCULATION_METHODS constant to four independent constants, so we can arrive at something like this.

 class Plan::Channel < ApplicationRecord CALCULATION_METHOD_SLIDING_SCALE_NET_ORIGINATION_FEE = 'sliding_scale_net_origination_fee'.freeze CALCULATION_METHOD_SLIDING_SCALE_ORIGINATION_VOLUME = 'sliding_scale_origination_volume'.freeze CALCULATION_METHOD_FLAT = 'flat'.freeze CALCULATION_METHOD_SPREAD = 'spread'.freeze CALCULATION_METHODS = [ CALCULATION_METHOD_SLIDING_SCALE_NET_ORIGINATION_FEE, CALCULATION_METHOD_SLIDING_SCALE_ORIGINATION_VOLUME, CALCULATION_METHOD_FLAT, CALCULATION_METHOD_SPREAD ).freeze # … end # … class Commission::Calculator # … class << self def calculator(preimage) case preimage.channel.calculation_method when Plan::Channel::CALCULATION_METHOD_SLIDING_SCALE_NET_ORIGINATION_FEE, Plan::Channel::CALCULATION_METHOD_SLIDING_SCALE_ORIGINATION_VOLUME Commission::Calculator::SlidingScale when Plan::Channel::CALCULATION_METHOD_FLAT Commission::Calculator::Flat when Plan::Channel::CALCULATION_METHOD_SPREAD Commission::Calculator::Spread end end end # … end 

But, again, this code does not look good to me. Another approach would be to refactor this code with AR Enum API, but we will do it in another way.

Lets try to use Attributes API again. We have calculation_method column in plan_channels that is String.

We should add it as attribute to Plan::Channel model and define type.

 class Plan::Channel < ApplicationRecord TYPES = %w( direct broker correspondent bdr non_funded_origination ).freeze self.inheritance_column = nil belongs_to :plan TYPES.each do |type| scope type, -> { where(type: "#{type}") } end attribute :tiers, Plan::Channel::Tiers::Type.new attribute :calculation_method, Plan::Channel::CalculationMethod::Type.new end 
 require 'dry-initializer' class Plan::Channel::CalculationMethod extend Dry::Initializer FLAT = 'flat'.freeze private_constant :FLAT SPREAD = 'spread'.freeze private_constant :SPREAD SLIDING_SCALE_NET_ORIGINATION_FEE = 'sliding_scale_net_origination_fee'.freeze private_constant :SLIDING_SCALE_NET_ORIGINATION_FEE SLIDING_SCALE_ORIGINATION_VOLUME = 'sliding_scale_origination_volume'.freeze private_constant :SLIDING_SCALE_ORIGINATION_VOLUME SLIDING_SCALE = [ SLIDING_SCALE_NET_ORIGINATION_FEE, SLIDING_SCALE_ORIGINATION_VOLUME ].freeze private_constant :SLIDING_SCALE param :value, proc(&:to_s) def self.values [FLAT, SPREAD, SLIDING_SCALE].flatten end def to_s value.to_s end values.each do |v| define_method "#{v}?" do value == v end end def sliding_scale? SLIDING_SCALE.include?(value) end class Type < ActiveModel::Type::ImmutableString def cast(value) Plan::Channel::CalculationMethod.new(value) end def serialize(value) case value when Plan::Channel::CalculationMethod value.to_s else super end end end end 

Plan::Channel::CalculationMethod became a Value Object. What did the developer gain from refactoring?

  • Instead of a set of primitive values, the programmer has a full-fledged class with all the benefits that object-oriented programming has to offer (typing data by class name, type hinting, etc.).

  • There's no need to worry about data validation, as only expected values can be set.

  • When CalculationMethod logic extends, it will be collected in one place that's dedicated to it.

 class Commission::Calculator::SlidingScale < Commission::Calculator def rate 100 * max_commission_amount / group.net_origination_fee_amount end def charged_amount loan.commissionable_amount || loan.net_origination_fee_amount end private def max_commission_amount @max_commission_amount ||= tiers.each_cons(2).sum do |previous, current| next 0 if total_cumulative_amount <= previous.amount commission_charged_amount(previous, current) * current.rate / 100 end end def commission_charged_amount(previous, current) group.net_origination_fee_amount * cumulative_amount(previous, current) / total_cumulative_amount end def cumulative_amount(previous, current) [ total_cumulative_amount, current.amount ].min - previous.amount end def total_cumulative_amount if channel.calculation_method.sliding_scale_net_origination_fee? group.net_origination_fee_amount elsif channel.calculation_method.sliding_scale_origination_volume? group.total_amount end end def tiers @tiers ||= [ Plan::Channel::Tier.new(amount: 0, rate: 0) ] + channel.tiers + [ Plan::Channel::Tier.new(amount: Float::INFINITY, rate: channel.tiers.last.try(:rate) || 0) ] end end 
 class Commission::Calculator class << self def calculate(preimage) get(preimage).calculate end def get(preimage) calculator(preimage).new(preimage) end def calculator(preimage) if preimage.channel.calculation_method.sliding_scale? Commission::Calculator::SlidingScale elsif preimage.channel.calculation_method.flat? Commission::Calculator::Flat elsif preimage.channel.calculation_method.spread? Commission::Calculator::Spread end end end attr_reader :preimage delegate :group, :loan, :channel, :to => :preimage def initialize(preimage) @preimage = preimage end def calculate preimage.amount = rate * charged_amount / 100 end end 

The same should be done with TYPES constant. And after this our model looks dry and clean.

 class Plan::Channel < ApplicationRecord self.inheritance_column = nil belongs_to :plan Plan::Channel::Type.values.each do |type| scope type, -> { where(type: "#{type}") } end attribute :tiers, Plan::Channel::Tiers::Type.new attribute :calculation_method, Plan::Channel::CalculationMethod::Type.new attribute :type, Plan::Channel::Type::Type.new end 

The last thing that should be done is to rename plan_channels#type column. I guess source would be the correct name and after this we have a final version of our model.

 class Plan::Channel < ApplicationRecord belongs_to :plan Plan::Channel::Source.values.each do |source| scope source, -> { where(source: "#{source}") } end attribute :tiers, Plan::Channel::Tiers::Type.new attribute :calculation_method, Plan::Channel::CalculationMethod::Type.new attribute :source, Plan::Channel::Source::Type.new end 

Isn't this over-engineering? A whole new class instead of a string? Really? The answer to such a question is always context-dependent, but I rarely find that it is over-engineering.

You may argue that 15 minutes is a lot, compared to the zero minutes it would take you if you'd 'just' use a string. On the surface, that seems like a valid counter-argument, but perhaps you're forgetting that with a primitive string, you still need to write validation and business logic 'around' the string, and you have to remember to apply that logic consistently across your entire code base. My guess is that you'll spend more than 15 minutes on doing this, and on the troubleshooting defects that occur when someone forgets to apply one of those rules to a string in some other part of the code base.

Do you have a project for us?Get a free quote