Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic DSL to define operations that can fail #6

Merged
merged 3 commits into from Oct 4, 2023

Conversation

waiting-for-dev
Copy link
Member

@waiting-for-dev waiting-for-dev commented Oct 2, 2023

Overview

We introduce a thin DSL on top of dry-monads' result type to define operations that can fail.

Dry::Operation#steps accepts a block where individual operations can be called with #step. When they return a Success, the inner value is automatically unwrapped, ready to be consumed by subsequen steps. When a Failure is returned along the way, the remaining steps are skipped and the failure is returned.

Example:

require "dry/operation"

class MyOperation < Dry::Operation
  def call(input)
    steps do
      attrs = step validate(input)
      user = step persist(attrs)
      step notify(user)
      user
    end
  end

  def validate(input)
   # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
  end

  def persist(attrs)
   # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
  end

  def notify(user)
   # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
  end
end

include Dry::Monads[:result]

case MyOperation.new.call(input)
in Success(user)
  puts "User #{user.name} created"
in Failure[:invalid_input, validation_errors]
  puts "Invalid input: #{validation_errors}"
in Failure(:database_error)
  puts "Database error"
in Failure(:email_error)
  puts "Email error"
end

The approach is similar to the so-called "do notation" in Haskell, but done in an idiomatic Ruby way. There's no magic happening between every line within the block (i.e., "programmable semicolons"). Besides not being something possible in Ruby, it'd be very confusing for people to require all the lines to return a Result type (e.g., we want to allow debugging). Instead, it's required to unwrap intermediate results through the step method. Notice that not having logic to magically unwrap results is also intentional to allow flexibility to transform results in between steps (e.g., validate(input).value_or({}))

Please, check also the first two commits that perform some extra setup for development.

@waiting-for-dev

This comment was marked as outdated.

@bkuhlmann

This comment was marked as outdated.

Copy link
Member

@timriley timriley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking great, thanks @waiting-for-dev! I've one little request about flatting the file structure for our unit specs, but otherwise, let's get this in :)

spec/unit/dry/operation_spec.rb Outdated Show resolved Hide resolved
spec/unit/dry/operation_spec.rb Outdated Show resolved Hide resolved
We introduce a thin DSL on top of dry-monads' result type [1] to define
operations that can fail.

`Dry::Operation#steps` accepts a block where individual operations can
be called with `#step`. When they return a `Success`, the inner value
is automatically unwrapped, ready to be consumed by subsequen steps.
When a `Failure` is returned along the way, the remaining steps are
skipped and the failure is returned.

Example:

```ruby
require "dry/operation"

class MyOperation < Dry::Operation
  def call(input)
    steps do
      attrs = step validate(input)
      user = step persist(attrs)
      step notify(user)
      user
    end
  end

  def validate(input)
   # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
  end

  def persist(attrs)
   # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
  end

  def notify(user)
   # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
  end
end

include Dry::Monads[:result]

case MyOperation.new.call(input)
in Success(user)
  puts "User #{user.name} created"
in Failure[:invalid_input, validation_errors]
  puts "Invalid input: #{validation_errors}"
in Failure(:database_error)
  puts "Database error"
in Failure(:email_error)
  puts "Email error"
end
```

The approach is similar to the so-called "do notation" in Haskell [1],
but done in an idiomatic Ruby way. There's no magic happening between
every line within the block (i.e., "programmable semicolons"). Besides
not being something possible in Ruby, it'd be very confusing for people
to require all the lines to return a `Result` type (e.g., we want to
allow debugging). Instead, it's required to unwrap intermediate results
through the `step` method. Notice that not having logic to magically
unwrap results is also intentional to allow flexibility to transform
results in between steps (e.g., `validate(input).value_or({})`)

[1] https://dry-rb.org/gems/dry-monads/1.6/result/
[2] https://en.wikibooks.org/wiki/Haskell/do_notation
@waiting-for-dev waiting-for-dev merged commit 48eebad into main Oct 4, 2023
2 checks passed
@waiting-for-dev waiting-for-dev deleted the waiting-for-dev/operations branch October 4, 2023 12:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants