bcdd-contract 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +128 -0
- data/CHANGELOG.md +45 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +964 -0
- data/Rakefile +18 -0
- data/Steepfile +32 -0
- data/examples/README.md +11 -0
- data/examples/anti_corruption_layer/README.md +212 -0
- data/examples/anti_corruption_layer/Rakefile +30 -0
- data/examples/anti_corruption_layer/app/models/payment/charge_credit_card.rb +36 -0
- data/examples/anti_corruption_layer/config.rb +20 -0
- data/examples/anti_corruption_layer/lib/payment_gateways/adapters/circle_up.rb +19 -0
- data/examples/anti_corruption_layer/lib/payment_gateways/adapters/pay_friend.rb +19 -0
- data/examples/anti_corruption_layer/lib/payment_gateways/contract.rb +15 -0
- data/examples/anti_corruption_layer/lib/payment_gateways/response.rb +5 -0
- data/examples/anti_corruption_layer/lib/payment_gateways.rb +11 -0
- data/examples/anti_corruption_layer/vendor/circle_up/client.rb +11 -0
- data/examples/anti_corruption_layer/vendor/pay_friend/client.rb +11 -0
- data/examples/business_processes/README.md +245 -0
- data/examples/business_processes/Rakefile +50 -0
- data/examples/business_processes/config.rb +14 -0
- data/examples/business_processes/lib/division.rb +58 -0
- data/examples/design_by_contract/README.md +227 -0
- data/examples/design_by_contract/Rakefile +60 -0
- data/examples/design_by_contract/config.rb +13 -0
- data/examples/design_by_contract/lib/shopping_cart.rb +62 -0
- data/examples/ports_and_adapters/README.md +246 -0
- data/examples/ports_and_adapters/Rakefile +68 -0
- data/examples/ports_and_adapters/app/models/user/record/repository.rb +13 -0
- data/examples/ports_and_adapters/app/models/user/record.rb +7 -0
- data/examples/ports_and_adapters/config.rb +28 -0
- data/examples/ports_and_adapters/db/setup.rb +16 -0
- data/examples/ports_and_adapters/lib/user/creation.rb +19 -0
- data/examples/ports_and_adapters/lib/user/data.rb +5 -0
- data/examples/ports_and_adapters/lib/user/repository.rb +24 -0
- data/examples/ports_and_adapters/test/user_test/repository.rb +21 -0
- data/lib/bcdd/contract/assertions.rb +21 -0
- data/lib/bcdd/contract/config.rb +25 -0
- data/lib/bcdd/contract/core/checker.rb +37 -0
- data/lib/bcdd/contract/core/checking.rb +38 -0
- data/lib/bcdd/contract/core/factory.rb +32 -0
- data/lib/bcdd/contract/core/proxy.rb +19 -0
- data/lib/bcdd/contract/core.rb +12 -0
- data/lib/bcdd/contract/interface.rb +25 -0
- data/lib/bcdd/contract/list.rb +45 -0
- data/lib/bcdd/contract/map/pairs.rb +47 -0
- data/lib/bcdd/contract/map/schema.rb +50 -0
- data/lib/bcdd/contract/map.rb +10 -0
- data/lib/bcdd/contract/proxy.rb +40 -0
- data/lib/bcdd/contract/registry.rb +67 -0
- data/lib/bcdd/contract/unit/checker.rb +51 -0
- data/lib/bcdd/contract/unit/factory.rb +53 -0
- data/lib/bcdd/contract/unit.rb +40 -0
- data/lib/bcdd/contract/version.rb +7 -0
- data/lib/bcdd/contract.rb +118 -0
- data/lib/bcdd-contract.rb +3 -0
- data/sig/bcdd/contract/assertions.rbs +7 -0
- data/sig/bcdd/contract/config.rbs +15 -0
- data/sig/bcdd/contract/core.rbs +60 -0
- data/sig/bcdd/contract/interface.rbs +12 -0
- data/sig/bcdd/contract/list.rbs +21 -0
- data/sig/bcdd/contract/map.rbs +45 -0
- data/sig/bcdd/contract/proxy.rbs +8 -0
- data/sig/bcdd/contract/registry.rbs +25 -0
- data/sig/bcdd/contract/unit.rbs +39 -0
- data/sig/bcdd/contract.rbs +31 -0
- metadata +116 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
- [📈 Business Processes](#-business-processes)
|
|
2
|
+
- [💻 Self-Documented Code](#-self-documented-code)
|
|
3
|
+
- [Why use a division as an example?](#why-use-a-division-as-an-example)
|
|
4
|
+
- [What are the challenges of dividing numbers?](#what-are-the-challenges-of-dividing-numbers)
|
|
5
|
+
- [What are NaN and Infinity numbers?](#what-are-nan-and-infinity-numbers)
|
|
6
|
+
- [Representing a Process as Code](#representing-a-process-as-code)
|
|
7
|
+
- [⚖️ What are the benefits of using this pattern?](#️-what-are-the-benefits-of-using-this-pattern)
|
|
8
|
+
- [Is it worth the overhead of contract checking at runtime?](#is-it-worth-the-overhead-of-contract-checking-at-runtime)
|
|
9
|
+
- [🏃♂️ How to run the application?](#️-how-to-run-the-application)
|
|
10
|
+
|
|
11
|
+
## 📈 Business Processes
|
|
12
|
+
|
|
13
|
+
**What is a Process?**
|
|
14
|
+
|
|
15
|
+
A process is a series of steps or actions performed in a specific order to achieve an outcome. These steps streamline operations, reduce errors, and enhance productivity.
|
|
16
|
+
|
|
17
|
+
Processes can be documented, analyzed, comprehended, and continuously improved to change and adapt to new circumstances/requirements.
|
|
18
|
+
|
|
19
|
+
**What is a Business Process in Software?**
|
|
20
|
+
|
|
21
|
+
In software, a business process refers to a structured sequence of tasks or activities that embody a particular function or operation within a business.
|
|
22
|
+
|
|
23
|
+
For example, if a business involves product sales, it'll have distinct processes for receiving orders, processing payments, and shipping products. These processes are and reflect the business's core operations. So, the sum of these processes is the business or the software that automates/represents it.
|
|
24
|
+
|
|
25
|
+
## 💻 Self-Documented Code
|
|
26
|
+
|
|
27
|
+
In this example, we'll use the [`BCDD::Result`](https://github.com/B-CDD/result) and `BCDD::Contract` to express a division of two numbers.
|
|
28
|
+
|
|
29
|
+
Is this a business process? If your business involves dividing numbers, yes, it is. 😛
|
|
30
|
+
|
|
31
|
+
### Why use a division as an example?
|
|
32
|
+
|
|
33
|
+
Because it's simple to understand and complex enough to show the benefits of using this pattern.
|
|
34
|
+
|
|
35
|
+
### What are the challenges of dividing numbers?
|
|
36
|
+
|
|
37
|
+
- The dividend and divisor must be valid numbers (not `NaN` or `Infinity`).
|
|
38
|
+
- The divisor must be different from zero.
|
|
39
|
+
- If the dividend is zero, the result must be zero.
|
|
40
|
+
- The result must be a valid number (not `NaN` or `Infinity`).
|
|
41
|
+
|
|
42
|
+
### What are NaN and Infinity numbers?
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
nan = 0.0 / 0.0 # => NaN
|
|
46
|
+
inf = 1.0 / 0.0 # => Infinity
|
|
47
|
+
|
|
48
|
+
nan.is_a?(Numeric) # => true
|
|
49
|
+
inf.is_a?(Numeric) # => true
|
|
50
|
+
|
|
51
|
+
nan / 2 # => NaN
|
|
52
|
+
inf / 2 # => Infinity
|
|
53
|
+
|
|
54
|
+
inf / nan # => NaN
|
|
55
|
+
nan / inf # => NaN
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Yes, Ruby has these "numbers". 😅
|
|
59
|
+
|
|
60
|
+
### Representing a Process as Code
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
class Division
|
|
64
|
+
module Contract
|
|
65
|
+
not_nan = -> { _1.respond_to?(:nan?) and _1.nan? and '%p cannot be nan' }
|
|
66
|
+
not_inf = -> { _1.respond_to?(:infinite?) and _1.infinite? and '%p cannot be infinite' }
|
|
67
|
+
|
|
68
|
+
FiniteNumber = ::BCDD::Contract[Numeric] & not_nan & not_inf
|
|
69
|
+
CannotBeZero = ::BCDD::Contract[-> { _1.zero? and 'cannot be zero' }]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
include ::BCDD::Result::Expectations.mixin(
|
|
73
|
+
config: { addon: { continue: true } },
|
|
74
|
+
success: { division_completed: Contract::FiniteNumber },
|
|
75
|
+
failure: {
|
|
76
|
+
invalid_arg: ->(value) { value in [Symbol, Array] },
|
|
77
|
+
division_by_zero: ->(value) { value in [:arg2, Array] }
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def call(arg1, arg2)
|
|
82
|
+
::BCDD::Result.transitions(name: 'Division', desc: 'divide two numbers') do
|
|
83
|
+
Given([Contract::FiniteNumber[arg1], Contract::FiniteNumber[arg2]])
|
|
84
|
+
.and_then(:require_numbers)
|
|
85
|
+
.and_then(:check_for_zeros)
|
|
86
|
+
.and_then(:divide)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def require_numbers((arg1, arg2))
|
|
93
|
+
arg1.invalid? and return Failure(:invalid_arg, [:arg1, arg1.errors])
|
|
94
|
+
arg2.invalid? and return Failure(:invalid_arg, [:arg2, arg2.errors])
|
|
95
|
+
|
|
96
|
+
Continue([arg1.value, arg2.value])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def check_for_zeros(numbers)
|
|
100
|
+
num1 = numbers[0]
|
|
101
|
+
num2 = Contract::CannotBeZero[numbers[1]]
|
|
102
|
+
|
|
103
|
+
num2.invalid? and return Failure(:division_by_zero, [:arg2, num2.errors])
|
|
104
|
+
|
|
105
|
+
num1.zero? and return Success(:division_completed, 0)
|
|
106
|
+
|
|
107
|
+
Continue(numbers)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def divide((num1, num2))
|
|
111
|
+
Success(:division_completed, num1 / num2)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Let's break it down.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
module Contract
|
|
120
|
+
module Contract
|
|
121
|
+
not_nan = -> { _1.respond_to?(:nan?) and _1.nan? and '%p cannot be nan' }
|
|
122
|
+
not_inf = -> { _1.respond_to?(:infinite?) and _1.infinite? and '%p cannot be infinite' }
|
|
123
|
+
|
|
124
|
+
FiniteNumber = ::BCDD::Contract[Numeric] & not_nan & not_inf
|
|
125
|
+
CannotBeZero = ::BCDD::Contract[-> { _1.zero? and 'cannot be zero' }]
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The `Contract` module defines the contracts the `Division` class uses. The `FiniteNumber` ensures the value is numeric, not `NaN` or `Infinity`. The `CannotBeZero` contract ensures the value is not zero.
|
|
130
|
+
|
|
131
|
+
The lambda is the contract unit checker. It receives two arguments: the value to be validated and an array of errors. The checker will add an error to the array when the value is invalid.
|
|
132
|
+
|
|
133
|
+
**What is a contract unit?**
|
|
134
|
+
|
|
135
|
+
It's a single or as part of a contract composition. It can perform validations and type checking and be used for pattern matching.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
include ::BCDD::Result::Expectations.mixin(
|
|
139
|
+
config: { addon: { continue: true } },
|
|
140
|
+
success: { division_completed: Contract::FiniteNumber },
|
|
141
|
+
failure: {
|
|
142
|
+
invalid_arg: ->(value) { value in [Symbol, Array] },
|
|
143
|
+
division_by_zero: ->(value) { value in [:arg2, Array] }
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The `BCDD::Result::Expectations.mixin` is a mixin that adds the `Given()`, `Continue()`, `Success()`, and `Failure()` methods. It also defines a contract (the expectations) for the `Success()` and `Failure()` results. If the contract is unsatisfied, the result methods will raise an exception.
|
|
149
|
+
|
|
150
|
+
**Note:** The `Contract::FiniteNumber` is being used to type-check the `:division_completed` result.
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
def call(arg1, arg2)
|
|
154
|
+
::BCDD::Result.transitions(name: 'Division', desc: 'divide two numbers') do
|
|
155
|
+
Given([Contract::FiniteNumber[arg1], Contract::FiniteNumber[arg2]])
|
|
156
|
+
.and_then(:require_numbers)
|
|
157
|
+
.and_then(:check_for_zeros)
|
|
158
|
+
.and_then(:divide)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The `call` method uses the `BCDD::Result.transitions` method to track the result of each step (perform `result.transitions` to see it in action) within the business process. It starts with the `Given()` and uses the `and_then()` to chain the steps. The process will stop on the first `Success()` or `Failure()`. Based on this, the previous step must return a `Continue()` to achieve the next one.
|
|
164
|
+
|
|
165
|
+
**Note:** the inputs were transformed into contract units by using the `[]` operator.
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
def require_numbers((arg1, arg2))
|
|
169
|
+
arg1.invalid? and return Failure(:invalid_arg, [:arg1, arg1.errors])
|
|
170
|
+
arg2.invalid? and return Failure(:invalid_arg, [:arg2, arg2.errors])
|
|
171
|
+
|
|
172
|
+
Continue([arg1.value, arg2.value])
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The `require_numbers` method receives the inputs as a tuple (an array with two elements). It checks if the inputs are valid and returns `Continue()` with the contract unit values or a `Failure()` with the contract unit errors.
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
def check_for_zeros(numbers)
|
|
180
|
+
num1 = numbers[0]
|
|
181
|
+
num2 = Contract::CannotBeZero[numbers[1]]
|
|
182
|
+
|
|
183
|
+
num2.invalid? and return Failure(:division_by_zero, [:arg2, num2.errors])
|
|
184
|
+
|
|
185
|
+
num1.zero? and return Success(:division_completed, 0)
|
|
186
|
+
|
|
187
|
+
Continue(numbers)
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The `check_for_zeros` method receives the inputs as an array. It uses the `Contract::CannotBeZero` to check if the second input is zero. If it is, it returns a `Failure()` with the contract errors. If the first input is zero, it returns a `Success()` with `0` to stop the process. Otherwise, it returns a `Continue()` to continue it.
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
def divide((num1, num2))
|
|
195
|
+
Success(:division_completed, num1 / num2)
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The `divide` method is the last step. If it was reached, it means the inputs are valid and the divisor is not zero. So, it returns a `Success()` with the result of the division.
|
|
200
|
+
|
|
201
|
+
## ⚖️ What are the benefits of using this pattern?
|
|
202
|
+
|
|
203
|
+
- The process is
|
|
204
|
+
- reliable. (Contracts for inputs and outputs)
|
|
205
|
+
- self-documented. (Is simple to understand)
|
|
206
|
+
- simple to test. (Every possible outcome is clear)
|
|
207
|
+
- simple to reuse. (The contracts and processes are reusable)
|
|
208
|
+
- simple to extend. (Just add a new step)
|
|
209
|
+
- simple to evolve. (The contracts and behaviors can be changed to support new requirements)
|
|
210
|
+
- simple to observe, monitor. (The transitions are tracked, each step is a method)
|
|
211
|
+
|
|
212
|
+
### Is it worth the overhead of contract checking at runtime?
|
|
213
|
+
|
|
214
|
+
You can eliminate the overhead by disabling the `BCDD::Result` expectations, which are the result contract checkers. Use it in dev/test environments to ensure the contracts are satisfied and disable it in production.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
BCDD::Result.configuration do |config|
|
|
218
|
+
config.feature.disable!(:expectations) if ::Rails.env.production?
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## 🏃♂️ How to run the application?
|
|
223
|
+
|
|
224
|
+
In the same directory as this `README`, run:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
rake
|
|
228
|
+
|
|
229
|
+
# Output sample:
|
|
230
|
+
#
|
|
231
|
+
# -- Failures --
|
|
232
|
+
#
|
|
233
|
+
# #<BCDD::Result::Failure type=:invalid_arg value=[:arg1, ["\"10\" must be numeric"]]>
|
|
234
|
+
# #<BCDD::Result::Failure type=:invalid_arg value=[:arg2, ["\"2\" must be numeric"]]>
|
|
235
|
+
# #<BCDD::Result::Failure type=:invalid_arg value=[:arg1, ["cannot be nan"]]>
|
|
236
|
+
# #<BCDD::Result::Failure type=:invalid_arg value=[:arg2, ["cannot be infinite"]]>
|
|
237
|
+
# #<BCDD::Result::Failure type=:division_by_zero value=[:arg2, ["cannot be zero"]]>
|
|
238
|
+
# #<BCDD::Result::Failure type=:division_by_zero value=[:arg2, ["cannot be zero"]]>
|
|
239
|
+
#
|
|
240
|
+
# -- Successes --
|
|
241
|
+
#
|
|
242
|
+
# #<BCDD::Result::Success type=:division_completed value=0>
|
|
243
|
+
# #<BCDD::Result::Success type=:division_completed value=0>
|
|
244
|
+
# #<BCDD::Result::Success type=:division_completed value=5>
|
|
245
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if RUBY_VERSION <= '3.1'
|
|
4
|
+
puts 'This example requires Ruby 3.1 or higher.'
|
|
5
|
+
exit! 1
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
require_relative 'config'
|
|
9
|
+
|
|
10
|
+
task :default do
|
|
11
|
+
puts '=================================================='
|
|
12
|
+
puts 'Business Processes = BCDD::Result + BCDD::Contract'
|
|
13
|
+
puts '=================================================='
|
|
14
|
+
|
|
15
|
+
puts
|
|
16
|
+
puts '-- Failures --'
|
|
17
|
+
puts
|
|
18
|
+
|
|
19
|
+
p Division.new.call('10', 2)
|
|
20
|
+
p Division.new.call(10, '2')
|
|
21
|
+
p Division.new.call(Float::NAN, 2)
|
|
22
|
+
p Division.new.call(10, Float::INFINITY)
|
|
23
|
+
p Division.new.call(10, 0)
|
|
24
|
+
p Division.new.call(10, 0.0)
|
|
25
|
+
|
|
26
|
+
puts
|
|
27
|
+
puts '-- Successes --'
|
|
28
|
+
puts
|
|
29
|
+
|
|
30
|
+
p Division.new.call(0, 2)
|
|
31
|
+
p Division.new.call(0.0, 2)
|
|
32
|
+
p Division.new.call(10, 2)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Output sample: rake
|
|
36
|
+
#
|
|
37
|
+
# -- Failures --
|
|
38
|
+
#
|
|
39
|
+
# #<BCDD::Result::Failure type=:invalid_arg value=[:arg1, ["\"10\" must be numeric"]]>
|
|
40
|
+
# #<BCDD::Result::Failure type=:invalid_arg value=[:arg2, ["\"2\" must be numeric"]]>
|
|
41
|
+
# #<BCDD::Result::Failure type=:invalid_arg value=[:arg1, ["cannot be nan"]]>
|
|
42
|
+
# #<BCDD::Result::Failure type=:invalid_arg value=[:arg2, ["cannot be infinite"]]>
|
|
43
|
+
# #<BCDD::Result::Failure type=:division_by_zero value=[:arg2, ["cannot be zero"]]>
|
|
44
|
+
# #<BCDD::Result::Failure type=:division_by_zero value=[:arg2, ["cannot be zero"]]>
|
|
45
|
+
#
|
|
46
|
+
# -- Successes --
|
|
47
|
+
#
|
|
48
|
+
# #<BCDD::Result::Success type=:division_completed value=0>
|
|
49
|
+
# #<BCDD::Result::Success type=:division_completed value=0>
|
|
50
|
+
# #<BCDD::Result::Success type=:division_completed value=5>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This class ensures the division of two valid numbers.
|
|
4
|
+
#
|
|
5
|
+
# It uses BCDD::Result to represent the operation as a process
|
|
6
|
+
# (a series of steps to achieve a particular result).
|
|
7
|
+
#
|
|
8
|
+
class Division
|
|
9
|
+
module Contract
|
|
10
|
+
not_nan = -> { _1.respond_to?(:nan?) and _1.nan? and '%p cannot be nan' }
|
|
11
|
+
not_inf = -> { _1.respond_to?(:infinite?) and _1.infinite? and '%p cannot be infinite' }
|
|
12
|
+
|
|
13
|
+
FiniteNumber = ::BCDD::Contract[Numeric] & not_nan & not_inf
|
|
14
|
+
CannotBeZero = ::BCDD::Contract[-> { _1.zero? and 'cannot be zero' }]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
include ::BCDD::Result::Expectations.mixin(
|
|
18
|
+
config: { addon: { continue: true } },
|
|
19
|
+
success: { division_completed: Contract::FiniteNumber },
|
|
20
|
+
failure: {
|
|
21
|
+
invalid_arg: ->(value) { value in [Symbol, Array] },
|
|
22
|
+
division_by_zero: ->(value) { value in [:arg2, Array] }
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def call(arg1, arg2)
|
|
27
|
+
::BCDD::Result.transitions(name: 'Division', desc: 'divide two numbers') do
|
|
28
|
+
Given([Contract::FiniteNumber[arg1], Contract::FiniteNumber[arg2]])
|
|
29
|
+
.and_then(:require_numbers)
|
|
30
|
+
.and_then(:check_for_zeros)
|
|
31
|
+
.and_then(:divide)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def require_numbers((arg1, arg2))
|
|
38
|
+
arg1.invalid? and return Failure(:invalid_arg, [:arg1, arg1.errors])
|
|
39
|
+
arg2.invalid? and return Failure(:invalid_arg, [:arg2, arg2.errors])
|
|
40
|
+
|
|
41
|
+
Continue([arg1.value, arg2.value])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def check_for_zeros(numbers)
|
|
45
|
+
num1 = numbers[0]
|
|
46
|
+
num2 = Contract::CannotBeZero[numbers[1]]
|
|
47
|
+
|
|
48
|
+
num2.invalid? and return Failure(:division_by_zero, [:arg2, num2.errors])
|
|
49
|
+
|
|
50
|
+
num1.zero? and return Success(:division_completed, 0)
|
|
51
|
+
|
|
52
|
+
Continue(numbers)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def divide((num1, num2))
|
|
56
|
+
Success(:division_completed, num1 / num2)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
- [📜 Design by Contract Example](#-design-by-contract-example)
|
|
2
|
+
- [A Shopping Cart](#a-shopping-cart)
|
|
3
|
+
- [The implementation](#the-implementation)
|
|
4
|
+
- [What are the preconditions in this code?](#what-are-the-preconditions-in-this-code)
|
|
5
|
+
- [What are the postconditions in this code?](#what-are-the-postconditions-in-this-code)
|
|
6
|
+
- [What is the invariant in this code?](#what-is-the-invariant-in-this-code)
|
|
7
|
+
- [⚖️ What are the benefits of using this pattern?](#️-what-are-the-benefits-of-using-this-pattern)
|
|
8
|
+
- [How much to do this (apply DbC)?](#how-much-to-do-this-apply-dbc)
|
|
9
|
+
- [Is it worth the overhead of contract checking at runtime?](#is-it-worth-the-overhead-of-contract-checking-at-runtime)
|
|
10
|
+
- [🏃♂️ How to run the application?](#️-how-to-run-the-application)
|
|
11
|
+
|
|
12
|
+
## 📜 Design by Contract Example
|
|
13
|
+
|
|
14
|
+
The **Design by Contract**, or DbC, is an approach where components define their expected behavior with preconditions, postconditions, and invariants, enhancing reliability and code understanding.
|
|
15
|
+
|
|
16
|
+
These are the key concepts of DbC:
|
|
17
|
+
|
|
18
|
+
- **Preconditions:** Conditions that must be met before a method is executed. They specify the input requirements/expectations.
|
|
19
|
+
|
|
20
|
+
- **Postconditions:** Conditions that must hold true after the method has completed its execution. They specify the guarantees or outcomes.
|
|
21
|
+
|
|
22
|
+
- **Invariants:** Invariants are conditions that should always be true for a specific module or class throughout its execution. They represent the essential properties that should never be violated.
|
|
23
|
+
|
|
24
|
+
- **Assertions:** Are statements or checks placed within the code to validate that preconditions, postconditions, and invariants are being satisfied. If an assertion fails, it indicates a violation of the contract, highlighting a potential bug or issue.
|
|
25
|
+
|
|
26
|
+
## A Shopping Cart
|
|
27
|
+
|
|
28
|
+
What if we want to create a shopping cart that:
|
|
29
|
+
- Features
|
|
30
|
+
- Can add items to the cart
|
|
31
|
+
- Can remove items from the cart
|
|
32
|
+
- Can calculate the total price of the cart
|
|
33
|
+
- Has a list of items with a name, quantity, and price per unit.
|
|
34
|
+
- To add or remove an item
|
|
35
|
+
- The name must be a non-empty string.
|
|
36
|
+
- The quantity must be a positive integer.
|
|
37
|
+
- The per unit must be a positive valid number (numeric, not infinite or nan).
|
|
38
|
+
- To remove an item
|
|
39
|
+
- The item name must exist in the cart.
|
|
40
|
+
- The cart must have enough quantity.
|
|
41
|
+
- If the quantity is zero, the item must be removed from the cart.
|
|
42
|
+
- Before and after each operation, the cart must be valid:
|
|
43
|
+
- The items must have a valid name (not-blank), quantity (cannot be negative), and price per unit (positive valid number).
|
|
44
|
+
- This is an **invariant**. It should always be true.
|
|
45
|
+
|
|
46
|
+
### The implementation
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
class ShoppingCart
|
|
50
|
+
module Item
|
|
51
|
+
module Contract
|
|
52
|
+
cannot_be_nan = ->(val) { val.respond_to?(:nan?) and val.nan? and '%p cannot be nan' }
|
|
53
|
+
cannot_be_inf = ->(val) { val.respond_to?(:infinite?) and val.infinite? and '%p cannot be infinite' }
|
|
54
|
+
must_be_positive = ->(label) { ->(val) { val.positive? or "#{label} (%p) must be positive" } }
|
|
55
|
+
|
|
56
|
+
PricePerUnit = ::BCDD::Contract[::Numeric] & cannot_be_nan & cannot_be_inf & must_be_positive['price per unit']
|
|
57
|
+
Quantity = ::BCDD::Contract[::Integer] & must_be_positive['quantity']
|
|
58
|
+
Name = ::BCDD::Contract[::String] & ->(val) { val.empty? and 'item name must be filled' }
|
|
59
|
+
|
|
60
|
+
NameAndData = ::BCDD::Contract.pairs(Name => { quantity: Quantity, price_per_unit: PricePerUnit })
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
module Items
|
|
65
|
+
module Contract
|
|
66
|
+
extend ::BCDD::Contract[::Hash] & ->(items, errors) do
|
|
67
|
+
return if items.empty?
|
|
68
|
+
|
|
69
|
+
Item::Contract::NameAndData[items].then { |it| it.valid? or errors.concat(it.errors) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(items = {})
|
|
75
|
+
@items = +Items::Contract[items]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def add_item(item_name, quantity, price_per_unit)
|
|
79
|
+
Items::Contract.invariant(@items) do |items|
|
|
80
|
+
item_name = +Item::Contract::Name[item_name]
|
|
81
|
+
|
|
82
|
+
item = items[item_name] ||= { quantity: 0, price_per_unit: 0 }
|
|
83
|
+
|
|
84
|
+
item[:price_per_unit] = +Item::Contract::PricePerUnit[price_per_unit]
|
|
85
|
+
item[:quantity] += +Item::Contract::Quantity[quantity]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def remove_item(item_name, quantity)
|
|
90
|
+
Items::Contract.invariant(@items) do |items|
|
|
91
|
+
item_name = +Item::Contract::Name[item_name]
|
|
92
|
+
quantity = +Item::Contract::Quantity[quantity]
|
|
93
|
+
|
|
94
|
+
item = items[item_name]
|
|
95
|
+
|
|
96
|
+
::BCDD::Contract.assert!(item_name, 'item (%p) not found')
|
|
97
|
+
::BCDD::Contract.refute!(item_name, 'item (%p) not enough quantity to remove') { quantity > item[:quantity] }
|
|
98
|
+
|
|
99
|
+
item[:quantity] -= quantity
|
|
100
|
+
|
|
101
|
+
item[:quantity].tap { |number| items.delete(item_name) if number.zero? }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def total_price
|
|
106
|
+
(+Items::Contract[@items]).sum { |_name, data| data[:quantity] * data[:price_per_unit] }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
#### What are the preconditions in this code?
|
|
112
|
+
|
|
113
|
+
- The `add_item` method expects three arguments: `item_name`, `quantity`, and `price_per_unit`.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
def add_item(item_name, quantity, price_per_unit)
|
|
117
|
+
Items::Contract.invariant(@items) do |items|
|
|
118
|
+
item_name = +Item::Contract::Name[item_name]
|
|
119
|
+
|
|
120
|
+
item = items[item_name] ||= { quantity: 0, price_per_unit: 0 }
|
|
121
|
+
|
|
122
|
+
item[:quantity] += +Item::Contract::Quantity[quantity]
|
|
123
|
+
item[:price_per_unit] = +Item::Contract::PricePerUnit[price_per_unit]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Contract:**
|
|
129
|
+
|
|
130
|
+
- The name must be a non-empty string.
|
|
131
|
+
- The quantity must be a positive integer.
|
|
132
|
+
- The per unit must be a positive valid number (numeric, not infinite or nan).
|
|
133
|
+
|
|
134
|
+
**Note:** The `+` operator is used to perform a strict validation, raising an exception if the input does not match the expected type.
|
|
135
|
+
|
|
136
|
+
- The `remove_item` method expects two arguments: `item_name` and `quantity`.
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
def remove_item(item_name, quantity)
|
|
140
|
+
Items::Contract.invariant(@items) do |items|
|
|
141
|
+
item_name = +Item::Contract::Name[item_name]
|
|
142
|
+
quantity = +Item::Contract::Quantity[quantity]
|
|
143
|
+
|
|
144
|
+
item = items[item_name]
|
|
145
|
+
|
|
146
|
+
::BCDD::Contract.assert!(item_name, 'item (%p) not found')
|
|
147
|
+
::BCDD::Contract.refute!(item_name, 'item (%p) not enough quantity to remove') { quantity > item[:quantity] }
|
|
148
|
+
|
|
149
|
+
item[:quantity] -= quantity
|
|
150
|
+
|
|
151
|
+
item[:quantity].then { |number| items.delete(item_name) if number.zero? }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Contract:**
|
|
157
|
+
|
|
158
|
+
- The name must be a non-empty string.
|
|
159
|
+
- The quantity must be a positive integer.
|
|
160
|
+
- The item name must exist in the cart.
|
|
161
|
+
- The cart must have enough quantity.
|
|
162
|
+
|
|
163
|
+
#### What are the postconditions in this code?
|
|
164
|
+
|
|
165
|
+
Did you notice that all the methods are wrapped by the `Items::Contract.invariant` method?
|
|
166
|
+
|
|
167
|
+
In this case, the `Items::Contract` contains the postconditions and the invariant.
|
|
168
|
+
|
|
169
|
+
#### What is the invariant in this code?
|
|
170
|
+
|
|
171
|
+
The invariant is the `Items::Contract` contract, which ensures that the items are valid before and after each operation.
|
|
172
|
+
|
|
173
|
+
**Contract:**
|
|
174
|
+
|
|
175
|
+
- The items must have a valid name (not-blank), quantity (cannot be negative), and price per unit (positive valid number).
|
|
176
|
+
|
|
177
|
+
## ⚖️ What are the benefits of using this pattern?
|
|
178
|
+
|
|
179
|
+
- The code will work properly, as the preconditions and postconditions are validated by each method.
|
|
180
|
+
- The code is simple to understand and test, as the preconditions and postconditions are explicit.
|
|
181
|
+
- The object is always valid, an invariant can be defined to ensure the object state is valid before and after each operation.
|
|
182
|
+
|
|
183
|
+
### How much to do this (apply DbC)?
|
|
184
|
+
|
|
185
|
+
It depends on the context. In this example, the `ShoppingCart` is a core application component, so it is worth applying DbC. However, if it was simple, it may not be worth it.
|
|
186
|
+
|
|
187
|
+
Use some or all the DbC concepts (preconditions, postconditions, invariants, assertions) to ensure the behavior of critical components.
|
|
188
|
+
|
|
189
|
+
### Is it worth the overhead of contract checking at runtime?
|
|
190
|
+
|
|
191
|
+
Having a slow and correct code is better than a fast and incorrect code. Slow is relative as an I/O (DB query, network call, etc.) operation is much slower than a contract check.
|
|
192
|
+
|
|
193
|
+
## 🏃♂️ How to run the application?
|
|
194
|
+
|
|
195
|
+
In the same directory as this `README`, run:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
rake
|
|
199
|
+
|
|
200
|
+
# Output sample:
|
|
201
|
+
#
|
|
202
|
+
# -- Adding items --
|
|
203
|
+
#
|
|
204
|
+
# Total Price: $7.5
|
|
205
|
+
#
|
|
206
|
+
# -- Removing items --
|
|
207
|
+
#
|
|
208
|
+
# Total Price: $4.5
|
|
209
|
+
#
|
|
210
|
+
# -- Invalid input --
|
|
211
|
+
#
|
|
212
|
+
# item (Apple) not enough quantity to remove
|
|
213
|
+
#
|
|
214
|
+
# --------------------------------------------
|
|
215
|
+
# -- Violating the invariant deliberately --
|
|
216
|
+
# --------------------------------------------
|
|
217
|
+
#
|
|
218
|
+
# rake aborted!
|
|
219
|
+
# BCDD::Contract::Error: (Apple: (quantity: "1" must be a Integer)) (BCDD::Contract::Error)
|
|
220
|
+
# /.../lib/bcdd/contract/core/checking.rb:30:in `raise_validation_errors!'
|
|
221
|
+
# /.../lib/bcdd/contract/core/checker.rb:18:in `invariant'
|
|
222
|
+
# /.../examples/design_by_contract/lib/shopping_cart.rb:44:in `remove_item'
|
|
223
|
+
# /.../examples/design_by_contract/Rakefile:52:in `block (2 levels) in <top (required)>'
|
|
224
|
+
# /.../examples/design_by_contract/Rakefile:54:in `block in <top (required)>'
|
|
225
|
+
# Tasks: TOP => default
|
|
226
|
+
# (See full trace by running task with --trace)
|
|
227
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if RUBY_VERSION <= '3.1'
|
|
4
|
+
puts 'This example requires Ruby 3.1 or higher.'
|
|
5
|
+
exit! 1
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
require_relative 'config'
|
|
9
|
+
|
|
10
|
+
task :default do
|
|
11
|
+
puts '==================='
|
|
12
|
+
puts 'Design by Contract '
|
|
13
|
+
puts '==================='
|
|
14
|
+
puts
|
|
15
|
+
|
|
16
|
+
cart = ShoppingCart.new
|
|
17
|
+
|
|
18
|
+
puts '-- Adding items --'
|
|
19
|
+
puts
|
|
20
|
+
|
|
21
|
+
cart.add_item('Apple', 5, 1.5)
|
|
22
|
+
|
|
23
|
+
puts "Total Price: $#{cart.total_price}"
|
|
24
|
+
puts
|
|
25
|
+
|
|
26
|
+
puts '-- Removing items --'
|
|
27
|
+
puts
|
|
28
|
+
|
|
29
|
+
cart.remove_item('Apple', 2)
|
|
30
|
+
|
|
31
|
+
puts "Total Price: $#{cart.total_price}"
|
|
32
|
+
puts
|
|
33
|
+
|
|
34
|
+
puts '-- Invalid input --'
|
|
35
|
+
puts
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
cart.remove_item('Apple', 4)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
puts e.message
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
puts
|
|
44
|
+
puts '--------------------------------------------'
|
|
45
|
+
puts '-- Violating the invariant deliberately --'
|
|
46
|
+
puts '--------------------------------------------'
|
|
47
|
+
puts
|
|
48
|
+
|
|
49
|
+
[
|
|
50
|
+
-> { cart.instance_variable_set(:@items, { 'Apple' => { quantity: [-1, '1'].sample, price_per_unit: 1.5 } }) },
|
|
51
|
+
-> { cart.instance_variable_set(:@items, { 'Apple' => { quantity: 1, price_per_unit: [-1.5, '1.5'].sample } }) },
|
|
52
|
+
-> { cart.instance_variable_set(:@items, { ['', nil].sample => { quantity: 1, price_per_unit: 1.5 } }) }
|
|
53
|
+
].sample.call
|
|
54
|
+
|
|
55
|
+
[
|
|
56
|
+
-> { cart.add_item('Orange', 1, 1) },
|
|
57
|
+
-> { cart.remove_item('Apple', 1) },
|
|
58
|
+
-> { cart.total_price }
|
|
59
|
+
].sample.call
|
|
60
|
+
end
|