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)
#...
enddef create
@user = User.new(user_params)
Domain::Account.create(@user)
endThis 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
enddef 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
endIn 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
enddef 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
endHere we rely on Domain::User.create populating @user.errors