Skip to main content Link Search Menu Expand Document (external link)

AASM

AASM is another popular gem that we utilize in our application. AASM helps us handle more complicated model process states.

In this article, we will install aasm in our application and learn how it works.

Table of contents

  1. What is AASM?
  2. How to install aasm
  3. Usage
  4. Callback
  5. Guard

What is AASM?

AASM started as acts_as_state_machine plugin, is a Ruby package that allows you to add finite state machines to your classes. A state machine may store all possible states of something as well as the transitions between them.

How to install aasm

Reminder:

Make sure that your containers are up and running.

In your Gemfile, add gem aasm.

 gem 'aasm'

Then run bundle install.

 root@0122:/usr/src/app# bundle install
Fetching gem metadata from https://rubygems.org/..........
Resolving dependencies...
...
Installing aasm 5.4.0
Bundle complete! 23 Gemfile dependencies, 95 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

Usage

Adding a state machine is as simple as adding the AASM module and starting to define states, events, and their transitions.

Before we add the aasm, let’s create an order that belongs to users. your order will have amount (decimal), serial_number (string), and state (string) columns.

 root@0122:/usr/src/app# rails g model Order
      invoke  active_record
      create    db/migrate/xxxxxxxxxxxxxx_create_orders.rb
      create    app/models/order.rb
# db/migrate/xxxxxxxxxxxxxx_create_orders.rb

class CreateOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :orders do |t|
      t.decimal :amount, precision: 12, scale: 2
      t.string :serial_number
      t.references :user
      t.string :state
      t.timestamps
    end
  end
end

Additionally, we will add a balance (decimal) column to our users table.

 root@0122:/usr/src/app# rails g migration AddBalanceToUser
      invoke  active_record
      create    db/migrate/xxxxxxxxxxxxxx_add_balance_to_user.rb
# db/migrate/xxxxxxxxxxxxxx_add_balance_to_user.rb

class AddBalanceToUser < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :balance, :decimal, precision: 18, scale: 2, default: 0
  end
end

Then migrate.

 root@0122:/usr/src/app# rails db:migrate
== xxxxxxxxxxxxxx CreateOrders: migrating =====================================
-- create_table(:orders)
   -> 0.0099s
== xxxxxxxxxxxxxx CreateOrders: migrated (0.0100s) ============================

== xxxxxxxxxxxxxx AddBalanceToUser: migrating =================================
-- add_column(:users, :balance, :decimal, {:precision=>18, :scale=>2, :default=>0})
   -> 0.0090s
== xxxxxxxxxxxxxx AddBalanceToUser: migrated (0.0091s) ========================

Then add their respective associations.

# app/models/order.rb

class Order < ApplicationRecord
  belongs_to :user
end
# app/models/user.rb

class User < ApplicationRecord
  # ...
+ has_many :orders
  enum genre: { client: 0, admin: 1 }
end

Now that we finished creating our order model, we can now add the aasm. Setup aasm to state column in our order model.

# app/models/order.rb

class Order < ApplicationRecord
+ include AASM
  belongs_to :user
+
+ aasm column: :state do
+   state :pending, initial: true
+   state :submitted, :paid, :failed, :revoked
+ 
+   event :submit do
+     transitions from: :pending, to: :submitted
+   end
+ 
+   event :pay do
+     transitions from: :submitted, to: :paid
+   end
+
+   event :fail do
+     transitions from: [:pending, :submitted], to: :failed
+   end
+
+   event :revoke do
+     transitions from: [:pending, :submitted], to: :revoked
+   end
+ end
end

This gives us a few of public methods for our order instance. Let’s try it out.

irb(main):001:0> order = User.first.orders.build
  User Load (0.4ms)  SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
