Last active
March 20, 2018 20:06
-
-
Save ludyna/cd25e502a240efb6e89404efed57b919 to your computer and use it in GitHub Desktop.
Nested DCI Contexts
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
# | |
# 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