Skip to content

Instantly share code, notes, and snippets.

@eprothro
Created March 27, 2020 15:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save eprothro/6f1db38176717165620f8ba4e0b3f613 to your computer and use it in GitHub Desktop.
Save eprothro/6f1db38176717165620f8ba4e0b3f613 to your computer and use it in GitHub Desktop.

This is a work in progress.

Domain and Persistence Separation

Our primary motivation for this architecture is separating Domain and Persistence concepts.

  1. 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)
  1. 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.

Exception Flow Control

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

Validation Errors

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

Nested Attributes / Models

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment