Skip to content

Instantly share code, notes, and snippets.

@ludyna
Last active March 20, 2018 20:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ludyna/cd25e502a240efb6e89404efed57b919 to your computer and use it in GitHub Desktop.
Save ludyna/cd25e502a240efb6e89404efed57b919 to your computer and use it in GitHub Desktop.
Nested DCI Contexts
#
# Example: Nested DCI Contexts
# Blog post: https://www.ludyna.com/oleh/dci-in-scale-free-fractal-way
#
# Note that Ruby language while supporting multiple programming paradigms does not
# natively support DCI paradigm. I am using classes and modules to express Context,
# Data and Roles concepts from DCI.
module GameWorldContext
# Data. Imagine that it represents Human actor (controlled by a player or AI) in the game
class HumanData
attr_accessor :name
attr_accessor :balance # amount of cash in a pocket
attr_accessor :bank_account_id # account id in the bank
attr_accessor :hit_points # health level (health bar)
def initialize(name:, balance:, bank_account_id:, hit_points:)
@name = name
@balance = balance
@bank_account_id = bank_account_id
@hit_points = hit_points
end
end
#####################################################################################
# Bank Context which includes two use-cases: deposit and invest.
# There is only one Bank in the game.
#####################################################################################
class BankContext
# Data
class BankAccountData
attr_accessor :account_id
attr_accessor :balance
def initialize(initial_balance:, account_id:)
@balance = initial_balance
@account_id = account_id
end
end
# Role. This role is used by deposit and invest use-cases
module BankRole
# Bank should be able to find your account
def find_personal_account(bank_account_id)
@personal_accounts[bank_account_id]
end
# Bank should be able to propose investment account
def find_investment_account()
@investment_accounts[rand(@investment_accounts.length)]
end
end
attr_accessor :personal_accounts
attr_accessor :investment_accounts
def initialize
@personal_accounts = Array.new(4) {|i| BankAccountData.new(initial_balance: 0, account_id: i)}
@investment_accounts = Array.new(4) {|i| BankAccountData.new(initial_balance: 0, account_id: i)}
end
# Use-case in which human deposits some amount from their pocket to their account in the bank.
def deposit(bank_client, amount)
# Apply roles to objects
bank_client.extend SourceAccountRole
self.extend BankRole # I use BankContext as Data instance too
# (because it has personal_accounts and investment_accounts global variables)
personal_account = self.find_personal_account(bank_client.bank_account_id)
personal_account.extend DestinationAccountRole
# execute business logic
bank_client.transfer_to(personal_account, amount)
# Remove roles from objects
# human.unextend SourceAccountRole
# self.unextend BankRole
# personal_account.unextend DestinationAccountRole
end
# Use-case in which human deposits some amount from their pocket to random investment account
def invest(bank_client, amount)
# Apply roles to objects
bank_client.extend SourceAccountRole
self.extend BankRole
investment_account = self.find_investment_account
investment_account.extend DestinationAccountRole
# execute business logic
bank_client.transfer_to(investment_account, amount)
# Remove roles from objects
# human.unextend SourceAccountRole
# self.unextend BankRole
# investment_account.unextend DestinationAccountRole
end
end
#####################################################################################
# Coffee shop Context which has two use-cases: buy_menu_item and eat_at_the_table. We have
# eat_at_the_table use-case as separate use-case because we do not always want to eat menu_item
# immediately, we might want to save it for later.
# There is only one CoffeeShop in the game.
#####################################################################################
class CoffeeShopContext
# Data (related to CoffeeShopContext only)
class MenuItemData
attr_accessor :name
attr_accessor :price
attr_accessor :hit_points # amount of hit points that it gives to human
def initialize(name:, price:, hit_points:)
@name = name
@price = price
@hit_points = hit_points
end
end
# Data (similar to BankAccountData but without account_id as we do not need it)
class ShopAccountData
attr_accessor :balance
def initialize(initial_balance:)
@balance = initial_balance
end
end
attr_accessor :shop_account
attr_accessor :menu
def initialize
@shop_account = ShopAccountData.new(initial_balance: 77)
@menu = [
MenuItemData.new(name: 'coffee', price: 5, hit_points: 3),
MenuItemData.new(name: 'donut', price: 10, hit_points: 8)
]
end
# Use-case
def buy_menu_item(buyer)
# apply roles to objects
buyer.extend SourceAccountRole
buyer.extend MenuReaderRole
shop_account.extend DestinationAccountRole
# Choose a menu item
menu_item = buyer.choose_menu_item(@menu)
# Pay for the menu_item
buyer.transfer_to(shop_account, menu_item.price)
# Remove roles from objects
# human.unextend SourceAccountRole
# human.unextend MenuReaderRole
# shop_account.unextend DestinationAccountRole
# return menu_item
menu_item
end
# Use-case
def eat_at_the_table(eater, menu_item)
eater.extend EaterRole
# Eating at the table in the Coffee Shop gives additional hit point
eater.eat(menu_item.hit_points + 1)
# Remove roles from objects
# human.unextend EaterRole
end
end
#####################################################################################
# ROLES
# Note that some roles are shared between contexts and use-cases
#####################################################################################
module SourceAccountRole
def transfer_to(destination, amount)
if balance < amount
raise 'ERROR. Insufficient funds, Operation aborted.'
else
decrease_balance(amount)
destination.transfer_from(amount)
end
end
private
def decrease_balance(amount)
@balance -= amount
end
end
module DestinationAccountRole
def transfer_from(amount)
increase_balance(amount)
end
private
def increase_balance(amount)
@balance += amount
end
end
# Role for an entity that can read menu.
# For example, human that knows menu language and can read it. Young child might not know how to read.
# We could define this role in CoffeeShopContext.buy_menu_item Context
# but potentially in can be used in restaurant too
module MenuReaderRole
def choose_menu_item(menu)
menu[rand(menu.length)]
end
end
# Role for an entity that can eat and restore their hit_points(health level).
# For example, it can be a human or a dog.
# We could define this role in CoffeeShopContext.eat_menu_item Context
# but potentially in can be used in restaurant too
module EaterRole
MAX_HIT_POINTS = 80
def eat(hit_points)
@hit_points = [@hit_points + hit_points, MAX_HIT_POINTS].min
end
end
#####################################################################################
# Code to execute the app
#####################################################################################
def self.run # <- this is Use-case that makes use of Contexts and other Use-cases
human = HumanData.new(name: 'Jim', balance: 100, bank_account_id: 3, hit_points: 73)
report_status("initial", human)
######################################################################
# Jim decides to go to a bank
######################################################################
bank = BankContext.new
puts "\nDeposit $15 to a personal account."
bank.deposit(human, 15) # deposit $15 to Jim's personal account
puts "Personal Accounts: #{bank.personal_accounts.map {|i| i.balance}.join(", ")}"
report_status("after deposit", human)
puts "\nInvest $3"
bank.invest(human, 3) # invest $3
puts "Investment Accounts: #{bank.investment_accounts.map {|i| i.balance}.join(", ")}"
report_status("after investing", human)
######################################################################
# Jim decides to go to a coffee shop
######################################################################
shop = CoffeeShopContext.new
puts "\nVisit Coffee Shop and buy menu item"
menu_item = shop.buy_menu_item(human)
puts "#{human.name} chose #{menu_item.name} for a price $#{menu_item.price} "\
"that gives #{menu_item.hit_points} hit points."
report_status("after buying menu item", human)
puts "\nEat at the table in the Coffee Shop"
shop.eat_at_the_table(human, menu_item)
report_status("after eating menu item", human)
end
private
# Utility method
def self.report_status(label, human)
puts "#{label.upcase}: #{human.name}'s balance: $#{human.balance} and health: #{human.hit_points}"
end
end
# Actually running an app
GameWorldContext.run
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment