-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathcommand_objects.rb
204 lines (175 loc) · 6.02 KB
/
command_objects.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# frozen_string_literal: true
require 'bundler'
Bundler.setup(:examples)
require 'plumb'
require 'json'
require 'fileutils'
require 'money'
Money.default_currency = Money::Currency.new('GBP')
Money.locale_backend = nil
Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN
# Different approaches to the Command Object pattern using composable Plumb types.
module Types
include Plumb::Types
# Note that within this `Types` module, when we say String, Integer etc, we mean Types::String, Types::Integer etc.
# Use ::String to refer to Ruby's String class.
#
###############################################################
# Define core types in the domain
# The task is to process, validate and store mortgage applications.
###############################################################
# Turn integers into Money objects (requires the money gem)
Amount = Integer.build(Money)
# A valid customer type
Customer = Hash[
name: String.present,
age?: Integer[18..],
email: Email
]
# A step to validate a Mortgage application payload
# including valid customer, mortgage type and minimum property value.
MortgagePayload = Hash[
customer: Customer,
type: String.options(%w[first-time switcher remortgage]).default('first-time'),
property_value: Integer[100_000..] >> Amount,
mortgage_amount: Integer[50_000..] >> Amount,
term: Integer[5..30],
]
# A domain validation step: the mortgage amount must be less than the property value.
# This is just a Proc that implements the `#call(Result::Valid) => Result::Valid | Result::Invalid` interface.
# # Note that this can be anything that supports that interface, like a lambda, a method, a class etc.
ValidateMortgageAmount = proc do |result|
if result.value[:mortgage_amount] > result.value[:property_value]
result.invalid(errors: { mortgage_amount: 'Cannot exceed property value' })
else
result
end
end
# A step to create a mortgage application
# This could be backed by a database (ex. ActiveRecord), a service (ex. HTTP API), etc.
# For this example I just save JSON files to disk.
class MortgageApplicationsStore
def self.call(result) = new.call(result)
def initialize(dir = './examples/data/applications')
@dir = dir
FileUtils.mkdir_p(dir)
end
# The Plumb::Step interface to make these objects composable.
# @param result [Plumb::Result::Valid]
# @return [Plumb::Result::Valid, Plumb::Result::Invalid]
def call(result)
if save(result.value)
result
else
result.invalid(errors: 'Could not save application')
end
end
def save(payload)
file_name = File.join(@dir, "#{Time.now.to_i}.json")
File.write(file_name, JSON.pretty_generate(payload))
end
end
# Finally, a step to send a notificiation to the customer.
# This should only run if the previous steps were successful.
NotifyCustomer = proc do |result|
# Send an email here.
puts "Sending notification to #{result.value[:customer][:email]}"
result
end
###############################################################
# Option 1: define standalone steps and then pipe them together
###############################################################
CreateMortgageApplication1 = MortgagePayload \
>> ValidateMortgageAmount \
>> MortgageApplicationsStore \
>> NotifyCustomer
###############################################################
# Option 2: compose steps into a Plumb::Pipeline
# This is just a wrapper around step1 >> step2 >> step3 ...
# But the procedural style can make sequential steps easier to read and manage.
# Also to add/remove debugging and tracing steps.
###############################################################
CreateMortgageApplication2 = Any.pipeline do |pl|
# The input payload
pl.step MortgagePayload
# Some inline logging to demostrate inline steps
# This is also useful for debugging and tracing.
pl.step do |result|
p [:after_payload, result.value]
result
end
# Domain validation
pl.step ValidateMortgageAmount
# Save the application
pl.step MortgageApplicationsStore
# Notifications
pl.step NotifyCustomer
end
# Note that I could have also started the pipeline directly off the MortgagePayload type.
# ex. CreateMortageApplication2 = MortgagePayload.pipeline do |pl
# For super-tiny command objects you can do it all inline:
#
# Types::Hash[
# name: String,
# age: Integer
# ].pipeline do |pl|
# pl.step do |result|
# .. some validations
# result
# end
# end
#
# Or you can use Method objects as steps
#
# pl.step SomeObject.method(:create)
###############################################################
# Option 3: use your own class
# Use Plumb internally for validation and composition of shared steps or method objects.
###############################################################
class CreateMortgageApplication3
def initialize
@pipeline = Types::Any.pipeline do |pl|
pl.step MortgagePayload
pl.step method(:validate)
pl.step method(:save)
pl.step method(:notify)
end
end
def run(payload)
@pipeline.resolve(payload)
end
private
def validate(result)
# etc
result
end
def save(result)
# etc
result
end
def notify(result)
# etc
result
end
end
end
# Uncomment each case to run
# p Types::CreateMortgageApplication1.resolve(
# customer: { name: 'John Doe', age: 30, email: '[email protected]' },
# property_value: 200_000,
# mortgage_amount: 150_000,
# term: 25
# )
# p Types::CreateMortgageApplication2.resolve(
# customer: { name: 'John Doe', age: 30, email: '[email protected]' },
# property_value: 200_000,
# mortgage_amount: 150_000,
# term: 25
# )
# Or, with invalid data
# p Types::CreateMortgageApplication2.resolve(
# customer: { name: 'John Doe', age: 30, email: '[email protected]' },
# property_value: 200_000,
# mortgage_amount: 201_000,
# term: 25
# )