operations 0.0.1 → 0.6.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +33 -0
  3. data/.gitignore +4 -0
  4. data/.rspec +0 -2
  5. data/.rubocop.yml +21 -0
  6. data/.rubocop_todo.yml +36 -0
  7. data/Appraisals +8 -0
  8. data/CHANGELOG.md +11 -0
  9. data/Gemfile +8 -2
  10. data/README.md +910 -5
  11. data/Rakefile +3 -1
  12. data/gemfiles/rails.5.2.gemfile +14 -0
  13. data/gemfiles/rails.6.0.gemfile +14 -0
  14. data/gemfiles/rails.6.1.gemfile +14 -0
  15. data/gemfiles/rails.7.0.gemfile +14 -0
  16. data/gemfiles/rails.7.1.gemfile +14 -0
  17. data/lib/operations/command.rb +412 -0
  18. data/lib/operations/components/base.rb +79 -0
  19. data/lib/operations/components/callback.rb +55 -0
  20. data/lib/operations/components/contract.rb +20 -0
  21. data/lib/operations/components/idempotency.rb +70 -0
  22. data/lib/operations/components/on_failure.rb +16 -0
  23. data/lib/operations/components/on_success.rb +35 -0
  24. data/lib/operations/components/operation.rb +37 -0
  25. data/lib/operations/components/policies.rb +42 -0
  26. data/lib/operations/components/prechecks.rb +38 -0
  27. data/lib/operations/components/preconditions.rb +45 -0
  28. data/lib/operations/components.rb +5 -0
  29. data/lib/operations/configuration.rb +15 -0
  30. data/lib/operations/contract/messages_resolver.rb +11 -0
  31. data/lib/operations/contract.rb +39 -0
  32. data/lib/operations/convenience.rb +102 -0
  33. data/lib/operations/form/attribute.rb +42 -0
  34. data/lib/operations/form/builder.rb +85 -0
  35. data/lib/operations/form.rb +194 -0
  36. data/lib/operations/result.rb +122 -0
  37. data/lib/operations/test_helpers.rb +71 -0
  38. data/lib/operations/types.rb +6 -0
  39. data/lib/operations/version.rb +3 -1
  40. data/lib/operations.rb +42 -2
  41. data/operations.gemspec +20 -4
  42. metadata +164 -9
  43. data/.travis.yml +0 -6
data/README.md CHANGED
@@ -1,8 +1,49 @@
1
1
  # Operations
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/operations`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ ## A bit of theory
4
4
 
5
- TODO: Delete this and the text above, and describe your gem
5
+ ### What is an operation
6
+
7
+ First of all, let's define an application as a combination of domain logic and application state. Domain logic can either read and return the parts of the application state to the consumer (Query) or can modify the application state (Command).
8
+
9
+ **Note:** There is a concept that is called Command Query Separation (CQS) or Command Query Responsibility Segregation (CQRS) and which can be used at any level of the implementation (classes in OOP, API) but the general idea is simply not to mix these two up.
10
+
11
+ While Query is a simple concept (just fetch data from the application state and render it to the consumer in the requested form), Command implementation can have a lot of caveats.
12
+
13
+ _Command_ or _business operation_ or _interaction_ or _use case_ or _application service_ (DDD term) even just _service_ (this term is so ambiguous) is a predefined sequence of programmed actions that can be called by a user (directly in the code or via the API) and modifies the application state. In the scope of this framework, we prefer the _operation_ term though.
14
+
15
+ These modifications are atomic in the sense that the application state is supposed to be consistent and valid after the operation execution. It might be eventually consistent but the idea is that the application state is valid after the operation execution and in a normal case, there should be no need to call a complimentary operation to make the state valid.
16
+
17
+ Operations can also create different side effects such as: sending an email message, making asynchronous API calls (shoot-and-forget), pushing events to an event bus, etc.
18
+
19
+ **Note:** An important note is that contrary to the pure DDD approach that considers aggregate a transactional boundary, in this framework - the operation itself is wrapped inside of a transaction, though it is configurable.
20
+
21
+ The bottom line here is: any modifications to the application state, whether it is done via controller or Sidekiq job, or even console should happen in operations. Operation is the only entry point to the domain modification.
22
+
23
+ ### The Rails way
24
+
25
+ In a classic Rails application, the role of business operations is usually played by ActiveRecord models. When a single model implements multiple use cases, it creates messy noodles of code that are trying to incorporate all the possible paths of execution. This leads to a lot of not funny things including conditional callbacks and virtual attributes on models. Simply put, this way violates the SRP principle and the consequences are well known.
26
+
27
+ Each operation in turn contains a single execution routine, a single sequence of program calls that is easy to read and modify.
28
+
29
+ This approach might look more fragile in the sense that ActiveRecord big ball of mud that might look like centralized logic storage and if we will not use it, we might miss important parts of domain logic and produce an invalid application state (e.g. after each update an associated record in the DB supposed to be updated somehow). This might be the case indeed but it can be easily solved by using a tiny bit of determination. The benefits of the operations approach easily overweigh this potential issue.
30
+
31
+ ### Operations prerequisites
32
+
33
+ Operations are supposed to be the first-class citizens of an application. This means that ideally application state is supposed to be modified using operations exclusively. There are some exceptions though:
34
+
35
+ * In tests, sometimes it is faster and simpler to create an application state using direct storage calls (factories) since the desired state might be a result of multiple operations' calls which can be omitted for the sake of performance.
36
+ * When the running application state is inconsistent or invalid, there might not be an appropriate operation implemented to fix the state. So we have to use direct storage modifications.
37
+
38
+ **NOTE:** When application state is valid but an appropriate operation does not exist, it is better to create one. Especially if the requested state modification needs to happen more than 1 time.
39
+
40
+ ### Alternatives
41
+
42
+ There are many alternatives to this framework in the Rails world such as:
43
+
44
+ * https://github.com/AaronLasseigne/active_interaction
45
+ * https://github.com/toptal/granite
46
+ * https://github.com/trailblazer/trailblazer
6
47
 
7
48
  ## Installation
8
49
 
@@ -22,7 +63,872 @@ Or install it yourself as:
22
63
 
23
64
  ## Usage
24
65
 
25
- TODO: Write usage instructions here
66
+ ### Getting started
67
+
68
+ The simplest operation that is implemented with the framework will look like this:
69
+
70
+ ```ruby
71
+ create_user = Operations::Command.new(
72
+ User::Create.new,
73
+ contract: User::CreateContract.new,
74
+ policy: nil
75
+ )
76
+
77
+ create_user.call({ email: "user@gmail.com" })
78
+ ```
79
+
80
+ Where the operation body is implemented as:
81
+
82
+ ```ruby
83
+ class User::Create
84
+ include Dry::Monads[:result]
85
+
86
+ def call(params, **)
87
+ user = User.new(params)
88
+ if user.save
89
+ Success(user: user)
90
+ else
91
+ Failure(:user_not_created)
92
+ end
93
+ end
94
+ end
95
+ ```
96
+
97
+ And the contract as:
98
+
99
+ ```ruby
100
+ class User::CreateContract < Operations::Contract
101
+ params do
102
+ required(:email).filled(:string, format?: URI::MailTo::EMAIL_REGEXP)
103
+ end
104
+ end
105
+ ```
106
+
107
+ Where `Operations::Contract` is actually a [Dry::Validation::Contract](https://dry-rb.org/gems/dry-validation/) with tiny additions.
108
+
109
+ Everything in the framework is built with the composition over inheritance approach in mind. An instance of `Operations::Command` essentially runs a pipeline through the steps passed into the initializer. In this particular case, the passed parameters will be validated by the contract and if everything is good, will be passed into the operation body.
110
+
111
+ **Important:** the whole operation pipeline (except [callbacks](#callbacks-on-success-on-failure)) is wrapped within a transaction by default. This behavior can be adjusted by changing `Operations::Configuration#transaction` (see [Configuration](#configuration) section).
112
+
113
+ ### Operation Result
114
+
115
+ A result of any operation call is an instance of `Operation::Result` which contains all the necessary information:
116
+
117
+ ```ruby
118
+ create_user.call({ email: "user@gmail.com" }) #=>
119
+ # #<Operations::Result ...>,
120
+ # component = :operation,
121
+ # params = {:email=>"user@gmail.com"},
122
+ # context = {:user=>#<User ...>},
123
+ # on_success = [],
124
+ # on_failure = [],
125
+ # errors = #<Dry::Validation::MessageSet messages=[] options={}>>
126
+ ```
127
+
128
+ * `component` - the stage where execution stopped. If it operation failed on precondition it is going to be `:preconditions`. See `Operations::Command::COMPONENTS` for the full list.
129
+ * `params` - params passed to the operation `call` and other methods.
130
+ * `context` - initial context merged with the context generated by operation body returned in Success() monad.
131
+ * `on_success`, `on_failure` - corresponding callback results.
132
+ * `errors` - a list of errors returned by contract/policies/preconditions.
133
+
134
+ There are several useful methods on `Operations::Result`:
135
+
136
+ * `success?`, `failure?` - checks for errors presence.
137
+ * `errors(full: true)` - this is not only an attribute but also a method accepting additional params just like `Dry::Validation::Contract#errors` does.
138
+ * `failed_policy?`, `failed_precondition?`, `failed_precheck?` - checks whether the operation failed on policy, precondition, or any of them respectively. It is possible to check for exact error codes as well.
139
+
140
+ ### Params and context
141
+
142
+ Every operation input consists of 2 components: params and context. Params is a hash and it is passed as a hash argument while context is passed as kwargs.
143
+
144
+ Params are used to pass user input. It will be coerced by the contract implementation and will contain only simple types like strings or integers.
145
+ Context should never contain any user input and used to pass contextual data like current_user or, say, ActiveRecord models that were fetched from DB before the operation call.
146
+
147
+ ```ruby
148
+ create_post.call({ title: "Post Title" }, current_user: current_user)
149
+ ```
150
+
151
+ The context will be passed further to every component in the pipeline:
152
+
153
+ ```ruby
154
+ class Post::Create
155
+ def call(params, current_user:, **)
156
+ current_user.posts.new(params)
157
+
158
+ Success({})
159
+ end
160
+ end
161
+ ```
162
+
163
+ A rule of thumb: context conveys all the data you don't want to be passed by the user.
164
+
165
+ ### Contract
166
+
167
+ Besides params coercion, a contract is responsible for filling in additional context. In Dry::Validation, rules are used to perform this:
168
+
169
+ ```ruby
170
+ class Post::UpdateContract < Operations::Contract
171
+ params do
172
+ required(:post_id).filled(:integer)
173
+ required(:title).filled(:string)
174
+ end
175
+
176
+ rule(:post_id) do |context:|
177
+ context[:post] = Post.find(value)
178
+ end
179
+ end
180
+ ```
181
+
182
+ Then, the operation body can proceed with the found post handling:
183
+
184
+ ```ruby
185
+ class Post::Update
186
+ def call(params, post:, **)
187
+ post.update(params)
188
+
189
+ Success({})
190
+ end
191
+ end
192
+ ```
193
+
194
+ A more advanced example of finding ActiveRecord records:
195
+
196
+ ```ruby
197
+ class Post::UpdateContract < Operations::Contract
198
+ params do
199
+ optional(:post_id).filled(:integer)
200
+ required(:title).filled(:string)
201
+ end
202
+
203
+ rule do |context:|
204
+ next key.failure(:key?) unless key?(:post_id) && context[:post]
205
+
206
+ post = Post.find_by(id: values[:comment_id])
207
+
208
+ if post
209
+ context[:post] = post
210
+ else
211
+ key.failure(:not_found)
212
+ end
213
+ end
214
+ end
215
+ ```
216
+
217
+ **Important:** Please check [Generic preconditions and policies](#generic-preconditions-and-policies) on reasons why we don't assign nil post to the context.
218
+
219
+ Now notice that `post_id` param became optional and `required` validation is handled by the rule conditionally. This allows passing either param or a post instance itself if it exists:
220
+
221
+ ```ruby
222
+ post_update.call({ post_id: 123 })
223
+ post_update.call({}, post: post)
224
+ ```
225
+
226
+ Both of the calls above are going to work exactly alike but in the first case, there will be an additional database query.
227
+
228
+ It is possible to extract context filling into some kind of generic macro:
229
+
230
+ ```ruby
231
+ class OperationContract < Operations::Contract
232
+ def self.find(context_key)
233
+ rule do |context:|
234
+ params_key = :"#{name}_id"
235
+
236
+ next key.failure(:key?) unless key?(params_key) && context[context_key]
237
+
238
+ record = context_key.to_s.classify.constantize.find_by(id: values[params_key])
239
+
240
+ if record
241
+ context[context_key] = record
242
+ else
243
+ key.failure(:not_found, tokens: { context_key: context_key })
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ class Post::UpdateContract < OperationContract
250
+ params do
251
+ optional(:post_id).filled(:integer)
252
+ required(:title).filled(:string)
253
+ end
254
+
255
+ find :post
256
+ end
257
+ ```
258
+
259
+ **Important:** contract is solely responsible for populating operation context from given params. At the same time, it should be flexible enough to accept the passed context for optimization purposes.
260
+
261
+ ### Operation body
262
+
263
+ The operation body can be any callable object (respond to the `call` method), even a lambda. But it is always better to define it as a class since there might be additional instance methods and [dependency injections](#dependency-injection).
264
+
265
+ In any event, the operation body should return a Dry::Monad::Result instance. In case of a Failure, it will be converted into an `Operation::Result#error` and in case of Success(), its content will be merged into the operation context.
266
+
267
+ **Important:** since the Success result payload is merged inside of a context, it is supposed to be a hash.
268
+
269
+ ### Application container
270
+
271
+ Operations are built using the principle: initializers are for [dependencies](#dependency-injection). This means that the Command instance is supposed to be initialized once for the application lifetime and is a perfect candidate for some kind of application container to be stored in.
272
+
273
+ But if your application does not have an application container - the best approach would be to use class methods to store Command instances.
274
+
275
+ ```ruby
276
+ class Post::Update
277
+ def self.default
278
+ @default ||= Operations::Command.new(
279
+ new,
280
+ contract: User::CreateContract.new,
281
+ policy: nil
282
+ )
283
+ end
284
+
285
+ def call(params, post: **)
286
+ end
287
+ end
288
+ ```
289
+
290
+ And then this command can be called from anywhere (for example, controller) using:
291
+
292
+ ```ruby
293
+ def update
294
+ post_update = Post::Update.default.call(params[:post], current_user: current_user)
295
+
296
+ if post_update.success?
297
+ redirect_to(post_path(post_update.context[:post].id))
298
+ else
299
+ render :edit
300
+ end
301
+ end
302
+ ```
303
+
304
+ ### Dependency Injection
305
+
306
+ Dependency injection can be used to provide IO clients with the operation. It could be DB repositories or API clients. The best way is to use Dry::Initializer for it since it provides the ability to define acceptable types.
307
+
308
+ If you still prefer to use ActiveRecord, it is worth creating a wrapper around it providing Dry::Monad-compatible interfaces.
309
+
310
+ ```ruby
311
+ class ActiveRecordRepository
312
+ include Dry::Monads[:result]
313
+ extend Dry::Initializer
314
+
315
+ param :model, type: Types.Instance(Class).constrained(lt: ActiveRecord::Base)
316
+
317
+ def create(**attributes)
318
+ record = model.new(**attributes)
319
+
320
+ if record.save
321
+ Success(model.name.underscore.to_sym => record) # Success(post: record)
322
+ else
323
+ failure_from_errors(record.errors) # Failure([{ message: "Must be present", code: :blank, path: "title" }])
324
+ end
325
+ end
326
+
327
+ private
328
+
329
+ def failure_from_errors(errors)
330
+ failures = errors.map do |error|
331
+ { message: error.message, code: error.type, path: error.attribute }
332
+ end
333
+ Failure(failures)
334
+ end
335
+ end
336
+ ```
337
+
338
+ Then this repository can be used in the operation directly:
339
+
340
+ ```ruby
341
+ class Post::Create
342
+ extend Dry::Initializer
343
+
344
+ option :post_repository, default: proc { ActiveRecordRepository.new(Post) }
345
+
346
+ def call(params, **)
347
+ post_repository.create(**params)
348
+ end
349
+ end
350
+ ```
351
+
352
+ `ActiveRecordRepository#create` returns a proper Success() monad which will become a part of `Operation::Result#context` returned by Composite or a properly built Failure() monad which will be incorporated into `Operation::Result#errors`.
353
+
354
+ Of course, it is possible to use [dry-auto_inject](https://dry-rb.org/gems/dry-auto_inject/) along with [dry-container](https://dry-rb.org/gems/dry-container/) to make things even fancier.
355
+
356
+ ### Configuration
357
+
358
+ The gem has a global default configuration:
359
+
360
+ ```ruby
361
+ Operations.configure(
362
+ error_reporter: -> (message, payload) { Sentry.capture_message(message, extra: payload) },
363
+ )
364
+ ```
365
+
366
+ But also, a configuration instance can be passed directly to a Command initializer (for example, to switch off the wrapping DB transaction for a single operation):
367
+
368
+ ```ruby
369
+ Operations::Command.new(..., configuration: Operations.default_config.new(transaction: -> {}))
370
+ ```
371
+
372
+ It is possible to call `configuration_instance.new` to receive an updated configuration instance since it is a `Dry::Struct`
373
+
374
+ ### Preconditions
375
+
376
+ When we need to check against the application state, preconditions are coming to help. Obviously, we can do all those checks in Contract rule definitions but it is great to have separate kinds of components (a separate stage in the pipeline) for this particular reason as it gives the ability to check them in isolation.
377
+
378
+ There are many potential scenarios when it can be handy. For example, we might need to render a button only when the subject entity satisfies preconditions for a particular operation. Or we want to return a list of possible operations from an API we have.
379
+
380
+ **Important:** a rule of thumb here is that preconditions don't depend on the user input, they only check the existing state of the application and they are supposed to access only the operation context for this purpose, not params.
381
+
382
+ ```ruby
383
+ class Post::Publish
384
+ def self.default
385
+ @default ||= Operations::Command.new(
386
+ new,
387
+ contract: Contract.new,
388
+ policy: nil,
389
+ preconditions: [NotPublishedPrecondition.new]
390
+ )
391
+ end
392
+
393
+ def call(_, post:, **)
394
+ post.update(published_at: Time.zone.now)
395
+
396
+ Success({})
397
+ end
398
+ end
399
+
400
+ class Post::Publish::Contract < OperationContract
401
+ params do
402
+ optional(:post_id).filled(:integer)
403
+ end
404
+
405
+ find :post
406
+ end
407
+
408
+ class Post::Publish::NotPublishedPrecondition
409
+ include Dry::Monads[:result]
410
+
411
+ def call(post:, **)
412
+ return Failure(:already_published) if post.published?
413
+
414
+ Success()
415
+ end
416
+ end
417
+ ```
418
+
419
+ Precondition is supposed to return either a Success() monad if an operation is ok to proceed with the updates or `Failure(:error_symbol)` if we want to interrupt the operation execution.
420
+
421
+ Besides just a symbol, it is possible to return a Failure with a hash:
422
+
423
+ ```ruby
424
+ class Post::Publish::NotPublishedPrecondition
425
+ include Dry::Monads[:result]
426
+
427
+ def call(post:, **)
428
+ return Failure(error: :already_published, tokens: { published_at: post.published_at }) if post.published?
429
+
430
+ Success()
431
+ end
432
+ end
433
+ ```
434
+
435
+ Then `tokens:` values can be used in the translation string: `Post is already published at %{published_at}` as a way to provide more context to the end user.
436
+
437
+ ```ruby
438
+ result = Post::Publish.default.call({ post_id: 123 }) #=>
439
+ # #<Operations::Result ...>,
440
+ # component = :preconditions,
441
+ # params = {:post_id=>123},
442
+ # context = {:post=>#<Post id=123, ...>},
443
+ # on_success = [],
444
+ # on_failure = [],
445
+ # errors = #<Dry::Validation::MessageSet messages=[
446
+ # #<Dry::Validation::Message text="Post is already published at 20.02.2023 12:00" path=[nil] meta={:code=>:already_published}>
447
+ # ] options={}>>
448
+ result.failed_precheck? #=> true
449
+ result.failed_precondition? #=> true
450
+ result.failed_policy? #=> false
451
+ result.failed_precondition?(:already_published) #=> true
452
+ result.failed_precheck?(:already_published) #=> true
453
+ result.failed_precondition?(:another_code) #=> false
454
+ result.failed_precheck?(:another_code) #=> false
455
+ ```
456
+
457
+ Alternatively, it is possible to return either just an error_symbol or `nil` from the precondition where nil is interpreted as a lack of error. In this case, the precondition becomes a bit less bulky:
458
+
459
+ ```ruby
460
+ class Post::Publish::NotPublishedPrecondition
461
+ def call(post:, **)
462
+ :already_published if post.published?
463
+ end
464
+ end
465
+ ```
466
+
467
+ It is up to the developer which notion to use but we recommend a uniform application-wide approach to be established.
468
+
469
+ To resolve an error message from an error code, the contract's MessageResolver is used. So the rules are the same as for the failures returned by `Operations::Contract`.
470
+
471
+ It is possible to pass multiple preconditions. They will be evaluated all at once and if even one of them fails - the operation fails as well.
472
+
473
+ ```ruby
474
+ class Post::Publish
475
+ def self.default
476
+ @default ||= Operations::Command.new(
477
+ new,
478
+ contract: Contract.new,
479
+ policy: nil,
480
+ preconditions: [
481
+ NotPublishedPrecondition.new,
482
+ ApprovedPrecondition.new
483
+ ]
484
+ )
485
+ end
486
+ end
487
+
488
+ class Post::Publish::ApprovedPrecondition
489
+ def call(post:, **)
490
+ :not_approved_yet unless post.approved?
491
+ end
492
+ end
493
+ ```
494
+
495
+ Now we want to render a button in the interface:
496
+
497
+ ```erb
498
+ <% if Post::Publish.default.callable?(post: @post) %>
499
+ <% link_to "Publish post", publish_post_url, method: :patch %>
500
+ <% end %>
501
+ ```
502
+
503
+ In this case, you may notice that the post was found before in the controller action and since we have a smart finder rule in the contract, the operation is not going to need `post_id` param and will utilize the given `@post` instance.
504
+
505
+ There are 4 methods to be used for such checks:
506
+
507
+ * `possible(**context)` - returns an operation result (success or failure depending on precondition checks result). Useful when you need to check the exact error that happened.
508
+ * `possible?(**context)` - the same as the previous one but returns a boolean result.
509
+ * `callable(**context)` - checks for both preconditions and [policies](#policies).
510
+ * `callable?(**context)` - the same as the previous one but returns a boolean result.
511
+
512
+ `callable/callable?` will be the method used in 99% of cases, there are very few situations when one needs to check preconditions separately from policies.
513
+
514
+ ### Policies
515
+
516
+ Now we need to check if the current actor can perform the operation. Policies are utilized for this purpose:
517
+
518
+ ```ruby
519
+ class Post::Publish
520
+ def self.default
521
+ @default ||= Operations::Command.new(
522
+ new,
523
+ contract: Contract.new,
524
+ policy: AuthorPolicy.new,
525
+ )
526
+ end
527
+
528
+ def call(_, post:, **)
529
+ post.update(published_at: Time.zone.now)
530
+
531
+ Success({})
532
+ end
533
+ end
534
+
535
+ class Post::Publish::AuthorPolicy
536
+ def call(post:, current_user:, **)
537
+ post.author == current_user
538
+ end
539
+ end
540
+ ```
541
+
542
+ The difference between policies and preconditions is simple: policies generally involve current actor checks against the rest of the context while preconditions check the rest of the context without the actor's involvement. Both should be agnostic of user input.
543
+
544
+ Policies are separate from preconditions because in we usually treat the results of those checks differently. For example, if an operation fails on preconditions we might want to render a disabled button with an error message as a hint but if the policy fails - we don't render the button at all.
545
+
546
+ Similarly to preconditions, policies are having 2 returned values signatures: `true/false` and `Success()/Failure(:error_code)`. In case of `false`, an `:unauthorized` error code will be returned as a result. It is possible to return any error code using the `Failure` monad.
547
+
548
+ ```ruby
549
+ class Post::Publish::AuthorPolicy
550
+ include Dry::Monads[:result]
551
+
552
+ def call(post:, current_user:, **)
553
+ post.author == current_user ? Success() : Failure(:not_an_author)
554
+ end
555
+ end
556
+ ```
557
+
558
+ It is possible to pass multiple policies, and all of them have to succeed similarly to preconditions. Though it is impossible not to pass any policies for security reasons. If an operation is internal to the system and not exposed to the end user, `policy: nil` should be passed to `Operations::Command` instance anyway just to explicitly specify that this operation doesn't have any policy.
559
+
560
+ There are 2 more methods to check for policies separately:
561
+
562
+ * `allowed(**context)` - returns an operation result (success or failure depending on policy checks result). Useful when you need to check the exact error that happened.
563
+ * `allowed?(**context)` - the same as the previous one but returns a boolean result.
564
+
565
+ ### ActiveRecord scopes usage
566
+
567
+ There might be a temptation to use AR scopes while fetching records from the DB. For example:
568
+
569
+ ```ruby
570
+ class Post::Publish::Contract < Operations::Contract
571
+ params do
572
+ optional(:post_id).filled(:integer)
573
+ end
574
+
575
+ rule do |context:|
576
+ next key.failure(:key?) unless key?(:post_id) && context[:post]
577
+
578
+ post = context[:current_user].posts.publishable.find_by(id: values[:post_id])
579
+
580
+ if post
581
+ context[:post] = post
582
+ else
583
+ key.failure(:not_found) unless post
584
+ end
585
+ end
586
+ end
587
+ ```
588
+
589
+ Please avoid this at all costs since:
590
+
591
+ 1. It is possible to pass context variables like `post` explicitly and there is no guarantee that it will be scoped.
592
+ 2. We want to return an explicit `unauthorized/not_publishable` error to the user instead of a cryptic `not_found`.
593
+ 3. It does not fit the concept of repositories.
594
+
595
+ If we want to use security through obscurity in the controller later - we can easily turn a particular operation error into an `ActiveRecord::RecordNotFound` exception at will.
596
+
597
+ There could be non-domain but rather technical scopes like soft deletion used as an exception but this is a rare case and it should be carefully considered.
598
+
599
+ ### Generic preconditions and policies
600
+
601
+ Normally we would expect the following execution order of the operation:
602
+
603
+ 1. Set all the context
604
+ 2. Check policies
605
+ 3. Check preconditions
606
+ 4. Validate user input
607
+ 5. Call operation if everything is valid
608
+
609
+ This order is expected because it doesn't even make sense to validate user input if the state of the system or the current actor doesn't fit the requirements for the operation to be performed. This is also useful since we want to check if an operation is callable in some instances but we don't have user input at this point (e.g. on operation button rendering).
610
+
611
+ Unfortunately, to set the context, we need some of the user input (like `post_id`) to be validated. Separating context-filling params from the rest of them would cause 2 different contracts and other unpleasant or even ugly solutions. So to keep a single contract, the decision was to implement the following algorithm:
612
+
613
+ 1. Validate user input in the contract
614
+ 2. Try to set context if possible in contract rules
615
+ 3. If the contract fails, don't return failure just yet
616
+ 4. Check policies if we have all the required context set
617
+ 5. Check preconditions if we have all the required context set
618
+ 6. Return contract error if it was impossible to check preconditions/policies or they have passed
619
+ 7. Call operation if everything is valid
620
+
621
+ This way we don't have to separate user input validation but the results returned will be very close to what the first routine would produce.
622
+
623
+ Since all the context variables are passed as kwargs to policies/preconditions, it is quite simple to determine if we have all the context required to run those policies/preconditions:
624
+
625
+ ```ruby
626
+ class Comment::Update::NotSoftDeletedPrecondition
627
+ def call(comment:, **)
628
+ :soft_deleted if comment.deleted_at?
629
+ end
630
+ end
631
+ ```
632
+
633
+ Now we decided that we want this precondition to apply to all the models and be universal.
634
+
635
+ ```ruby
636
+ class Preconditions::NotSoftDeleted
637
+ extend Dry::Initializer
638
+
639
+ param :context_key, Types::Symbol
640
+
641
+ def call(**context)
642
+ :soft_deleted if context[context_key].deleted_at?
643
+ end
644
+ end
645
+
646
+ class Comment::Update
647
+ def self.default
648
+ @default ||= Operations::Command.new(
649
+ new,
650
+ contract: Contract.new,
651
+ preconditions: [Preconditions::NotSoftDeleted.new(:comment)],
652
+ policy: nil
653
+ )
654
+ end
655
+ end
656
+ ```
657
+
658
+ In this example, we pass the context key to check in the precondition initializer. And the algorithm that checks for the filled context is now unable to determine the required kwargs since there are no kwargs.
659
+
660
+ Fortunately, `context_key` or `context_keys` are magic parameter names that are also considered by this algorithm along with kwargs, so this example will do the trick and these magic variables are making generic policies/preconditions possible to define.
661
+
662
+ ```ruby
663
+ class Policies::BelongsToUser
664
+ extend Dry::Initializer
665
+
666
+ param :context_key, Types::Symbol
667
+
668
+ def call(current_user: **context)
669
+ context[context_key].user == current_user
670
+ end
671
+ end
672
+
673
+ class Comment::Update
674
+ def self.default
675
+ @default ||= Operations::Command.new(
676
+ new,
677
+ contract: Contract.new,
678
+ policy: Policies::BelongsToUser.new(:comment)
679
+ )
680
+ end
681
+ end
682
+ ```
683
+
684
+ In the examples above we safely assume that the context key is present in the case when the contract and context population is implemented the following way:
685
+
686
+ ```ruby
687
+ class Comment::Update::Contract < OperationContract
688
+ params do
689
+ optional(:comment_id).filled(:integer)
690
+ end
691
+
692
+ find :comment
693
+ end
694
+ ```
695
+
696
+ The context key is not even set if the object was not found. In this case, the context will be considered insufficient and operation policies/preconditions will not be even called accordingly to the algorithm described above.
697
+
698
+ ### Callbacks (on_success, on_failure)
699
+
700
+ Sometimes we need to run further application state modifications outside of the operation transaction. For this purpose, there are 2 separate callbacks: `on_success` and `on_failure`.
701
+
702
+ The key difference besides one running after operation success and another - after failure, is that `on_success` runs after the transaction commit. This means that if one operation calls another operation inside of it and the inner one has `on_success` callbacks defined - the callbacks are going to be executed only after the outermost transaction is committed successfully.
703
+
704
+ To achieve this, the framework utilizes the [after_commit_everywhere](https://github.com/Envek/after_commit_everywhere) gem and the behavior is configurable using `Operations::Configuration#after_commit` option.
705
+
706
+ It is a good idea to use these callbacks to schedule some jobs instead of just running inline code since if callback execution fails - the failure will be ignored and the operation is still going to be successful. Though the failure from both callbacks will be reported using `Operations::Configuration#error_reporter` and using Sentry by default.
707
+
708
+ ```ruby
709
+ class Comment::Update
710
+ def self.default
711
+ @default ||= Operations::Command.new(
712
+ ...,
713
+ on_success: [
714
+ PublishCommentUpdatedEvent.new,
715
+ NotifyBoardAdmin.new
716
+ ]
717
+ )
718
+ end
719
+ end
720
+
721
+ class PublishCommentUpdatedEvent
722
+ def call(params, comment:, **)
723
+ PublishEventJob.perform_later('comment', comment, params: params)
724
+ end
725
+ end
726
+
727
+ class NotifyBoardAdmin
728
+ def call(_, comment:, **)
729
+ AdminMailer.comment_updated(comment.id)
730
+ end
731
+ end
732
+ ```
733
+
734
+ Additionally, a callback `call` method can receive the operation result instead of params and context. This enables powerful introspection for generic callbacks.
735
+
736
+ ```ruby
737
+ class PublishCommentUpdatedEvent
738
+ def call(operation_result)
739
+ PublishEventJob.perform_later(
740
+ 'comment',
741
+ operation_result.context[:comment],
742
+ params: operation_result.params,
743
+ operation_name: operation_result.operation.operation.class.name.underscore
744
+ )
745
+ end
746
+ end
747
+ ```
748
+
749
+ ### Idempotency checks
750
+
751
+ Idempotency checks are used to skip the operation body in certain conditions. It is very similar to preconditions but if the idempotency check fails - the operation will be successful anyway. This is useful in cases when we want to ensure that operation is not going to run for the second time even if it was called, and especially for idempotent consumer pattern implementation in event-driven systems.
752
+
753
+ Normally, we advise for idempotency checks not to use the same logic which would be used for preconditions, i.e. not to use application business state checks. Instead, it is worth implementing a separate mechanism like `ProcessedEvents` DB table.
754
+
755
+ Idempotency checks are running after policy checks but before preconditions.
756
+
757
+ ```ruby
758
+ class Order::MarkAsCompleted
759
+ def self.default
760
+ @default ||= Operations::Command.new(
761
+ new,
762
+ contract: Order::MarkAsCompleted::Contract.new,
763
+ policy: nil,
764
+ idempotency: [Idempotency::ProcessedEvents.new],
765
+ preconditions: [Order::RequireStatus.new(:processing)]
766
+ )
767
+ end
768
+ end
769
+
770
+ class Order::MarkAsCompleted::Contract < OperationContract
771
+ params do
772
+ # event_id is optional and the operation can be called without it, i.e. from the console.
773
+ optional(:event_id).filled(Types::UUID)
774
+ optional(:order_id).filled(:integer)
775
+ end
776
+
777
+ find :order
778
+ end
779
+
780
+ class Order::RequireStatus
781
+ extend Dry::Initializer
782
+ include Dry::Monads[:result]
783
+
784
+ param :statuses, [Types::Symbol]
785
+
786
+ def call(order:, **)
787
+ return Failure(error: :invalid_status, tokens: { status: order.status }) unless order.status.in?(statuses)
788
+
789
+ Success()
790
+ end
791
+ end
792
+
793
+ class Idempotency::ProcessedEvents
794
+ include Dry::Monads[:result]
795
+
796
+ # Notice that, unlike preconditions, idempotency checks have params provided
797
+ def call(params, **)
798
+ return Success() unless params.key?(:event_id)
799
+ # Assume that `ProcessedEvents` has a unique index on `event_id`
800
+ ProcessedEvents.create!(event_id: params[:event_id])
801
+ Success()
802
+ rescue ActiveRecord::StatementInvalid
803
+ Failure({})
804
+ end
805
+ end
806
+ ```
807
+
808
+ **Important:** contrary to the operation, idempotency checks require to return a hash in Failure monad. This hash will be merged into the resulting context. This is necessary for operations interrupted during idempotency checks to return the same result as at the first run.
809
+
810
+ It might be also worth defining 2 different operations that will be called in different circumstances to reduce human error:
811
+
812
+ ```ruby
813
+ class Order::MarkAsCompleted
814
+ def self.system
815
+ @system ||= Operations::Command.new(
816
+ new,
817
+ contract: Order::MarkAsCompleted::SystemContract.new,
818
+ policy: nil,
819
+ preconditions: [Order::RequireStatus.new(:processing)]
820
+ )
821
+ end
822
+
823
+ # We use `merge` method here to dry the code a bit.
824
+ def self.kafka
825
+ @kafka ||= system.merge(
826
+ contract: Order::MarkAsCompleted::KafkaContract.new,
827
+ idempotency: [Idempotency::ProcessedEvents.new],
828
+ )
829
+ end
830
+ end
831
+
832
+ class Order::MarkAsCompleted::SystemContract < OperationContract
833
+ params do
834
+ optional(:order_id).filled(:integer)
835
+ end
836
+
837
+ find :order
838
+ end
839
+
840
+ # All the params and rules are inherited
841
+ class Order::MarkAsCompleted::KafkaContract < Order::MarkAsCompleted::SystemContract
842
+ params do
843
+ required(:event_id).filled(Types::UUID)
844
+ end
845
+ end
846
+ ```
847
+
848
+ In this case, `Order::MarkAsCompleted.system.call(...)` will be used in, say, console, and `Order::MarkAsCompleted.kafka.call(...)` will be used on Kafka event consumption.
849
+
850
+ ### Convenience helpers
851
+
852
+ `Operations::Convenience` is an optional module that contains helpers for simpler operation definitions. See module documentation for more details.
853
+
854
+ ### Form objects
855
+
856
+ While we normally recommend using frontend-backend separation, it is still possible to use this framework with Rails view helpers:
857
+
858
+ ```ruby
859
+ class PostsController < ApplicationController
860
+ def edit
861
+ @post_update = Post::Update.default.callable(
862
+ { post_id: params[:id] },
863
+ current_user: current_user
864
+ )
865
+
866
+ respond_with @post_update
867
+ end
868
+
869
+ def update
870
+ # With operations we don't need strong parameters as the operation contract takes care of this.
871
+ @post_update = Post::Update.default.call(
872
+ { **params[:post_update_default_form], post_id: params[:id] },
873
+ current_user: current_user
874
+ )
875
+
876
+ respond_with @post_update, location: edit_post_url(@post_update.context[:post])
877
+ end
878
+ end
879
+ ```
880
+
881
+ The key here is to use `Operations::Result#form` method for the form builder.
882
+
883
+ ```erb
884
+ # views/posts/edit.html.erb
885
+ <%= form_for @post_update.form, url: post_url(@post_update.context[:post]), method: :patch do |f| %>
886
+ <%= f.input :title %>
887
+ <%= f.text_area :body %>
888
+ <% end %>
889
+ ```
890
+
891
+ In cases when we need to populate the form data, it is possible to pass `form_hydrator:`:
892
+
893
+ ```ruby
894
+ class Post::Update
895
+ def self.default
896
+ @default ||= Operations::Command.new(
897
+ ...,
898
+ form_hydrator: Post::Update::Hydrator.new
899
+ )
900
+ end
901
+ end
902
+
903
+ class Post::Update::Hydrator
904
+ def call(form_class, params, post:, **)
905
+ value_attributes = form_class.attributes.keys - %i[post_id]
906
+ data = value_attributes.index_with { |name| post.public_send(name) }
907
+
908
+ data.merge!(params)
909
+ end
910
+ end
911
+ ```
912
+
913
+ The general idea here is to figure out attributes we have in the contract (those attributes are also defined automatically in a generated form class) and then fetch those attributes from the model and merge them with the params provided within the request.
914
+
915
+ Also, in the case of, say, [simple_form](https://github.com/heartcombo/simple_form), we need to provide additional attributes information, like data type. It is possible to do this with an optional `form_model_map:` operation option:
916
+
917
+ ```ruby
918
+ class Post::Update
919
+ def self.default
920
+ @default ||= Operations::Command.new(
921
+ ...,
922
+ form_hydrator: Post::Update::Hydrator.new,
923
+ form_model_map: {
924
+ [%r{.+}] => "Post"
925
+ }
926
+ )
927
+ end
928
+ end
929
+ ```
930
+
931
+ Here we define all the fields mapping to a model class with a regexp or just string values.
26
932
 
27
933
  ## Development
28
934
 
@@ -32,8 +938,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
32
938
 
33
939
  ## Contributing
34
940
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/operations.
36
-
941
+ Bug reports and pull requests are welcome on GitHub at https://github.com/BookingSync/operations.
37
942
 
38
943
  ## License
39
944