Replace aasm with Rails Enum today

… and check why 5600+ Rails engineers read also this

Replace aasm with Rails Enum today

There’s a great chance that your Rails app contains one of the gems providing so called state machine implementation. There’s event a greater chance that it will be aasm formerly known as acts_as_state_machine. Btw. Who remembers acts_as_hasselhoff? — ok, boomer. The aasm does quite a lot when included into your ActiveRecord model — the question is do you really need all those things?

My problem with aasm

I was struck by reckless use of this gem so many times that first thing I do after joining a new project is running cat Gemfile | grep aasm and here comes the meme which I made ~1.5 years ago:

My main concern with use of this gem is that you probably don’t need all the features it offers. More features means more temptation to use them. Greater use across the codebase means more coupling to external library which can be incompatible with upcoming Rails versions, blocking you from upgrade or making it pretty costly. You have to read yet another changelog to check if there aren’t any breaking changes or some subtle behavior change running your serious business application into problems.

Next thing is that it promotes patterns like callbacks which I personally see as a huge problem within complex rails applications. I prefer explicit code, callbacks aren’t such. Ackchyually callbacks are a part of the framework, I know, but let’s leave this discussion for another place in time.

It’s also struggle for all the IDEs, Language Server Providers to find definitions of methods defined by aasm’s DSL and you are forced to use grep, read the code carefully and decide whether those active! method comes from or where my pending scope is defined.

I’ve recently learned that it also autogenerates constants for each state so I don’t have to. I’ve recently spotted Transaction::STATUS_FAILED somewhere, it took quite some time to figure out the origin of this constant which wasn’t explicitly defined within the Transaction class.

Those free lunches can be tasty, but eventually you will be forced to pay for them.

Last, but not least, having attribute like state or status often suggest poor design in your codebase, but that’s a totally different story.

Starting point

Nine years old legacy Rails app backing very successful business. Let’s have a look at some of the details:

-> ruby -v
ruby 3.3.0 (2023-12-25 revision 5124f9ac75)

-> bin/rails r "p Rails.version"
"7.1.3.2"

-> bundle list | wc -l
319

-> rg include\ AASM -l | wc -l
33

Few uses of model.aasm.states.map(&:name)

Two uses of SwitchRequest.aasm.states_for_select to provide options for some <select> tags.

Rails, the white knight

As a Rails developer, you’re probably familiar with enum which was introduced in Rails 6.0 and allowed to declare an attribute where the values map to integers in the database. It evolved a bit with next framework versions, but Rails 7.1 finally brought all the features required to replace all of the aasm uses in the codebase I’m currently working on.

Let’s make use of this simple class as an example for our further work:

class Transaction < ApplicationRecord
  include AASM

  PROCESSING = :processing
  FAILED = :failed
  SUCCESSFUL = :successful
  CANCELED = :canceled

  aasm column: :status do
    state PROCESSING, initial: true
    state FAILED
    state SUCCESSFUL
    state CANCELED
  end
end

What’s required to get exact the same behavior

  • Scopes like Transaction.successful to query all the transactions having status successful — we can have those for free from ActiveRecord::Enum.
  • Instance methods to:
    • check whether our object’s status is successful?enum got you covered
    • change the status to successful and run save! as we got used to it — same here, enum will do it’s job here if you call transaction.successful!
  • Set desired initial state for new objects — this can be done by providing default keyword argument to enum method, like default: PROCESSING.
  • Values need to be stored as strings, not as integers in the db — it’s possible since Rails 7.0
  • Hopefully there were no transitions or guards in state machines in our application — yet another reason to not employ aasm — but I can image implementing it with ActiveRecord::Dirty quite easy. Or even better, implement this behavior as a higher level abstracion and keep you model dummy in this matter.
  • No callbacks either, yay!
  • Constants containing all the possible states were already in place, so those defined by aasm, like STATUS_PROCESSING, STATUS_SUCCESSFUL and so on were something nobody asked for.

Validation of the provided value

There’s slight difference in default enum behavior. If the provided a value doesn’t match specified values, you will be struck with ArgumentError when trying to assign one.

As mentioned earlier, in Rails 7.1 there’s a possibility to provide validate: true keyword argument. Enum will behave exactly the same as aasm in this manner which checks the validity of provided value before save resulting in ActiveRecord::RecordInvalid instead.

Show me the code

class Transaction < ApplicationRecord
    PROCESSING = :processing
    FAILED = :failed
    SUCCESSFUL = :successful
    CANCELED = :canceled

    enum :status,
         { processing: PROCESSING,
           failed: FAILED,
           successful: SUCCESSFUL,
           canceled: CANCELED
         }.transform_values(&:to_s),
         default: PROCESSING.to_s,
         validate: true
end

That’s it. Quit clean and understandable, isn’t it?

The values quirk

