-
-
Save rinaldifonseca/2891308 to your computer and use it in GitHub Desktop.
DCI in ruby without injection - MoneyTransfer example
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# The code can be seen here: gist.github.com/2711153 (it does use a small bit | |
# from the Rails framework, just for the persisted LedgerEntry and to create | |
# attribute readers). Some notes about the code: | |
# - Each Role is a class, having only (public) class methods. To help | |
# avoid them being called outside of their Context, a Role Method only has | |
# access to its associated object while the role's context is the | |
# current/active global context. | |
# - Following Trygve's approach: "Roles are referenced by name. If I mean | |
# the Role I write the role name, not 'self'." | |
# - For referencing the object playing a role, I've overridden the Role's | |
# method_missing to forward methods to its associated object (Role Methods | |
# get precedence over the object's instance methods). I'm not so keen on | |
# this, so when calling object instance methods I've chosen to use the | |
# boilerplate role method "player" in preference of self or Ledgers (the Role | |
# class in the example) to retrieve the role player object from the context | |
# before we would hit the Role's method_missing. But it could be left to the | |
# programmer's style whether to use Role, self or player when sending | |
# messages to the role player. | |
# - Another thing was the need to avoid passing a Role instead of its | |
# associated object as an argument to outside of the current context. The | |
# MoneyTransfer context passes an amount argument to the Account context | |
# method decrease_balance(), so I had to make sure in the MoneyTransfer | |
# context the associated amount object and not the Amount role class was | |
# passed (roles don't have meaning outside their context). | |
# While I like the inline readability of the contexts here, and the lack of | |
# code injection into domain objects, I'm not sure how acceptable the | |
# boilerplate code is and also the need to explicitly set the active context | |
# within every context method. I'd be very interested to hear any feedback on | |
# this; on whether the approach here can be approved upon or on whether role | |
# injection is ultimately preferable for DCI in ruby. | |
# | |
# | |
----------------------------------------- | |
Boilerplate code (in rails_app_root/lib) | |
----------------------------------------- | |
module ContextAccessor | |
def context | |
Thread.current[:context] | |
end | |
end | |
module Context | |
include ContextAccessor | |
attr_reader :role_player # allows a role to find its player | |
# Context setter is defined here so it's not exposed to roles (via ContextAccessor) | |
def context=(ctx) | |
Thread.current[:context] = ctx | |
end | |
# sets the current global context for access by roles in the interaction | |
def execute_in_context | |
old_context = self.context | |
self.context = self | |
return_object = yield | |
self.context = old_context | |
return_object | |
end | |
end # module Context | |
# A role contains only class methods and cannot be instantiated. | |
# Although role methods are implemented as public class methods, they only have | |
# access to their associated object while the role's context is the current context. | |
class Role | |
def initialize | |
raise "A Role should not be instantiated" | |
end | |
class << self | |
protected | |
include ContextAccessor | |
# retrieve role object from its (active) context's hash instance variable | |
def player | |
context.role_player[self] | |
end | |
# allow player object instance methods to be called on the role's self | |
def method_missing(method, *args, &block) | |
super unless context && context.is_a?(my_context_class) | |
if player.respond_to?(method) | |
player.send(method, *args, &block) | |
else # Neither a role method nor a valid player instance method | |
super | |
end | |
end | |
def my_context_class # a role is defined inside its context class | |
self.to_s.chomp(role_name).constantize | |
end | |
def role_name | |
self.to_s.split("::").last | |
end | |
end # Role class methods | |
end | |
----------------------------------------- | |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
Contexts (in rails_app_root/app/contexts) | |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
----------------------------------------- | |
class Account | |
include Context | |
def initialize(ledgers = []) | |
@role_player = {} | |
@role_player[Ledgers] = Array(ledgers) # association of the object ledgers (an array) with the role Ledgers | |
end | |
def balance() | |
execute_in_context do | |
Ledgers.balance | |
end | |
end | |
def increase_balance(amount) | |
execute_in_context do | |
Ledgers.add_entry 'depositing', amount | |
end | |
end | |
def decrease_balance(amount) | |
execute_in_context do | |
Ledgers.add_entry 'withdrawing', -1 * amount | |
end | |
end | |
# A role can use self or player to reference the obj associated with it | |
class Ledgers < Role | |
class << self | |
def add_entry(msg, amount) | |
player << LedgerEntry.new(:message => msg, :amount => amount) | |
end | |
def balance | |
player.collect(&:amount).sum | |
end | |
end # Role class methods | |
end # Role | |
end # Context | |
class MoneyTransfer | |
include Context | |
def initialize(source, destination, amount) | |
@role_player = {} | |
@role_player[Source] = source | |
@role_player[Destination] = destination | |
@role_player[Amount] = amount | |
end | |
def trans | |
execute_in_context do | |
Source.transfer @role_player[Amount] # so player not role will go to subcontext | |
end | |
end | |
class Source < Role | |
class << self | |
def transfer(amount) | |
log = Logger.new(STDOUT) | |
log.info "Source balance is #{Source.balance}" | |
log.info "Destination balance is #{Destination.balance}" | |
Destination.deposit amount | |
Source.withdraw amount | |
log.info "Source balance is now #{Source.balance}" | |
log.info "Destination balance is now #{Destination.balance}" | |
end | |
def withdraw(amount) | |
Source.decrease_balance amount | |
end | |
end | |
end | |
class Destination < Role | |
class << self | |
def deposit(amount) | |
Destination.increase_balance amount | |
end | |
end | |
end | |
class Amount < Role | |
end | |
end | |
------------------------------------------ | |
Domain Model (app/models/ledger_entry.rb) | |
DB table has amount:decimal & message:string | |
------------------------------------------ | |
class LedgerEntry < ActiveRecord::Base | |
attr_accessible :amount, :message | |
end | |
----------------------------- | |
Program (run in ruby console) | |
----------------------------- | |
l1 = LedgerEntry.new(:message=>'lodge',:amount=> 500) | |
l2 = LedgerEntry.new(:message=>'lodge',:amount=> 420) | |
source = Account.new([l1,l2]) | |
destination = Account.new() | |
context = MoneyTransfer.new(source, destination, 700) | |
context.trans |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment