Design Trade-offs with Rails + AASM
When building a business system with Rails, you eventually encounter a familiar question.
Suppose there is a process that completes a sale transaction.
- Change the transaction state from fulfilling to completed
- Update the fulfillment record
- Change the payment state
- Record that the transaction was completed
- Send an email if necessary
How much of this should be written in the Model?
Should calling
trade.complete!
complete the business process?
Or should you create a UseCase such as
CompleteTrade.call(trade_id)
and collect the transaction and associated-record updates there?
The answer is not determined solely by whether you use AASM. AASM is a component that adds a state machine to Ruby classes or ActiveRecord Models; it is not an architecture by itself. AASM README
What matters is where you place business verbs, state transitions, transactions, and external side effects.
Express the Business Before the Implementation
Using a sale transaction as an example, the business can be expressed as follows.
| Element | Description |
|---|---|
| Business unit | Sale transaction |
| States | listed, reserved, paid, fulfilling, completed |
| Business verbs | List, purchase, pay, start fulfillment, complete |
| Invariant | A completed transaction cannot be cancelled |
| Associated updates | Payment, fulfillment, completion record |
| External side effects | Email, external payment, search-index update |
flowchart LR
A[Transaction] --> B[Business verbs]
A --> C[State]
B --> D[Associated record updates]
B --> E[Email and external side effects]
The difference between the approaches comes from where these business elements are placed.
1. Rails MVC + Rich ActiveRecord Model
The first approach is to gather the state and business verbs in the ActiveRecord Model that represents the business unit.
class Trade < ApplicationRecord
include AASM
aasm column: :status do
state :listed, initial: true
state :reserved
state :paid
state :fulfilling
state :completed
event :reserve do
transitions from: :listed, to: :reserved,
guard: :purchasable?
end
event :complete, after: :record_completion do
transitions from: :fulfilling, to: :completed
end
end
def record_completion
fulfillment_record.complete!
completion_record.create!
end
end
The Controller calls the business verb.
def complete
@trade.complete!
TradeCompletedMailer.notify(@trade).deliver_later
redirect_to @trade
end
In this structure, the Model knows:
- Which states a transaction has
- Which state transitions are allowed
- The business verb of completing a transaction
- Which associated records must always be updated on completion
With AASM’s ActiveRecord integration, state transitions and persistence can be handled as a transaction. If a transition callback or state update fails, related database changes can be rolled back. AASM Transaction Support
The important point is that updating multiple tables does not automatically mean the logic must not live in the Model.
If the updates move several associated records into a consistent state in order to complete one transaction—the single business unit they belong to—they can be read as a business rule belonging to the Trade Model.
Strengths
- Business knowledge and the business unit stay together
- States and business verbs can be read in one place
- trade.complete! is a natural expression
- A sequence of changes involving associated records can be handled together
- Controllers stay thin
- You do not need to open many files to follow the business process
Weaknesses
- The Model can grow large
- The team needs rules for adding processing to AASM callbacks
- Putting email or external APIs in the Model expands its responsibility
- Side effects that happen outside the Model become harder to trace
- If the process is called from a non-web entry point, you must decide how to handle side effects such as email
This approach prioritizes cohesion of business knowledge around the business unit.
2. Rails MVC + DDD-oriented Domain Model / UseCase
Another approach is to separate the business model from the UseCase that represents the whole process. You keep Rails Controllers and Views while extracting part of the business process into a Domain Model or UseCase.
flowchart LR
C[Controller] --> U[CompleteTrade UseCase]
U --> R[Repository / ActiveRecord]
R --> D[Domain Model]
U --> T[DB transaction]
U --> O[Email / Outbox / Job]
The Domain Model represents the business unit of the transaction.
class Trade
include AASM
attr_reader :id
attr_accessor :status
def initialize(id:, status:)
@id = id
@status = status.to_sym
end
aasm column: :status do
state :fulfilling
state :completed
event :complete do
transitions from: :fulfilling, to: :completed
end
end
end
The UseCase loads the Domain Model, executes the business verb, and persists the result. The following code simplifies the Repository and transaction implementations.
class CompleteTrade
def call(trade_id)
TradeRecord.transaction do
record = trade_repository.find(trade_id)
trade = record.to_domain
trade.complete
trade_repository.save(trade)
outbox_repository.append!(
event_type: "trade_completed",
trade_id: trade.id
)
end
end
end
In this structure, the Domain Model owns states and invariants, while the UseCase owns the overall process, including persistence.
- The transaction boundary
- Loading data from the Repository
- Operating on the Domain Model
- Coordinating multiple business objects
- Recording an Outbox event or Job
- Creating an event to pass to an external process
A thin UseCase is not automatically a bad thing. If it makes the transaction boundary or the application-level entry point explicit, it has a role even when it contains little code.
However, if a UseCase only calls one Model method and owns neither a transaction nor an external side effect, a Model-centered design may be simpler.
Strengths
- Transaction boundaries are explicit
- Processes involving multiple business objects are easier to express
- Side effects such as email and external APIs are easier to separate
- The same process is easier to call from non-Controller entry points
- The overall sequence is readable in the UseCase
Weaknesses
- You need to read multiple files such as the Model, UseCase, and Repository
- The location of the business verb becomes harder to find
- UseCases tend to become thin
- You need rules for deciding whether something belongs in the Model or UseCase
- The cognitive load of following one business process increases
Being DDD-oriented inside Rails MVC does not necessarily mean eliminating Rails or ActiveRecord. You can use a Rails Model as an Aggregate while extracting UseCases or Domain Models only where it helps.
3. A Hybrid Form Using Both Model and UseCase
In practice, a design may use both.
For example, you could define rules like these.
| Process | Location |
|---|---|
| State definitions | Model + AASM |
| State-transition conditions | Model + AASM |
| Updates inside the Aggregate | Model |
| Coordination across multiple business units | UseCase |
| Transaction boundary | UseCase |
| Email and external APIs | UseCase or Job |
This approach may provide the strengths of both, but it is not free.
Every time, the implementer has to decide:
- Is this a state transition?
- Is this a business rule that accompanies a state transition?
- Is this an update inside the Aggregate?
- Is this coordination across multiple business units?
- Is this the transaction boundary?
- Is this an external side effect?
flowchart TD
A[Write the process] --> B{What kind of process is it?}
B -->|State transition| C[Model + AASM]
B -->|Update inside Aggregate| D[Model]
B -->|Coordinate multiple units| E[UseCase]
B -->|Email or external API| F[UseCase / Job]
If everyone on the team understands these rules, the split can be clean. But compared with a simple Model-centered design, the cognitive load is higher because there are more rules to remember.
The hybrid form is not “the correct answer that gives you both benefits.”
It is a design that accepts the rules and cognitive load required to gain both Model cohesion and explicit UseCase boundaries.
The Choice Depends on What You Prioritize
| Priority | Suitable structure |
|---|---|
| Gather business knowledge around the business unit | Rich ActiveRecord Model |
| Read states and business verbs in the same place | Rich ActiveRecord Model |
| Avoid following a process across multiple files | Rich ActiveRecord Model |
| Make the transaction boundary explicit | UseCase-centered |
| Manage external side effects | UseCase-centered |
| Call the same process from multiple entry points | UseCase-centered |
| Separate the Model and process sequence in detail | Model + UseCase |
| Accept more placement rules | Model + UseCase |
The important thing is not to decide “Model or UseCase?” first.
- What is the business unit?
- Which business verbs belong to that unit?
- Which states and invariants must be protected together?
- Which database changes should form one atomic unit?
- Where do external side effects begin?
If you ask these questions in this order, a design that puts AASM in a Rails Model and a design that separates work into a Domain Model or UseCase can both be explained from the same business structure.
Summary
Rails MVC can gain cohesion and locality by keeping the business unit and its business verbs close to the Model. AASM can express the states and permitted business verbs of that unit.
When you split the design in a DDD-oriented way, you separate the business model from persistence and process coordination. Both approaches can be implemented inside Rails MVC; the difference is how far you separate business logic from persistence and processing order.
A design that uses both approaches can gain benefits from each, but it also adds rules for deciding where each piece belongs.
Ultimately, choosing a design means deciding what to prioritize.
- If you prioritize cohesion of business knowledge, choose a Model-centered design.
- If you prioritize explicit transaction and process boundaries, choose a UseCase-centered design.
- If you prioritize both, use both while accepting the placement rules and cognitive load.
The important question is not which design is universally correct.
What do you want to keep in one place? Which boundaries do you want to make explicit? How many rules are you willing to remember to get there?
Once those choices are clear, you can select a Model-centered, UseCase-centered, or hybrid design.