There’s one quirk, you’ve probably already noticed: transform_values(&:to_s). I was quite confused what’s going on when I’ve provided symbols as values initially. Let’s see how the code looked like before:

class Transaction < ApplicationRecord
    PROCESSING = :processing
    FAILED = :failed
    SUCCESSFUL = :successful
    CANCELED = :canceled

    enum :status,
         { processing: PROCESSING,
           failed: FAILED,
           successful: SUCCESSFUL,
           canceled: CANCELED
         },
         default: PROCESSING,
         validate: true
end

The documentation hasn’t clearly stated that values should be strings. The example used String values, but there was no clear expectation about that. However, when saving the object, status became nil at some point, despite assigning ”successful” value.

I wrote a dummy app and test for this specific case aside from the main application I was working on. To be 100% sure that there’s no other factor influences this behavior:

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'

  gem 'activerecord'
  gem 'sqlite3'
  gem 'minitest'
end

require 'active_record'
require 'sqlite3'
require 'minitest/autorun'

begin
  db_name = 'enum_test.sqlite3'.freeze

  SQLite3::Database.new(db_name)
  ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: db_name)
  ActiveRecord::Schema.define do
    create_table :transactions, force: true do |t|
      t.string :status
    end
  end

  class Transaction < ActiveRecord::Base
    PROCESSING = :processing
    FAILED = :failed
    SUCCESSFUL = :successful
    CANCELED = :canceled

    enum :status,
         { processing: PROCESSING,
           failed: FAILED,
           successful: SUCCESSFUL,
           canceled: CANCELED
         },
         default: PROCESSING,
         validate: true
  end

  class TestTransaction < Minitest::Test
    def test_enum_behavior
      transaction = Transaction.new(status: :successful)
      assert_equal 'successful', transaction.status

      transaction.save!
      assert_equal 'successful', transaction.reload.status
    end
  end
ensure
  Dir.glob(db_name + '*').each { |file| File.delete(file) }
end

Result:

ruby enum.rb
-- create_table(:transactions, {:force=>true})
   -> 0.0076s
Run options: --seed 3038

# Running:

F

Finished in 0.013456s, 74.3163 runs/s, 148.6326 assertions/s.

  1) Failure:
TestTransaction#test_enum [enum.rb:44]:
Expected: "successful"
  Actual: nil

1 runs, 2 assertions, 1 failures, 0 errors, 0 skips

Ok, so the value is correctly set, but it disappears when save! is called. After quick session with debugger I was able to figure out that undesired change from "successful" to nil happens inside Enum::EnumType#deserialize method. Generic ActiveModel::Type::Value class which is a parent for the ActiveRecord::Enum::EnumType describes the purpose of deserialize method like that:

Converts a value from database input to the appropriate ruby type. The return value of this method will be returned from ActiveRecord::AttributeMethods::Read#read_attribute. The default implementation just calls Value#cast.

Let’s have a look at our specific scenario:

# value = "successful"
# mapping = ActiveSupport::HashWithIndifferentAccess.new({
#  "processing" => :processing,
#  "failed" => :failed,
#  "successful" => :successful,
#  "canceled" => :canceled,
# })
# subtype = ActiveModel::Type::String instance

def deserialize(value)
  mapping.key(subtype.deserialize(value))
end

What exactly happens:

  1. subtype.deserialize(value) returns ”successful”
  2. mapping is asked to return a key for ”successful” value, but there is no such in the hash, there’s a Symbol :succesful — yikes
  3. deserialize("successful") returns nil instead of "successful"

Probably a single line in the docs like: Don’t put symbols as values when defining the enum would do the job and save my time and potentially many other confused developers. What’s even more puzzling is the fact that you can provide default value as a Symbol, you can assign a value which is a Symbol and it will be stored as a String, which is understandable, but the definition has to contain string values — hence the .transform_values(&:to_s) trick.

However, why use symbols to define possible state?, you may ask. Because it’s a requirement of aasm. State has to be Symbol or object responding to #name method. If you provide String, you’ll see nice NoMethodError because it doesn’t respond to name.

You know what’s even funnier? Database returns String when asked for Transactions#status. Serializing it to JSON will also turn it into String as JSON doesn’t implement symbols. I could imagine more scenarios when constant casting from Symbol to String back and forth happen without any particular reason. But that’s exactly how 3rd party gem (AASM) drives your architectural decisions.

We can do even better

If you aren’t using scopes, you can simply disable them with scopes: false.

Same goes for instance methods like succesful! or failed?, those can be disabled with instance_methods: false.

Most of the models that was rewritten required both, but whenever it was possible I disabled both of the features.

Occurrences of Model.aasm.states.map(&:name) are replaceable by Model.statuses.values.

Model.aasm.states_for_select helper can be replaced with Model.statuses.values.map { |name, value| [I18n.l(name), value] }. Extract this to one of your helpers. Maybe you don’t need to call I18n at all and simple humanize will be enough.

Now it’s your turn.

You might also like