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
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
rjustreturns a new String of length integer withstrright justified and padded withpadstrif integer is more thanstr; else, it returnsstr.
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_balancemight incur a negative balance on a user when users balance is0and they made an order that transitioned torevoked.
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.