Policy
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:
- the CodeClimate's blog post "7 ways to decompose fat ActiveRecord module".
- the Chapter 10 of the book "Domain-Driven Design" by Eric Evans.
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.account.owner == enrollment.account.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 thePolicy::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 ActiveModelvalidate
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.