Some of you may already know me, as I've been around the Rails community for some time, including being on the RubyNZ Committee for two terms and having organised the 2015 RailsCamp NZ. You might say that I know a few things about Ruby, and maybe even about object oriented design.
Over the last couple of years the Ruby community has been learning from its Smalltalk roots about DCI with the help of great books like Clean Ruby by Jim Gay. Whether you buy into all the principles of DCI or prefer “DCI lite” (or Use Cases as Shevaun Coker calls them) there's been plenty of effort put into trying to teach Rails developers how to avoid “fat models”.
This is not a Rails blog post
So by now you're thinking “oh great, another blog about how to do DCI in Ruby and make my Rails code base so clean I can eat my dinner off it.” Ha! Tricked you! This is a blog post about Elixir. You read that right. I'm talking about DCI and Functional Programming in the same post.
Quick definition
So DCI stands for “Data, Context, Interaction” but that doesn't really explain what it is or how to use it. I would rather avoid a complete explanation of DCI as there a lot of really great resources for DCI available online, including those linked in the preamble. Here's the sort version.
Data
In DCI you always have at least one “data object”. Also called “role players”, these are the objects we work on during the transaction. For example an account object.
Context
The context is an object that handles collecting the role players, decorating them with the behaviour needed to play their roles and then triggering that behaviour. For example a transfer of funds between accounts.
Interaction
Interaction is the behaviour that gets added to the role players (ie, the “role”). For example making the accounts be “transferable”.
Ruby Example
There's a great example in the README of Mr Darcy – a RubyGem I wrote that handles DCI using asynchronous promises, so I'm going to steal it wholesale:
class BankTransfer < MrDarcy::Context
role :money_source do
def has_available_funds?(amount)
available_balance >= amount
end
def subtract_funds(amount)
self.available_balance = available_balance - amount
end
end
role :money_destination do
def receive_funds(amount)
self.available_balance = available_balance + amount
end
end
action :transfer do |amount|
if money_source.has_available_funds? amount
money_source.subtract_funds amount
money_destination.receive_funds amount
else
raise "insufficient funds"
end
amount
end
end
Here you can see that the object BankTransfer is the context, it has two roles which specify extra behaviour that is given to the two role players as they come into the context and an action. Here's how you'd use it:
Account = Struct.new(:available_balance)
marty = Account.new(10)
doc_brown = Account.new(15)
context = BankTransfer.new(money_source: marty, money_destination: doc_brown)
context.transfer(5).then do |amount|
puts "Successfully transferred #{amount} from #{money_source} to #{money_destination}"
end
context.transfer(50).fail do |exception|
puts "Failed to transfer funds: #{exception.message}"
end
Here we create two accounts with individual balances, place them into a context as the role players and then attempt to perform the interaction and either succeed or fail.
Elixir Example
For those of you not familiar with Elixir, it's a functional programming language which runs on the Erlang VM (known as the BEAM) but with a bunch of great features sprinkled on top. The first thing you'll notice is its Rubyish syntax, which leads many people to think that Elixir is “the CoffeeScript of Erlang”, but that's not true. Read Devin Torres' great blog post Elixir: It's Not About Syntax for more information.
Data
Functional programming is all about data, and Elixir is no different, with core data types such as integer, float, tuple, list and map we can easily model our data. Elixir also has something special, the concept of a “struct”. Structs can be thought of as “maps with a name”, but there's some other magic we'll get into a bit later, including polymorphism.
So using the example from above, our Data would be two account structs:
defmodule Account do
defstruct available_balance: nil
end
marty = %Account{available_balance: 10}
doc_brown = %Account{available_balance: 15}
Context
To implement a context we might make a module something like this:
defmodule BankTransfer do
def transfer amount, %{money_source: source, money_destination: dest} do
if MoneySource.has_available_funds? source, amount do
source = MoneySource.subtract_funds source, amount
dest = MoneyDestination.receive_funds dest, amount
{:ok, %{money_source: source, money_destination: dest}}
else
{:error, "insufficient funds"}
end
end
end
So let's explain what happened here; we created a module called BankTransfer
and defined a function called transfer which takes an amount and then a map and pattern matches it's money_source
and money_destination
properties and assigns them to local variables. We may want to add additional guard clauses for this function also (such as when is_number(amount) and amount > 0
).
Interaction
The next thing we did was make these accounts play the roles of MoneySource
and MoneyDestination
. How do we do this? This is where Elixir's protocols come in; they allow us to implement polymorphism for a function based on the type of its data. First we define the protocols:
defprotocol MoneySource do
def has_available_funds?(money_source, amount)
def subtract_funds(money_source, amount)
end
defprotocol MoneyDestination do
def receive_funds(money_destination, amount)
end
When you define a protocol you define the signatures of the functions, without specifying the implementations. Next we'll define the implementations of these functions for our Account
module:
defimpl MoneySource, for: Account do
def has_available_funds?(%Account{available_balance: bal}, amount) when bal >= amount do
true
end
def has_available_funds?(%Account{}, _amount) do
false
end
def subtract_funds(%Account{available_balance: bal}=account, amount) do
%{account | available_balance: bal - amount}
end
end
defimpl MoneyDestination, for: Account do
def receive_funds(%Account{available_balance: bal}=account, amount) do
%{account | available_balance: bal + amount}
end
end
Some cute things in the above code:
- We're able to implement
MoneySource.has_available_funds?
completely within guard clauses. If the first function matches it will return true, otherwise it will fall through to the next implementation, which matches in all circumstances. - We've used Elixir's short-hand syntax for updating maps to update the account, but we could just as easily have used the
Map.put/3
function.
And finally:
So we've taken our two accounts (which could easily enough have been Ecto Models) and implemented the behaviour we need, and the context in which we with them to behave this way. To use it we would do something like:
marty = %Account{available_balance: 10}
doc_brown = %Account{available_balance: 15}
case BankTransfer.transfer(5, %{money_source: marty, money_destination: doc_brown}) do
{:ok, result} ->
IO.puts "Successfully transferred 5 from #{inspect result.money_source} to #{inspect result.money_destination}"
{:error, msg} ->
IO.outs "Failed to transfer funds: #{msg}"
end
So that's how you implement DCI in Elixir. The only “objecty” feature needed is polymorphism, which Elixir provides by way of protocols. In many ways I'd suggest this a more “pure” expression of DCI than doing it with objects. Just saying.