Policy

Gem Version Build Status Dependency Status Code Climate Coverage Inline docs

A tiny library to implement a Policy Object pattern.

NOTE the gem was re-written from scratch in v2.0.0 (see Changelog section below)

Introduction

The gem was inspired by:

A Policy Object (assertion, invariant) encapsulates a business rule in isolation from objects (such as entities or services) following it.

Policy Objects can be combined by logical operators and, or, xor, not to provide complex policies.

This approach gives a number of benefits:

  • It makes business rules explicit instead of spreading and hiding them inside application objects.
  • It allows definition of rules for numerous attributes at once that should correspond to each other in some way.
  • It makes the rules simple and reusable in various context and combinations.
  • It makes complex rules testable in isolation from their parts.

Installation

Add this line to your application's Gemfile:

gem "policy", ">= 1.0"

And then execute:

$ bundle

Or install it yourself as:

$ gem install policy

Usage

The Model for Illustration

Suppose an over-simplified model of bank account transactions and account-to-account transfers.

# The account has a limit
class Account < Struct.new(:owner, :limit); end

# The transaction belongs to account and has a sum (< 0 for withdrawals)
class Transaction < Struct.new(:account, :sum); end

# The transfer, connecting two separate transactions
class Transfer < Struct.new(:withdrawal, :enrollment); end

What we need is to apply set of policies:

The sum of withdrawal's and enrollment's sums should be 0. The sum of withdrawal doesn't exceed the accounts' limit. The sum of transfers between client's own accounts can exceed the limit.

Let's do it with Policy Objects!

Policy Declaration

Define policies with Policy::Base module included. Tnen use ActiveModel::Validations methods to describe its rules:

# An arbitrary namespace for financial policies
module Policies

  # Withdrawal from one account should be equal to enrollment to another
  class Consistency < Struct.new(:withdrawal, :enrollment)
    include Policy::Base

    validates :withdrawal, :enrollment, presence: true
    validates :total_sum, numericality: { equal_to: 0 }

    private

    def total_sum
      withdrawal.sum + enrollment.sum
    end
  end

  # The sum of withdrawal doesn't exceed the accounts' limit
  class Limited < Struct.new(:withdrawal)
    include Policy::Base

    validate :not_exceeds_the_limit

    private

    def not_exceeds_the_limit
      return if withdrawal.sum + withdrawal.limit > 0
      errors.add :base, :exceeds_the_limit
    end
  end

  # The transfer is made between client's own accounts
  class InternalTransfer < Struct.new(:withdrawal, :enrollment)
    include Policy::Base

    validate :the_same_client

    private

    def the_same_client
      return if withdrawal..owner == enrollment..owner
      errors.add :base, :different_owners
    end
  end

end

Combining Policies

Use and, or, xor instance methods to provide complex policies from elementary ones.

You can use factory methods:

module Policies

  module LimitedOrInternal
    def self.new(withdrawal, enrollment)
      InternalTransfer.new(withdrawal, enrollment).or Limited.new(withdrawal)
    end
  end

end

As an alternative to instance methods, use the Policy module's methods:

def self.new(withdrawal, enrollment)
  Policy.or(
    InternalTransfer.new(withdrawal, enrollment),
    Limited.new(withdrawal)
  )
end

To provide negation use and.not, or.not, xor.not syntax:

first_policy.and.not(second_policy, third_policy)

# this is equal to:
Policy.and(first_policy, Policy.not(second_policy), Policy.not(third_policy))

Policies can composed at any number of levels.

Following Policies

Include the Policy::Follower module to the policies follower class.

Use the class method .follows_policies to declare policies (like ActiveModel::Validations .validate method does).

class Transfer < Struct.new(:withdrawal, :enrollment)
  include Policy::Follower

  follows_policies :consistent, :limited_or_internal

  private

  def consistent
    Policies::Consistency.new(withdrawal, enrollment)
  end

  def limited_or_internal
    Policies::LimitedOrInternal.new(withdrawal, enrollment)
  end

end

Surely, you can skip creating LimitedOrInternal builder and combine policies for current class only:

def limited_or_internal
  limited.or internal
end

def limited
  Policies::Limited.new(withdrawal)
end

def internal
  Policies::Internal.new(withdrawal, enrollment)
end

Checking Policies

Use the instance method follow_policies? to check whether an instance follows policies.

The method checks all policies and raises the Policy::ViolationError when the first followed policy is broken.

transfer = Transfer.new(
  Transaction.new(Account.new("Alice", 50), -100),
  Transaction.new(Account.new("Bob", 50), 100)
)

transfer.follow_policies?
# => <Policy::ViolationError ... > because Alice's limit of 50 is exceeded

The method doesn't mutate the follower. It collects errors inside the exception #errors method, not the follower's one.

begin
  transfer.follow_policies?
rescue ViolationError => err
  err.errors
end

You can check subset of policies by calling the method with policy names:

transfer.follow_policies? :consistent
# passes because the transfer is consistent: -100 + 100 = 0
# this doesn't check the :limited_or_internal policy

The method ignores policies, not declared by .follows_policies class method.

The method has singular alias follow_policy?(name) that accepts one argument.

Scaffolding

You can scaffold the policy with its specification and necessary translations using the generator:

policy new consistency -n policies financial -a withdrawal enrollment -l fr de

For a list of available options call the generator with an -h option:

policy new -h

Changelog

Version 2 was redesigned and rewritten from scratch. The main changes are:

  • Instead of building policy with a Policy.new method, it is now created by including the Policy::Base module.

In the previous version building a policy was needed to define an order of policy attributes. Now the definition of policy attributes is not the responsibility of the gem.

  • Instead of generating policies in a class scope (in the ActiveModel validates style), the .follows_policy refers to followers' instance methods (in the ActiveModel validate style).

This allows combining policy objects with logical expressions. Policies themselves becames more DRY, granular and testable in isolation.

  • Instead of mutating the follower, follow_policy? method raises an exception.

This allows follower to be immutable (frozen). The follower doesn't need to be messed with ActiveModule::Validations at all.

This approach makes follow_policy! method unnecessary.

Compatibility

Tested under rubies, compatible with MRI 2.0+:

  • MRI rubies 2.0+
  • Rubinius 2+ (2.0+ mode)
  • JRuby 9000 (2.0+ mode)

Rubies with API 1.9 are not supported.

Uses ActiveModel::Validations - tested for 3.1+

Uses RSpec 3.0+ for testing and hexx-suit for dev/test tools collection.

Contributing

  • Fork the project.
  • Read the STYLEGUIDE.
  • Make your feature addition or bug fix.
  • Add tests for it. This is important so I don't break it in a future version unintentionally.
  • Commit, do not mess with Rakefile or version (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
  • Send me a pull request. Bonus points for topic branches.

License

See MIT LICENSE.