This is a work in progress.
Our primary motivation for this architecture is separating Domain and Persistence concepts.
- When Domain and Persistence logic are mixed together in ActiveRecord Models, the resulting code is hard to reason about, difficult to test well, and resists change.
- AR Callbacks are a hellish way to manage domain logic
- Managing different validation contexts is not straightforward
- There is no clear Domain API (everything is a Model#save side effect)
- When there is no clear Domain API, controllers or models become fat, slow, brittle, or all three.
- Queries and Domain commands tend to be implicit, they are hidden in the implementation of controller actions
- Implicit interfaces don't get test coverage
- These interfaces tend to be duplicated with no assurance of parity
def create
#...
@user = User.new(user_params)
Account.create(@user)
Analytics.track('user.creation', @user)
#...
end
def create
@user = User.new(user_params)
Domain::Account.create(@user)
end
This is great. We've made an explict Domain API where we can put a set of unified commands and queries. We can create tests and let the Controller (or rake file?, worker?) that "I created an account" without knowning how.
We could implement this simple command pattern straight away in app/lib
. However, there are a few considerations.
While we're replacing Account#save
with Domain::Account.create(@user)
, we have a decision to make.
Are we going to propagate the pattern of supporting Account#save
and Account#save!
? This pattern says that there is a difference in kind between validation errors and other kinds of errors.
This results in a paradigm for error handling and a separate paradigm for exception handling.
If instead we decide that Domain::Account.create(@user)
will return the succesfully created Account
object or raise, we have to decide how we're going to support getting error information from a validation Exception.
def create
@user = User.new(user_params)
if @user.save
redirect_to @user
else
render "view that expects @user.errors to be populated with validation errors", status: bad_request
end
end
def create
@user = Domain::User.create(user_params)
redirect_to @user
rescue ActiveRecord::RecordInvalid
render "#new view that expects @user.errors to be populated with validation errors", status: bad_request
end
In the previous example, @user
will be nil
when new
view is rendered. We need a way to inform the view of the validation errors.
def create
@user = Domain::User.create(user_params)
redirect_to @user
rescue ActiveRecord::RecordInvalid => ex
@user = ex.record
render "#new view that expects @user.errors to be populated with validation errors", status: bad_request
end
def create
@user = User.new(user_params)
Domain::User.create(@user)
redirect_to @user
rescue ActiveRecord::RecordInvalid
render "#new view that expects @user.errors to be populated with validation errors", status: bad_request
end
Here we rely on Domain::User.create
populating @user.errors