Creating a dry-operation
Encapsulate a business workflow in an operation. Each step is explicit — success flows forward, failure short-circuits.
Quick Reference
- Framework:
dry-operationfor sequential steps,dry-transactionfor composed operations with rollback (each step is registered and can be overridden; failed steps trigger rollback of completed steps). - Return: Always
Success(value)orFailure(error). - DI: Use
include Deps[...]for dependencies. - Test: Pass test doubles through the constructor. Verify step ordering.
HARD-GATE
Write test → Run test → Verify it FAILS → Implement → Verify it PASSES
DO NOT put business logic in actions. Actions delegate to operations.
DO NOT return raw values — always wrap in Success or Failure.
Core Process
- Plan the workflow — list every step in order. Each step is a method or a callable.
- Create the operation class in
slices/<slice>/operations/<namespace>/:module Users class CreateUser < Dry::Operation include Deps["repositories.user_repo"] def call(input) attrs = step validate(input) user = step persist(attrs) step notify(user) user end private def validate(input) = # returns Success(attrs) or Failure(errors) def persist(attrs) = # returns Success(user) or Failure(error) def notify(user) = # returns Success(true) or Failure(error) end end - Each step — if it returns
Failure, the operation stops and returns that failure. - Compose operations by injecting them as dependencies. Never call one operation from inside another's private methods.
- Use operations from actions — the action calls the operation and maps the result to an HTTP response.
Spec Example
RSpec.describe Users::CreateUser do
subject(:operation) { described_class.new(user_repo: user_repo) }
let(:user_repo) { instance_double("UserRepo", create: Success(user)) }
let(:user) { double("User", id: 1, email: "test@example.com") }
describe "#call" do
context "when input is valid" do
it "returns Success with the created user" do
result = operation.call(email: "test@example.com", password: "secret")
expect(result).to be_success
expect(result.value!).to eq(user)
end
end
context "when validation fails" do
it "returns Failure with validation errors" do
result = operation.call(email: "", password: "")
expect(result).to be_failure
expect(result.failure).to include(:email)
end
end
context "when persistence fails" do
before { allow(user_repo).to receive(:create).and_return(Failure(:db_error)) }
it "returns Failure and does not send a notification" do
result = operation.call(email: "test@example.com", password: "secret")
expect(result).to be_failure
expect(result.failure).to eq(:db_error)
end
end
end
end
Output Style
- Operation class — complete implementation with
include Deps[...]. - Step methods — each private method with explicit return types.
- Spec — RSpec with test doubles, verifying Success and Failure paths (see Spec Example above).
- Action integration — how the action calls this operation.
- English only unless user requests otherwise.
For extended operation patterns, see OPERATION_PATTERNS.md.
Integration
| Skill | When to chain |
|---|---|
| create-validation-contract | Before the operation, to define validation rules |
| create-action | After the operation, to wire it to an HTTP endpoint |
| implement-di | To inject repositories and services into the operation |
| tdd-loop agent | Full TDD cycle including operation testing |