/solid_use_case

A flexible use case pattern that works *with* your workflow, not against it.

Primary LanguageRubyMIT LicenseMIT

Solid Use Case

Solid Use Case is a gem to help you implement well-tested and flexible use cases. Solid Use Case is not a framework - it's a design pattern library. This means it works with your app's workflow, not against it.

See the Austin on Rails presentation slides

Installation

Add this line to your application's Gemfile:

gem 'solid_use_case', '~> 2.2.0'

And then execute:

$ bundle

Or install it yourself as:

$ gem install solid_use_case

Usage

At its core, this library is a light wrapper around Deterministic, a practical abstraction over the Either monad. Don't let that scare you - you don't have to understand monad theory to reap its benefits.

The only thing required is using the #steps method:

Rails Example

class UserSignup
  include SolidUseCase

  steps :validate, :save_user, :email_user

  def validate(params)
    user = User.new(params[:user])
    if !user.valid?
      fail :invalid_user, :user => user
    else
      params[:user] = user
      continue(params)
    end
  end

  def save_user(params)
    user = params[:user]
    if !user.save
      fail :user_save_failed, :user => user
    else
      continue(params)
    end
  end

  def email_user(params)
    UserMailer.async.deliver(:welcome, params[:user].id)
    # Because this is the last step, we want to end with the created user
    continue(params[:user])
  end
end

Now you can run your use case in your controller and easily respond to the different outcomes (with pattern matching!):

class UsersController < ApplicationController
  def create
    UserSignup.run(params).match do
      success do |user|
        flash[:success] = "Thanks for signing up!"
        redirect_to profile_path(user)
      end

      failure(:invalid_user) do |error_data|
        render_form_errors(error_data, "Oops, fix your mistakes and try again")
      end

      failure(:user_save_failed) do |error_data|
        render_form_errors(error_data, "Sorry, something went wrong on our side.")
      end

      failure do |exception|
        flash[:error] = "something went terribly wrong"
        render 'new'
      end
    end
  end

  private

  def render_form_errors(user, error_message)
    @user = user
    @error_message = error_message
    render 'new'
  end
end

Control Flow Helpers

Because we're using consistent successes and failures, we can use different functions to gain some nice control flow while avoiding those pesky if-else statements :)

#check_exists

check_exists (alias maybe_continue) allows you to implicitly return a failure when a value is nil:

# NOTE: The following assumes that #post_comment returns a Success or Failure
video = Video.find_by(id: params[:video_id])
check_exists(video).and_then { post_comment(params) }

# NOTE: The following assumes that #find_tag and #create_tag both return a Success or Failure
check_exists(Tag.find_by(name: tag)).or_else { create_tag(tag) }.and_then { ... }

# If you wanted, you could refactor the above to use a method:
def find_tag(name)
  maybe_continue(Tag.find_by(name: name))
end

# Then, elsewhere...
find_tag(tag)
.or_else { create_tag(tag) }
.and_then do |active_record_tag|
  # At this point you can safely assume you have a tag :)
end

#check_each

If you're iterating through an array where each item could fail, #check_each might come in handy. A key point is that check_each will only fail if you return a failure; You don't need to return a continue().

Returning a failure within a #check_each block will short-circuit the loop.

def validate_score(score)
  fail :score_out_of_range unless score.between?(0,100)
end

input = [10, 50, 104, 3]

check_each(input) {|s| validate_score(s)}.and_then do |scores|
  write_to_db_or_whatever(scores)
end

If you need to continue with a value that is different from the array, you can use continue_with:. This is useful when you want to check a subset of your overall data.

params = { game_id: 7, scores: [10,50] }

check_each(params[:scores], continue_with: params) {|s|
  validate_score(s)
}.and_then {|foo|
  # Here `foo` is the same value as `params` above
}

#attempt

attempt allows you to catch an exception. It's useful when you want to attempt something that might fail, but don't want to write all that exception-handling boilerplate.

attempt also auto-wraps your values; in other words, the inner code does not have to return a success or failure.

For example, a Stripe API call:

# Goal: Only charge customer if he/she exists
attempt {
  Stripe::Customer.retrieve(some_id)
}
.and_then do |stripe_customer|
  stripe_customer.charge(...)
end

RSpec Matchers

If you're using RSpec, Solid Use Case provides some helpful matchers for testing.

First you mix them them into RSpec:

# In your spec_helper.rb
require 'solid_use_case'
require 'solid_use_case/rspec_matchers'

RSpec.configure do |config|
  config.include(SolidUseCase::RSpecMatchers)
end

And then you can use the matchers, with helpful error messages:

describe MyApp::SignUp do
  it "runs successfully" do
    result = MyApp::SignUp.run(:username => 'alice', :password => '123123')
    expect(result).to be_a_success
  end

  it "fails when password is too short" do
    result = MyApp::SignUp.run(:username => 'alice', :password => '5')
    expect(result).to fail_with(:invalid_password)

    # The above `fail_with` line is equivalent to:
    # expect(result.value).to be_a SolidUseCase::Either::ErrorStruct
    # expect(result.value.type).to eq :invalid_password

    # You still have access to your arbitrary error data
    expect(result.value.something).to eq 'whatever'
  end
end

Testing

$ bundle exec rspec

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request