=> #<Order:0x00005598c88311a0 id: nil, amount: nil, serial_number: nil, user_id: 1, state: "pending", created_at: nil, updated_at: nil>
irb(main):002:0> order.pending?       #=> true
irb(main):003:0> order.may_submit?    #=> true
irb(main):004:0> order.submit         #=> true
irb(main):005:0> order.submit         #=> true
irb(main):006:0> order.submitted?     #=> true
irb(main):007:0> order.may_pay?       #=> true
irb(main):008:0> order.pay            #=> true
irb(main):009:0> order.paid?          #=> true
irb(main):010:0> order.may_fail?      #=> false
irb(main):011:0> order.fail
/usr/local/bundle/gems/aasm-5.4.0/lib/aasm/aasm.rb:202:in `aasm_failed': Event 'fail' cannot transition from 'paid'. (AASM::InvalidTransition)
irb(main):012:0> order.may_revoke?    #=> false
irb(main):013:0> order.revoke
/usr/local/bundle/gems/aasm-5.4.0/lib/aasm/aasm.rb:202:in `aasm_failed': Event 'revoke' cannot transition from 'paid'. (AASM::InvalidTransition)

Assign serial number

Let us assign a serial number to each individual order so that we can differentiate certain orders from the rest.



class Order < ApplicationRecord
  include AASM
  belongs_to :user

+ after_create :assign_serial_number
+
  aasm column: :state do
    # ...
  end
+
+ private
+ 
+ def assign_serial_number
+   self.update(serial_number: "gem-#{id.to_s.rjust(9, '0')}")
+ end
end

Read Carefully:

Method rjust returns a new String of length integer with str right justified and padded with padstr if integer is more than str; else, it returns str.

Try it out on console.

irb(main):001:0> user = User.client.first
  User Load (0.3ms)  SELECT `users`.* FROM `users` WHERE `users`.`genre` = 0 ORDER BY `users`.`id` ASC LIMIT 1
=> #<User id: 2, email: "sid.klocko@bednar-koss.net", created_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", updated_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", genre: "client", balance: 0.0>
irb(main):002:0> order = user.orders.build
=> #<Order:0x000055f650f12e48 id: nil, amount: nil, serial_number: nil, user_id: 2, state: "pending", created_at: nil, updated_at: nil>
irb(main):003:0> order.save
  TRANSACTION (0.2ms)  BEGIN
  Order Create (0.4ms)  INSERT INTO `orders` (`amount`, `serial_number`, `user_id`, `state`, `created_at`, `updated_at`) VALUES (NULL, NULL, 2, 'pending', 'xxxx-xx-xx xx:xx:xx.xxxxxx', 'xxxx-xx-xx xx:xx:xx.xxxxxx')                                                                              
  Order Update (0.3ms)  UPDATE `orders` SET `orders`.`serial_number` = 'gem-000000001', `orders`.`updated_at` = 'xxxx-xx-xx xx:xx:xx.xxxxxx' WHERE `orders`.`id` = 1
  TRANSACTION (0.6ms)  COMMIT                                                                 
=> true

Callback

You can provide a number of callbacks for your transitions. When certain requirements, such as entering a specific state, are satisfied, these procedures will be performed.

Let’s try adding a callback that will adjust the user balance depending on the orders event.

# app/models/order.rb

class Order < ApplicationRecord
  # ...
  aasm column: :state do
    # ...
    event :pay do
-     transitions from: :submitted, to: :paid
+     transitions from: :submitted, to: :paid, after: :revise_balance
    end
    # ...
    event :revoke do
-     transitions from: [:pending, :submitted], to: :revoked
+     transitions from: :paid, to: :revoked, after: :deduct_balance
    end
  end
+
+ def revise_balance
+   user.update(balance: user.balance + amount)
+ end
+
+ def deduct_balance
+   user.update(balance: user.balance - amount)
+ end
  # ...
end

The callback that we made will deduct orders amount to the user balance when orders state transitions to revoked, while it will increase the user balance based on orders amount when your orders state transitions to paid.

Let’s try it out on console.

irb(main):001:0> user = User.client.first
irb(main):002:0> user.balance     #=> 0.0
  User Load (0.5ms)  SELECT `users`.* FROM `users` WHERE `users`.`genre` = 0 ORDER BY `users`.`id` ASC LIMIT 1
=> #<User id: 2, email: "sid.klocko@bednar-koss.net", created_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", updated_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", genre: "client", balance: 0.0>
irb(main):003:0> order = user.orders.build(amount: 80, state: :submitted)
=> #<Order:0x00007fc554745ee8 id: nil, amount: 0.8e2, serial_number: nil, user_id: 2, state: "submitted", created_at: nil, updated_at: nil>
irb(main):004:0> order.save!
  TRANSACTION (0.3ms)  BEGIN
  Order Create (0.4ms)  INSERT INTO `orders` (`amount`, `serial_number`, `user_id`, `state`, `created_at`, `updated_at`) VALUES (80.0, NULL, 2, 'submitted', 'xxxx-xx-xx xx:xx:xx.xxxxxx', 'xxxx-xx-xx xx:xx:xx.xxxxxx')
  Order Update (0.3ms)  UPDATE `orders` SET `orders`.`serial_number` = 'gem-000000002', `orders`.`updated_at` = 'xxxx-xx-xx xx:xx:xx.xxxxxx' WHERE `orders`.`id` = 2
  TRANSACTION (0.7ms)  COMMIT
=> true
irb(main):005:0> order.pay!
  TRANSACTION (0.2ms)  BEGIN
  User Update (0.3ms)  UPDATE `users` SET `users`.`updated_at` = 'xxxx-xx-xx xx:xx:xx.xxxxxx', `users`.`balance` = 80.0 WHERE `users`.`id` = 2
  Order Update (0.3ms)  UPDATE `orders` SET `orders`.`state` = 'paid', `orders`.`updated_at` = 'xxxx-xx-xx xx:xx:xx.xxxxxx' WHERE `orders`.`id` = 2
  TRANSACTION (0.9ms)  COMMIT                                    
