Skip to content
Rodrigo Álvarez edited this page May 30, 2019 · 2 revisions

Rationale behind Injectable

The idea is to encapsulate User Stories in Service Objects.

Made up example

The product team of a given sports website wants a new feature:

Feature: Adding players to team rosters.

As a team manager
In order to manage my team
I want to be able to add new players to my team’s roster

Acceptance criteria:

  • The user must exist
  • The team must exist
  • The team must be accepting new players

This could be a PORO implementation:

class AddPlayerToTeamRoster
  attr_reader :player_id, :team_id, :player_query, :team_query

  def self.call(team_query: nil, player_query: nil, player_id:, team_id:)
    player_query ||= UserQuery.new
    team_query   ||= TeamQuery.new

    new(
      team_query: team_query,
      player_query: player_query
    ).call(player_id: player_id, team_id: team_id)
  end

  def initialize(team_query:, player_query:)
    @team_query = team_query
    @player_query = player_query
  end

  def call(player_id:, team_id:)
    @team_id = team_id
    @player_id = player_id

    player_must_exist!
    team_must_exist!
    team_must_accept_players!

    team.add_to_roster(player)
  end

  private

  def player
    @player ||= player_query.call(player_id)
  end


  def team
    @team ||= team_query.call(team_id)
  end

  def player_must_exist!
    player.present? or raise UserNotFoundException
  end

  def team_must_exist!
    team.present? or raise TeamNotFoundException
  end

  def team_must_accept_player!
    team.accepts_players? or raise FullTeamException
  end
end

With Injectable you can avoid lots of boileplate:

class AddPlayerToTeamRoster
  include Injectable

  dependency :team_query
  dependency :player_query, class: UserQuery

  argument :player_id
  argument :team_id

  def call
    player_must_exist!
    team_must_exist!
    team_must_accept_players!

    team.add_to_roster(player)
  end
 
  private
 
  def player
    @player ||= player_query.call(player_id)
  end
 
  def team
    @team ||= team_query.call(team_id)
  end
 
  def player_must_exist!
    player.present? or raise UserNotFoundException
  end
 
  def team_must_exist!
    team.present? or raise TeamNotFoundException
  end

  def team_must_accept_player!
    team.accepts_players? or raise FullTeamException
  end
end

And we are using just a couple of Injectable's features.

There are some tips for you:

Domain exceptions

As you can see, we raise meaningful exceptions using the domain's language.

Declarative check of invariants

Before operating, we call several private methods that follow this idiom:

def check_something! # notice the bang
  some_precondition? or raise MeaningfulException
end

Those map 1:1 with the User Story acceptance criteria.

Avoid validations on the model

When possible, validate on your Service Objects. If you need to reuse validations, you can extract those into further POROs or use libraries like dry-validation.

If you find that you have conditional validations on your models based on the record state, you are probably in need of this approach. It's more code, but it's explicit.