sundbp
6/29/2012 - 10:02 AM

DCI in ruby without injection (using DSL) - MoneyTransfer example

DCI in ruby without injection (using DSL) - MoneyTransfer example

# Buidling blocks to support injectionless DCI
require 'rubygems'
require 'active_support/core_ext/string/inflections'
#require 'pry'

module ContextAccessor
  def context
    Thread.current[:context]
  end
end

module Context
  include ContextAccessor

  module ClassMethods
    include ContextAccessor

    def create(*args, &block)
      converted_args = args.map do |arg|
        next arg if context.nil?
        next context.player_for(arg) if context.has_player? arg
        arg
      end
      new(*converted_args, &block)
    end

    def interaction(name, &block)
      interaction_body = <<-EOF
        def #{name}(*args)
          execute_in_context do
            block = self.class.interaction_block_for(:#{name})
            self.instance_exec(*args, &block)
          end
        end
      EOF
      class_eval interaction_body
      register_interaction(name, &block)
    end

    def register_interaction(name, &block)
      @interaction_blocks ||= Hash.new
      @interaction_blocks[name] = block
    end

    def interaction_block_for(name)
      @interaction_blocks ||= Hash.new
      @interaction_blocks[name]
    end
  end

  def self.included(klass)
    klass.extend(ClassMethods)
    klass.private_class_method :new
  end

  def role_player
    @role_player ||= {}
    @role_player
  end

  def assign_role(role, player)
    role_player[role] = player
  end

  def player_for(role)
    role_player[role]
  end

  def has_player?(role)
    role_player.include? role
  end

  # 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
    return_object = begin
      self.context = self
      yield
    ensure
      self.context = old_context
    end
    return_object
  end
end

# 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
      @my_context_class ||= self.to_s.chomp(role_name).constantize
      @my_context_class
    end

    def role_name
      @role_name ||= self.to_s.split("::").last
      @role_name
    end

    def role_method(name, &block)
      role_method_body = <<-EOF
        def self.#{name}(*args)
          converted_args = args.map do |arg|
            next arg if context.nil?
            next context.player_for(arg) if context.has_player? arg
            arg
          end
          block = self.role_method_block_for(:#{name})
          self.instance_exec(*converted_args, &block)
        end
      EOF
      class_eval role_method_body
      register_role_method_block_for(name, &block)
    end

    def register_role_method_block_for(name, &block)
      @role_method_blocks ||= Hash.new
      @role_method_blocks[name] = block
    end

    def role_method_block_for(name)
      @role_method_blocks ||= Hash.new
      @role_method_blocks[name]
    end
  end
end

#####################################################################

# Contexts

class Account
  include Context

  def initialize(ledgers = [])
    assign_role Ledgers, Array(ledgers)
  end

  interaction :balance do
    Ledgers.balance
  end

  interaction :increase_balance do |amount|
    Ledgers.add_entry 'depositing', amount
  end

  interaction :decrease_balance do |amount|
    Ledgers.add_entry 'withdrawing', -1 * amount
  end

  # A role can use self or player to reference the obj associated with it
  class Ledgers < Role
    role_method :add_entry do |msg, amount|
      player << LedgerEntry.new(msg, amount)
    end

    role_method :balance do
      player.collect(&:amount).inject(0) {|sum, a| sum + a}
    end
  end
end

require 'logger'

class MoneyTransfer
  include Context

  def initialize(source, destination, amount)
    assign_role Source, source
    assign_role Destination, destination
    assign_role Amount, amount
  end

  interaction :transfer do
    Source.transfer Amount
  end

  class Source < Role
    role_method :transfer do |amount|
      log = Logger.new(STDOUT)
      log.info "Source balance is #{Source.balance}"
      log.info "Destination balance is #{Destination.balance}"
      Destination.deposit amount and Source.withdraw amount
      log.info "Source balance is now #{Source.balance}"
      log.info "Destination balance is now #{Destination.balance}"
    end

    role_method :withdraw do |amount|
      Source.decrease_balance amount
    end
  end

  class Destination < Role
    role_method :deposit do |amount|
      Destination.increase_balance amount
    end
  end

  class Amount < Role
  end
end

# Data

class LedgerEntry
  attr_accessor :amount
  attr_accessor :message

  def initialize(message, amount)
    @message = message
    @amount = amount
  end
end

############################################################

# Main

l1 = LedgerEntry.new('lodge', 500)
l2 = LedgerEntry.new('lodge', 420)

source = Account.create([l1,l2])
destination = Account.create()

context = MoneyTransfer.create(source, destination, 700)
context.transfer