From 62c1b1bef34ea2fe1c2f8cf6372f24ba51f995bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Busqu=C3=A9?= Date: Fri, 1 Nov 2024 08:22:57 +0100 Subject: [PATCH] Add documentation for the dry-rb.org website (#26) --------- Co-authored-by: Tim Riley --- docsite/source/configuration.html.md | 64 ++++++++++ docsite/source/design-pattern.html.md | 36 ++++++ docsite/source/error-handling.html.md | 65 ++++++++++ docsite/source/extensions.html.md | 171 ++++++++++++++++++++++++++ docsite/source/index.html.md | 115 +++++++++++++++++ 5 files changed, 451 insertions(+) create mode 100644 docsite/source/configuration.html.md create mode 100644 docsite/source/design-pattern.html.md create mode 100644 docsite/source/error-handling.html.md create mode 100644 docsite/source/extensions.html.md create mode 100644 docsite/source/index.html.md diff --git a/docsite/source/configuration.html.md b/docsite/source/configuration.html.md new file mode 100644 index 0000000..d521c92 --- /dev/null +++ b/docsite/source/configuration.html.md @@ -0,0 +1,64 @@ +--- +title: Configuration +layout: gem-single +name: dry-operation +--- + +By default, dry-operation automatically wraps the `#call` method of your operations with failure tracking and [error handling](docs::error-handling). This is what allows you to use `#step` directly in your `#call` method. + +```ruby +class CreateUser < Dry::Operation + def call(input) + # Step handling works in #call by default + user = step create_user(input) + step notify(user) + user + end +end +``` + +### Customizing wrapped methods + +You can customize which methods can handled steps using the `.operate_on` class method: + +```ruby +class MyOperation < Dry::Operation + # Handle steps in both #call and #process methods + operate_on :call, :process + + def call(input) + step validate(input) + end + + def process(input) + step transform(input) + end +end +``` + +### Disabling automatic wrapping + +If you want complete control over method wrapping, you can disable the automatic wrapping entirely using `.skip_prepending`. In that case, you'll need to wrap your methods manually with `steps do ... end` and manage error handling yourself. + +```ruby +class CreateUser < Dry::Operation + skip_prepending + + def call(input) + # Now you must explicitly wrap steps + steps do + user = step create_user(input) + step notify(user) + user + end + end +end +``` + +### Inheritance behaviour + +Both `.operate_on` and `.skip_prepending` configurations are inherited by subclasses. This means: + +- If a parent class configures certain methods to be wrapped, subclasses will inherit that configuration +- If a parent class skips prepending, subclasses will also skip prepending +- Subclasses can override their parent's configuration by calling `.operate_on` or `.skip_prepending` again diff --git a/docsite/source/design-pattern.html.md b/docsite/source/design-pattern.html.md new file mode 100644 index 0000000..80f3377 --- /dev/null +++ b/docsite/source/design-pattern.html.md @@ -0,0 +1,36 @@ +--- +title: Design Pattern +layout: gem-single +name: dry-operation +--- + +dry-operation implements a pattern that closely resembles monadic composition, particularly the `Result` monad, and the Railway Oriented Programming pattern. Understanding these monadic concepts can provide deeper insight into how dry-operation works and why it's designed this way. + +### Monadic composition + +In functional programming, a monad is a structure that represents computations defined as sequences of steps. A key feature of monads is their ability to chain operations, with each operation depending on the result of the previous one. + +dry-operation emulates this monadic behavior through its `#step` method and the overall structure of operations. + +In monadic terms, the `#step` method in `Dry::Operation` acts similarly to the `bind` operation: + +1. It takes a computation that may succeed or fail (returning `Success` or `Failure`). +1. If the computation succeeds, it extracts the value and passes it to the next step. +1. If the computation fails, it short-circuits the entire operation, skipping subsequent steps. + +This behavior allows for clean composition of operations while handling potential failures at each step. + +By expressing this behaviour via `#step`, dry-operation lets you intermingle ordinary Ruby code in between steps as required. + +### Railway Oriented Programming + +The design of dry-operation closely follows the concept of [Railway Oriented Programming](https://fsharpforfunandprofit.com/rop/), a way of structuring code that's especially useful for dealing with a series of operations that may fail. + +In this model: + +- The "happy path" (all operations succeed) is one track of the railway. +- The "failure path" (any operation fails) is another track. + +Each step is like a switch on the railway, potentially diverting from the success track to the failure track. + +dry-operation implements this pattern by allowing the success case to continue down the method, while immediately returning any failure, effectively "switching tracks". diff --git a/docsite/source/error-handling.html.md b/docsite/source/error-handling.html.md new file mode 100644 index 0000000..751e42d --- /dev/null +++ b/docsite/source/error-handling.html.md @@ -0,0 +1,65 @@ +--- +title: Error Handling +layout: gem-single +name: dry-operation +--- + +When using dry-operation, errors are handled through the `Failure` type from [dry-monads](/gems/dry-monads/). Each step in your operation should return either a `Success` or `Failure` result. When a step returns a `Failure`, the operation short-circuits, skipping the remaining steps and returning the failure immediately. + +You'll usually handle the failure from the call site, where you can pattern match on the result to handle success and failure cases. However, sometimes it's useful to encapsulate some error handling logic within the operation itself. + +### Global error handling + +You can define a global failure handler by implementing an `#on_failure` method in your operation class. This method is only called to perform desired side effects and it won't affect the operation's return value. + +```ruby +class CreateUser < Dry::Operation + def initialize(logger:) + @logger = logger + end + + def call(input) + attrs = step validate(input) + user = step persist(attrs) + step notify(user) + user + end + + private + + def on_failure(failure) + # Log or handle the failure globally + logger.error("Operation failed: #{failure}") + end +end +``` + +The `#on_failure` method can optionally accept a second argument that indicates which method encountered the failure, allowing you more granular control over error handling: + +```ruby +class CreateUser < Dry::Operation + def initialize(logger:) + @logger = logger + end + + def call(input) + attrs = step validate(input) + user = step persist(attrs) + step notify(user) + user + end + + private + + def on_failure(failure, step_name) + case step_name + when :validate + logger.error("Validation failed: #{failure}") + when :persist + logger.error("Persistence failed: #{failure}") + when :notify + logger.error("Notification failed: #{failure}") + end + end +end +``` diff --git a/docsite/source/extensions.html.md b/docsite/source/extensions.html.md new file mode 100644 index 0000000..f7602da --- /dev/null +++ b/docsite/source/extensions.html.md @@ -0,0 +1,171 @@ +--- +title: Extensions +layout: gem-single +name: dry-operation +--- + +### ROM + +The `ROM` extension adds transaction support to your operations when working with the [ROM](https://rom-rb.org) database persistence toolkit. When a step returns a `Failure`, the transaction will automatically roll back, ensuring data consistency. + +First, make sure you have rom-sql installed: + +```ruby +gem "rom-sql" +``` + +Require and include the extension in your operation class and provide access to the ROM container through a `#rom` method: + +```ruby +require "dry/operation/extensions/rom" + +class CreateUser < Dry::Operation + include Dry::Operation::Extensions::ROM + + attr_reader :rom + + def initialize(rom:) + @rom = rom + super() + end + + def call(input) + transaction do + user = step create_user(input) + step assign_role(user) + user + end + end + + # ... +end +``` + +By default, the `:default` gateway will be used. You can specify a different gateway either when including the extension: + +```ruby +include Dry::Operation::Extensions::ROM[gateway: :my_gateway] +``` + +Or at runtime: + +```ruby +transaction(gateway: :my_gateway) do + # ... +end +``` + +### Sequel + +The `Sequel` extension provides transaction support for operations when using the [Sequel](http://sequel.jeremyevans.net) database toolkit. It will automatically roll back the transaction if any step returns a `Failure`. + +Make sure you have sequel installed: + +```ruby +gem "sequel" +``` + +Require and include the extension in your operation class and provide access to the Sequel database object through a `#db` method: + +```ruby +require "dry/operation/extensions/sequel" + +class CreateUser < Dry::Operation + include Dry::Operation::Extensions::Sequel + + attr_reader :db + + def initialize(db:) + @db = db + super() + end + + def call(input) + transaction do + user_id = step create_user(input) + step create_profile(user_id) + user_id + end + end + + # ... +end +``` + +You can pass options to the transaction either when including the extension: + +```ruby +include Dry::Operation::Extensions::Sequel[isolation: :serializable] +``` + +Or at runtime: + +```ruby +transaction(isolation: :serializable) do + # ... +end +``` + +⚠️ Warning: The `:savepoint` option for nested transactions is not yet supported. + +### ActiveRecord + +The `ActiveRecord` extension adds transaction support for operations using the [ActiveRecord](https://api.rubyonrails.org/classes/ActiveRecord) ORM. Like the other database extensions, it will roll back the transaction if any step returns a `Failure`. + +Make sure you have activerecord installed: + +```ruby +gem "activerecord" +``` + +Require and include the extension in your operation class: + +```ruby +require "dry/operation/extensions/active_record" + +class CreateUser < Dry::Operation + include Dry::Operation::Extensions::ActiveRecord + + def call(input) + transaction do + user = step create_user(input) + step create_profile(user) + user + end + end + + # ... +end +``` + +By default, `ActiveRecord::Base` is used to initiate transactions. You can specify a different class either when including the extension: + +```ruby +include Dry::Operation::Extensions::ActiveRecord[User] +``` + +Or at runtime: + +```ruby +transaction(User) do + # ... +end +``` + +This is particularly useful when working with multiple databases in ActiveRecord. + +You can also provide default transaction options when including the extension: + +```ruby +include Dry::Operation::Extensions::ActiveRecord[isolation: :serializable] +``` + +You can override these options at runtime: + +```ruby +transaction(isolation: :serializable) do + # ... +end +``` + +⚠️ Warning: The `:requires_new` option for nested transactions is not yet fully supported. diff --git a/docsite/source/index.html.md b/docsite/source/index.html.md new file mode 100644 index 0000000..acb3ff6 --- /dev/null +++ b/docsite/source/index.html.md @@ -0,0 +1,115 @@ +--- +title: Introduction +layout: gem-single +type: gem +name: dry-operation +sections: + - error-handling + - configuration + - extensions + - design-pattern +--- + +dry-operation provides an expressive and flexible way for you to model your app's business operations. It provides a lightweight DSL around [dry-monads](/gems/dry-monads/), which allows you to chain together steps and operations with a focus on the happy path, while elegantly handling failures. + +### Introduction + +In complex business logic, it's common to have a series of operations that depend on each other. Traditionally, this leads to deeply nested conditional statements or a series of guard clauses. dry-operation provides a more elegant solution by allowing you to define a linear flow of operations, automatically short-circuiting on failure. + +### Basic Usage + +To use dry-operation, create a class that inherits from `Dry::Operation` and define your flow in the `#call` method: + +```ruby +class CreateUser < Dry::Operation + def call(input) + attrs = step validate(input) + user = step persist(attrs) + step notify(user) + user + end + + private + + def validate(input) + # Return Success(attrs) or Failure(error) + end + + def persist(attrs) + # Return Success(user) or Failure(error) + end + + def notify(user) + # Return Success(true) or Failure(error) + end +end +``` + +Each step (`validate`, `persist`, `notify`) is expected to return either a `Success` or `Failure` from [dry-monads](/gems/dry-monads/). + +### The step method + +The `#step` method is the core of `Dry::Operation`. It does two main things: + +- If the result is a `Success`, it unwraps the value and returns it. +- If the result is a `Failure`, it short-circuits the operation and returns the failure. + +This behavior allows you to write your happy path in a linear fashion, without worrying about handling failures at each step. + +### The call method + +The `#call` method will catch any potential failure from the steps and return it. If it completes without encountering any failure, its return value is automatically wrapped in a `Success`. This means you don't need to explicitly return a `Success` at the end of your `#call` method. + +For example, given this operation: + +```ruby +class CreateUser < Dry::Operation + def call(input) + attrs = step validate(input) + user = step persist(attrs) + step notify(user) + user # This is automatically wrapped in Success + end + + # ... other methods ... +end +``` + +When all steps succeed, calling this operation will return `Success(user)`, not just `user`. + +It's important to notice that steps don't need to immediately follow each other. You can add your own regular Ruby code between the steps to adjust values as required. For instance, the following works just fine: + +```ruby +class CreateUser < Dry::Operation + def call(input) + attrs = step validate(input) + attrs[:name] = attrs[:name].capitalize + user = step persist(attrs) + step notify(user) + user # This is automatically wrapped in Success + end + + # ... other methods ... +end +``` + +### Handling Results + +After calling an operation, you will receive either a `Success` or a `Failure`. You can pattern match on this result to handle each situation: + +```ruby +case CreateUser.new.(input) +in Success[user] + puts "User #{user.name} created successfully" +in Failure[:invalid_input, errors] + puts "Invalid input: #{errors}" +in Failure[:database_error] + puts "Database error occurred" +in Failure[:notification_error] + puts "User created but notification failed" +end +``` + +This pattern matching allows you to handle different types of failures in a clear and explicit manner. + +You can read more about dry-monads' `Result` usage in the [dry-monads documentation](/gems/dry-monads/1.6/result/).