The Result object this app already has

Free preview 5 min to read

In September 2026, app/services in this application held 65 Ruby files. Forty-nine of them declare a result type with a line shaped like this one, copied from app/services/auth/authenticate_user_service.rb:

Result = Data.define(:success, :user, :error)

Forty-four of those forty-nine put :success first. Nobody wrote a framework to make that happen. Every service faced the same problem, because a caller needs to know whether the work happened. The same answer kept looking reasonable, so the shape spread.

The pattern, in full, from real code

Here is the whole authentication service, as it stands in the repository:

module Auth
  class AuthenticateUserService
    Result = Data.define(:success, :user, :error)

    # Burned once at boot so unknown emails cost the same BCrypt work as known ones
    # (prevents timing-based user enumeration).
    DUMMY_PASSWORD_DIGEST = BCrypt::Password.create("timing-equalizer", cost: BCrypt::Engine.cost).to_s.freeze

    def self.call(email:, password:)
      user = ::Identity::User.find_by(email: email.to_s.strip.downcase)

      return Result.new(success: false, user: nil, error: I18n.t("auth.errors.account_locked")) if user&.locked?

      if user
        authenticated = user.authenticate(password)
      else
        BCrypt::Password.new(DUMMY_PASSWORD_DIGEST) == password.to_s
        authenticated = false
      end

      if authenticated
        user.reset_login_attempts!
        Result.new(success: true, user: user, error: nil)
      else
        user&.record_failed_login!
        Result.new(success: false, user: nil, error: I18n.t("auth.errors.invalid_credentials"))
      end
    end
  end
end

The controller that consumes it, app/controllers/auth/sessions_controller.rb, branches on the result. This excerpt shows the branch and leaves out what a successful sign-in does next:

# app/controllers/auth/sessions_controller.rb (excerpt)
result = Auth::AuthenticateUserService.call(
  email: params[:email],
  password: params[:password]
)

if result.success
  # ... start the session and redirect ...
else
  @error = result.error
  flash.now[:alert] = @error
  render :new, status: :unprocessable_content
end

That's a good service and a good controller. The boundary is explicit, the failure is a value and not an exception, and the branch reads cleanly. Three fields, one if. Nothing here needs rescuing.

Where the shape starts to strain

Now the same pattern at a harder job, Enrollment::CompleteLessonService:

Result = Data.define(:success, :lesson_completion, :xp_awarded, :next_lesson,
                     :course_completed, :level_reached, :awarded_badges, :errors)

Eight fields. A failure has to supply all eight, so the file carries a private helper whose only job is inventing empty values for the seven that no longer mean anything:

def self.failure(next_lesson, errors)
  Result.new(success: false, lesson_completion: nil, xp_awarded: 0, next_lesson: next_lesson,
             course_completed: false, level_reached: nil, awarded_badges: [], errors: errors)
end

xp_awarded: 0 isn't a fact about a failed lesson completion. It's padding, and it's required because success and failure share one shape.

There is a second cost in that file, quieter than the padding. Inside its transaction, the service calls four collaborators:

lesson_xp = ::Enrollment::AwardXpService.call(
  user: user, source: "lesson_completion", course: lesson.course, idempotency_key: "lesson_completion:#{lesson.id}"
)
::Enrollment::UpdateStreakService.call(user: user)
course_result = ::Enrollment::CompleteCourseService.call(user: user, course: lesson.course)
xp_awarded = lesson_xp.amount + course_result.xp_awarded

unlocks = if xp_awarded.positive?
  ::Enrollment::EvaluateUnlocksService.call(user: user, xp_before: xp_before)
else
  ::Enrollment::EvaluateUnlocksService::NOTHING
end

Two of them return a Result with a success field, and this code never reads it. That looks like an ignored failure path, so check before believing it. AwardXpService and UpdateStreakService never return success: false. Every Result.new in both files passes success: true. They report trouble by raising: update! in the streak service, an ArgumentError for a missing idempotency key in the XP service. The whole block runs inside ActiveRecord::Base.transaction, so a raise rolls everything back and leaves the method.

So nothing is swallowed. The success field carries no information in those two services. Reading it would be pointless, and the convention there is decoration.

The other two collaborators have different shapes again. CompleteCourseService declares Result = Data.define(:completed, :xp_awarded), with no success field. Its completed: false is a legitimate "this course isn't finished yet" answer, not a failure. EvaluateUnlocksService returns level_reached and awarded_badges, and it has a ready-made empty result, NOTHING, for completions that paid no XP.

That's the state of the pattern in this application. A Result object with a boolean, applied by habit, sometimes to operations that can't fail, and sometimes left without the boolean. Lesson 7 asks whether that's worth replacing.

What this course evaluates

Dry-rb is a family of Ruby libraries, and two of them address the strain above. dry-monads makes success and failure two different values, where the app has one value with a boolean. dry-validation moves input checking out of models and into standalone contracts.

Know this before you look for documentation: dry-rb.org now answers with a 301 redirect to hanakai.org/dry. The libraries moved under the Hanakai umbrella alongside Hanami and ROM, and the site calls the family "Dry". The gem names didn't change, so you still install dry-monads and dry-validation. Older blog posts and answers link to a domain that no longer serves the docs directly.

Neither gem is installed in this application. A grep for dry across the Gemfile and Gemfile.lock returns nothing. Every dry-rb example in this course was run against dry-monads 1.11.0 and dry-validation 1.11.1 in a scratch environment, and the output shown is what printed. None of it runs in this app today. The Rails code quoted alongside it is real and current.

That gap is the point. A pattern is worth adopting when it removes a cost you can already name in code you already have. This course names the costs first, then measures the libraries against them.

What the seven lessons cover

Lesson 2 replaces the success boolean with two distinct values and shows what changes for the caller. Lesson 3 covers fmap and bind, the two operations that let steps compose without an if between each pair. Lesson 4 reaches Do notation, which removes the need for CompleteLessonService's eight-field failure helper. Lesson 5 turns to dry-validation contracts, schemas and rules, including a short-circuit that surprises most people the first time. Lesson 6 wires a contract into a service through one extension line. It's easy to omit, and the error you get without it is confusing. Lesson 7 asks the question this course exists for, with real numbers: should this application adopt any of it?

The answer differs from one service to the next, and lesson 7 says where it's no.

Lesson 1 of 7 · free

Keep going with lesson 2

Success and Failure as values

Replaces the success boolean with Success and Failure classes, and shows how value! fails loudly on a Failure.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...