O objeto Result que esta aplicação já tem
Em setembro de 2026, app/services nesta aplicação tinha 65 arquivos Ruby. Quarenta e nove deles declaram um tipo de resultado com uma linha no formato desta, copiada de app/services/auth/authenticate_user_service.rb:
Result = Data.define(:success, :user, :error)
Quarenta e quatro desses quarenta e nove põem :success em primeiro. Ninguém escreveu um framework para isso acontecer. Todo service enfrentou o mesmo problema, porque quem chama precisa saber se o trabalho aconteceu. A mesma resposta continuou parecendo razoável, e a forma se espalhou.
O padrão, inteiro, em código real
Este é o service de autenticação completo, como está no repositório:
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
O controller que o consome, app/controllers/auth/sessions_controller.rb, decide pelo resultado. Este trecho mostra a decisão e omite o que um login bem-sucedido faz em seguida:
# app/controllers/auth/sessions_controller.rb (trecho)
result = Auth::AuthenticateUserService.call(
email: params[:email],
password: params[:password]
)
if result.success
# ... inicia a sessão e redireciona ...
else
@error = result.error
flash.now[:alert] = @error
render :new, status: :unprocessable_content
end
Esse é um bom service e um bom controller. A fronteira é explícita, a falha é um valor e não uma exceção, e o desvio se lê bem. Três campos, um if. Nada aqui precisa de conserto.
Onde a forma começa a pesar
Agora o mesmo padrão num trabalho mais difícil, Enrollment::CompleteLessonService:
Result = Data.define(:success, :lesson_completion, :xp_awarded, :next_lesson,
:course_completed, :level_reached, :awarded_badges, :errors)
Oito campos. Uma falha precisa fornecer os oito, então o arquivo carrega um helper privado cuja única função é inventar valores vazios para os sete que já não significam nada:
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 não é um fato sobre uma conclusão de aula que falhou. É enchimento, exigido porque sucesso e falha dividem a mesma forma.
Há um segundo custo nesse arquivo, mais discreto que o enchimento. Dentro da transação, o service chama quatro colaboradores:
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
Dois deles devolvem um Result com um campo success, e este código nunca o lê. Parece um caminho de falha ignorado, então confira antes de acreditar. AwardXpService e UpdateStreakService nunca devolvem success: false. Todo Result.new nos dois arquivos passa success: true. Eles avisam de problema levantando erro: update! no service de sequência, um ArgumentError para chave de idempotência ausente no service de XP. O bloco inteiro roda dentro de ActiveRecord::Base.transaction, então um erro levantado desfaz tudo e sai do método.
Ou seja, nada é engolido. O campo success não carrega informação nesses dois services. Lê-lo seria inútil, e ali a convenção é decoração.
Os outros dois colaboradores têm formas diferentes ainda. O CompleteCourseService declara Result = Data.define(:completed, :xp_awarded), sem campo success. Seu completed: false é uma resposta legítima de "este curso ainda não terminou", não uma falha. O EvaluateUnlocksService devolve level_reached e awarded_badges, e tem um resultado vazio pronto, NOTHING, para conclusões que não pagaram XP.
Esse é o estado do padrão nesta aplicação. Um objeto Result com booleano, aplicado por hábito, às vezes a operações que não podem falhar e às vezes sem o booleano. A aula 7 pergunta se vale substituí-lo.
O que este curso avalia
Dry-rb é uma família de bibliotecas Ruby, e duas delas tratam do problema acima. dry-monads faz de sucesso e falha dois valores diferentes, onde a aplicação tem um valor com um booleano. dry-validation tira a checagem de entrada dos models e a coloca em contratos independentes.
Saiba disto antes de procurar documentação: dry-rb.org hoje responde com um redirect 301 para hanakai.org/dry. As bibliotecas passaram para o guarda-chuva da Hanakai, ao lado de Hanami e ROM, e o site chama a família de "Dry". Os nomes das gems não mudaram, então você ainda instala dry-monads e dry-validation. Posts de blog e respostas mais antigas apontam para um domínio que já não serve a documentação diretamente.
Nenhuma das duas gems está instalada nesta aplicação. Um grep por dry no Gemfile e no Gemfile.lock não devolve nada. Todo exemplo de dry-rb deste curso rodou com dry-monads 1.11.0 e dry-validation 1.11.1 num ambiente descartável, e a saída mostrada é a que apareceu. Nada disso roda nesta aplicação hoje. O código Rails citado ao lado é real e atual.
Essa distância é o ponto. Um padrão vale a adoção quando remove um custo que você já consegue apontar em código que você já tem. Este curso nomeia os custos primeiro e depois mede as bibliotecas contra eles.
O que as sete aulas cobrem
A aula 2 troca o booleano success por dois valores distintos e mostra o que muda para quem chama. A aula 3 cobre fmap e bind, as duas operações que deixam passos se comporem sem um if entre cada par. A aula 4 chega à notação Do, que dispensa o helper de falha de oito campos do CompleteLessonService. A aula 5 passa aos contratos do dry-validation, schemas e rules, incluindo um curto-circuito que surpreende quase todo mundo na primeira vez. A aula 6 conecta um contrato a um service por uma linha de extensão. É fácil esquecê-la, e o erro que aparece sem ela é confuso. A aula 7 faz a pergunta que motiva o curso, com números reais: esta aplicação deveria adotar algo disso?
A resposta muda de um service para outro, e a aula 7 diz onde ela é não.
Continue com a aula 2
Success e Failure como valores
Troca o booleano success pelas classes Success e Failure e mostra como value! falha com barulho num Failure.
Com uma conta grátis você também tem os quizzes, seu progresso e suas anotações, e a discussão.