=> true                                                          
irb(main):006:0> user.reload
  User Load (0.4ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1
=> #<User id: 2, email: "sid.klocko@bednar-koss.net", created_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", updated_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", genre: "client", balance: 0.8e2>
irb(main):007:0> user.balance     #=> 0.8e2
irb(main):008:0> order = user.orders.build(amount: 30, state: :submitted)
=> #<Order:0x00007fc5549cff60 id: nil, amount: 0.3e2, serial_number: nil, user_id: 2, state: "submitted", created_at: nil, updated_at: nil>
irb(main):009:0> order.save!
  TRANSACTION (0.3ms)  BEGIN
  Order Create (0.4ms)  INSERT INTO `orders` (`amount`, `serial_number`, `user_id`, `state`, `created_at`, `updated_at`) VALUES (30.0, NULL, 2, 'submitted', 'xxxx-xx-xx xx:xx:xx.xxxxxx', 'xxxx-xx-xx xx:xx:xx.xxxxxx')
  Order Update (0.4ms)  UPDATE `orders` SET `orders`.`serial_number` = 'gem-000000003', `orders`.`updated_at` = 'xxxx-xx-xx xx:xx:xx.xxxxxx' WHERE `orders`.`id` = 3
  TRANSACTION (0.8ms)  COMMIT
=> true                                         
irb(main):010:0> order.revoke!
  TRANSACTION (0.3ms)  BEGIN
  User Update (0.4ms)  UPDATE `users` SET `users`.`updated_at` = 'xxxx-xx-xx xx:xx:xx.xxxxxx', `users`.`balance` = 50.0 WHERE `users`.`id` = 2
  Order Update (0.4ms)  UPDATE `orders` SET `orders`.`state` = 'revoked', `orders`.`updated_at` = 'xxxx-xx-xx xx:xx:xx.xxxxxx' WHERE `orders`.`id` = 3
  TRANSACTION (0.7ms)  COMMIT
=> true
irb(main):012:0> user.reload
  User Load (0.5ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1
=> #<User id: 2, email: "sid.klocko@bednar-koss.net", created_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", updated_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", genre: "client", balance: 0.5e2>
irb(main):011:0> user.balance     #=> 0.5e2

Guard

AASM lets you define a condition before transition with the use of guard. Guard runs before running the actual transition, and in the instance that the guard returns false, transition will be denied (raising AASM::InvalidTransition or returning false itself).

Read Carefully:

Looking back in the callback that we made, the deduct_balance might incur a negative balance on a user when users balance is 0 and they made an order that transitioned to revoked.

To prevent this kind of bug, we will use guard that will check if user have a enough balance.

# app/models/order.rb

class Order < ApplicationRecord
  # ...
  aasm column: :state do
    # ...
    event :pay do
-     transitions from: :submitted, to: :paid, after: :revise_balance
+     transitions from: :submitted, to: :paid, success: :revise_balance
    end
    # ...
    event :revoke do
      transitions from: [:pending, :submitted], to: :revoked
-     transitions from: :paid, to: :revoked, after: :deduct_balance
+     transitions from: :paid, to: :revoked, guard: :balance_enough?, success: :deduct_balance
    end
  end
  # ...
+
+ def balance_enough?
+   user.balance >= amount
+ end

  private
  # ...
end

We also changed the callback from after to success so it will only call the methods we specified when order transitioned successfully.

Check it out on console.

irb(main):001:0> user = User.client.first
  User Load (0.3ms)  SELECT `users`.* FROM `users` WHERE `users`.`genre` = 0 ORDER BY `users`.`id` ASC LIMIT 1
=> #<User id: 2, email: "sid.klocko@bednar-koss.net", created_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", updated_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", genre: "client", balance: 0.5e2>
irb(main):002:0> user.balance   #=> 0.5e2
irb(main):003:0> order = user.orders.build(amount: 51, state: :submitted)
=> #<Order:0x00007f2ac4173ba8 id: nil, amount: 0.51e2, serial_number: nil, user_id: 2, state: "submitted", created_at: nil, updated_at: nil>
irb(main):004:0> order.save!
  TRANSACTION (0.3ms)  BEGIN
  Order Create (0.4ms)  INSERT INTO `orders` (`amount`, `serial_number`, `user_id`, `state`, `created_at`, `updated_at`) VALUES (51.0, NULL, 2, 'submitted', 'xxxx-xx-xx xx:xx:xx.xxxxxx', 'xxxx-xx-xx xx:xx:xx.xxxxxx')
  Order Update (0.3ms)  UPDATE `orders` SET `orders`.`serial_number` = 'gem-000000004', `orders`.`updated_at` = 'xxxx-xx-xx xx:xx:xx.xxxxxx' WHERE `orders`.`id` = 4
  TRANSACTION (0.9ms)  COMMIT
=> true
irb(main):005:0> order.revoke!
/usr/local/bundle/gems/aasm-5.4.0/lib/aasm/aasm.rb:202:in `aasm_failed': Event 'revoke' cannot transition from 'submitted'. Failed callback(s): [:balance_enough?]. (AASM::InvalidTransition)
irb(main):006:0> user.reload
  User Load (0.5ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1
=> #<User id: 2, email: "sid.klocko@bednar-koss.net", created_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", updated_at: "xxxx-xx-xx xx:xx:xx.xxxxxxxxx +xxxx", genre: "client", balance: 0.5e2>
irb(main):007:0> user.balance   #=> 0.5e2

Now we successfully integrated AASM in our application. To know more about AASM, you can read AASM - Ruby state machines.


Back to top

Copyright © 2020-2022 Secure Smarter Service, Inc. This site is powered by KodaCamp.