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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +128 -0
  3. data/CHANGELOG.md +45 -0
  4. data/CODE_OF_CONDUCT.md +84 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +964 -0
  7. data/Rakefile +18 -0
  8. data/Steepfile +32 -0
  9. data/examples/README.md +11 -0
  10. data/examples/anti_corruption_layer/README.md +212 -0
  11. data/examples/anti_corruption_layer/Rakefile +30 -0
  12. data/examples/anti_corruption_layer/app/models/payment/charge_credit_card.rb +36 -0
  13. data/examples/anti_corruption_layer/config.rb +20 -0
  14. data/examples/anti_corruption_layer/lib/payment_gateways/adapters/circle_up.rb +19 -0
  15. data/examples/anti_corruption_layer/lib/payment_gateways/adapters/pay_friend.rb +19 -0
  16. data/examples/anti_corruption_layer/lib/payment_gateways/contract.rb +15 -0
  17. data/examples/anti_corruption_layer/lib/payment_gateways/response.rb +5 -0
  18. data/examples/anti_corruption_layer/lib/payment_gateways.rb +11 -0
  19. data/examples/anti_corruption_layer/vendor/circle_up/client.rb +11 -0
  20. data/examples/anti_corruption_layer/vendor/pay_friend/client.rb +11 -0
  21. data/examples/business_processes/README.md +245 -0
  22. data/examples/business_processes/Rakefile +50 -0
  23. data/examples/business_processes/config.rb +14 -0
  24. data/examples/business_processes/lib/division.rb +58 -0
  25. data/examples/design_by_contract/README.md +227 -0
  26. data/examples/design_by_contract/Rakefile +60 -0
  27. data/examples/design_by_contract/config.rb +13 -0
  28. data/examples/design_by_contract/lib/shopping_cart.rb +62 -0
  29. data/examples/ports_and_adapters/README.md +246 -0
  30. data/examples/ports_and_adapters/Rakefile +68 -0
  31. data/examples/ports_and_adapters/app/models/user/record/repository.rb +13 -0
  32. data/examples/ports_and_adapters/app/models/user/record.rb +7 -0
  33. data/examples/ports_and_adapters/config.rb +28 -0
  34. data/examples/ports_and_adapters/db/setup.rb +16 -0
  35. data/examples/ports_and_adapters/lib/user/creation.rb +19 -0
  36. data/examples/ports_and_adapters/lib/user/data.rb +5 -0
  37. data/examples/ports_and_adapters/lib/user/repository.rb +24 -0
  38. data/examples/ports_and_adapters/test/user_test/repository.rb +21 -0
  39. data/lib/bcdd/contract/assertions.rb +21 -0
  40. data/lib/bcdd/contract/config.rb +25 -0
  41. data/lib/bcdd/contract/core/checker.rb +37 -0
  42. data/lib/bcdd/contract/core/checking.rb +38 -0
  43. data/lib/bcdd/contract/core/factory.rb +32 -0
  44. data/lib/bcdd/contract/core/proxy.rb +19 -0
  45. data/lib/bcdd/contract/core.rb +12 -0
  46. data/lib/bcdd/contract/interface.rb +25 -0
  47. data/lib/bcdd/contract/list.rb +45 -0
  48. data/lib/bcdd/contract/map/pairs.rb +47 -0
  49. data/lib/bcdd/contract/map/schema.rb +50 -0
  50. data/lib/bcdd/contract/map.rb +10 -0
  51. data/lib/bcdd/contract/proxy.rb +40 -0
  52. data/lib/bcdd/contract/registry.rb +67 -0
  53. data/lib/bcdd/contract/unit/checker.rb +51 -0
  54. data/lib/bcdd/contract/unit/factory.rb +53 -0
  55. data/lib/bcdd/contract/unit.rb +40 -0
  56. data/lib/bcdd/contract/version.rb +7 -0
  57. data/lib/bcdd/contract.rb +118 -0
  58. data/lib/bcdd-contract.rb +3 -0
  59. data/sig/bcdd/contract/assertions.rbs +7 -0
  60. data/sig/bcdd/contract/config.rbs +15 -0
  61. data/sig/bcdd/contract/core.rbs +60 -0
  62. data/sig/bcdd/contract/interface.rbs +12 -0
  63. data/sig/bcdd/contract/list.rbs +21 -0
  64. data/sig/bcdd/contract/map.rbs +45 -0
  65. data/sig/bcdd/contract/proxy.rbs +8 -0
  66. data/sig/bcdd/contract/registry.rbs +25 -0
  67. data/sig/bcdd/contract/unit.rbs +39 -0
  68. data/sig/bcdd/contract.rbs +31 -0
  69. 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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/inline'
4
+
5
+ $LOAD_PATH.unshift(__dir__)
6
+
7
+ gemfile do
8
+ source 'https://rubygems.org'
9
+
10
+ gem 'bcdd-result', '>= 0.12.0'
11
+ gem 'bcdd-contract', path: '../../'
12
+ end
13
+
14
+ require 'lib/division'
@@ -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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/inline'
4
+
5
+ $LOAD_PATH.unshift(__dir__)
6
+
7
+ gemfile do
8
+ source 'https://rubygems.org'
9
+
10
+ gem 'bcdd-contract', path: '../../'
11
+ end
12
+
13
+ require 'lib/shopping_cart'