Real-World CMDx: Building a User Onboarding Pipeline¶
Part 1 of the Real-World CMDx series
Built on CMDx 2.0 — see the v2 release post for the runtime changes this post depends on.
User onboarding is one of those features that sounds simple until you actually build it. "Just create a user and send them an email." Sure—until you add email verification, trial activation, referral tracking, welcome sequences, analytics events, and a dozen conditional paths based on plan type, invite status, and geographic regulations.
I've built this feature in Ruby at least six times across different projects, and it always follows the same trajectory: starts as a single service object, grows tentacles, and eventually becomes the thing nobody wants to touch. This time, I'm building it with CMDx from the start—decomposed into focused tasks, orchestrated as a workflow, with full observability baked in.
The Requirements¶
Here's what our onboarding pipeline needs to do:
- Register the user (validate inputs, create the record)
- Send verification email (with a signed token)
- Activate trial (if the plan includes one)
- Apply referral bonus (if a referral code was provided)
- Send welcome email (different content for trial vs paid users)
- Track analytics (registration event with attribution data)
Some of these steps are conditional. Some can fail without killing the whole pipeline. Let's build it piece by piece.
Starting with the Tasks¶
Every good workflow starts with small, focused tasks. Each one does exactly one thing.
Register the User¶
class Users::Register < CMDx::Task
required :email, format: { with: URI::MailTo::EMAIL_REGEXP }
required :password, length: { min: 8 }
required :plan, inclusion: { in: %w[free trial professional enterprise] }
optional :referral_code
optional :invite_token
output :user
def work
fail!("Email already taken", code: :duplicate) if User.exists?(email: email)
context.user = User.create!(
email: email,
password: password,
plan: plan,
status: :pending_verification
)
end
end
Three layers of defense here: input validations catch malformed inputs, the fail! catches business rule violations, and output :user guarantees downstream tasks always have the user available — every declared output is implicitly required, so Runtime verifies it's present after work returns and fails the task otherwise.
Send Verification Email¶
class Users::SendVerification < CMDx::Task
required :user
output :verification_token
def work
context.verification_token = user.generate_verification_token!
UserMailer.verification(
user: user,
token: context.verification_token
).deliver_later
end
end
Activate Trial¶
This task only runs for trial plans. That conditional logic lives in the workflow—the task itself doesn't know or care:
class Users::ActivateTrial < CMDx::Task
required :user
output :trial_ends_at
def work
trial_duration = case user.plan
when "trial" then 14.days
when "professional" then 30.days
else 0.days
end
skip!("Plan has no trial period") if trial_duration.zero?
user.update!(
trial_started_at: Time.current,
trial_ends_at: Time.current + trial_duration
)
context.trial_ends_at = user.trial_ends_at
end
end
Notice the skip! — if somehow the task gets called for a plan without a trial, it skips gracefully rather than creating a zero-length trial. Belt and suspenders.
Apply Referral Bonus¶
class Users::ApplyReferralBonus < CMDx::Task
required :user
required :referral_code, presence: true
def work
referrer = User.find_by(referral_code: referral_code)
if referrer.nil?
fail!("Invalid referral code", code: :invalid_referral)
end
Referral.create!(
referrer: referrer,
referred: user,
bonus_type: :signup,
status: :pending
)
referrer.increment!(:referral_count)
logger.info "Referral bonus applied: #{referrer.email} → #{user.email}"
end
end
Send Welcome Email¶
Different content based on plan type:
class Users::SendWelcome < CMDx::Task
required :user
def work
template = case user.plan
when "trial", "professional" then :welcome_trial
when "enterprise" then :welcome_enterprise
else :welcome_free
end
UserMailer.welcome(user: user, template: template).deliver_later
end
end
Track Analytics¶
class Users::TrackRegistration < CMDx::Task
required :user
optional :referral_code
settings(tags: ["analytics", "onboarding"])
def work
Analytics.track("user_registered",
user_id: user.id,
plan: user.plan,
referral: referral_code.present?,
source: context.utm_source,
timestamp: Time.current
)
end
end
The Workflow¶
Now we compose these tasks into a pipeline:
class Users::Onboard < CMDx::Task
include CMDx::Workflow
settings(tags: ["onboarding"])
task Users::Register
task Users::SendVerification
task Users::ActivateTrial, if: :trial_plan?
task Users::ApplyReferralBonus, if: :has_referral?
task Users::SendWelcome
task Users::TrackRegistration
private
def trial_plan?
%w[trial professional].include?(context.plan)
end
def has_referral?
context.referral_code.present?
end
end
Read that top-to-bottom. Even someone unfamiliar with the codebase can see the onboarding flow at a glance. That's the storytelling pattern—the workflow declaration tells the story of what happens when a user signs up.
Adding Infrastructure¶
The tasks above handle business logic. Now let's wrap them with infrastructure concerns.
Base Task with Transaction¶
class ApplicationTask < CMDx::Task
register :middleware, DatabaseTransaction
register :middleware, ErrorTracking
end
All our tasks inherit from this (update the classes above accordingly), so every mutation is transactional and every exception is reported.
The Controller¶
class RegistrationsController < ApplicationController
def create
result = Users::Onboard.execute(
email: params[:email],
password: params[:password],
plan: params[:plan],
referral_code: params[:referral_code],
utm_source: params[:utm_source]
)
case result
in { status: "success" }
sign_in(result.context.user)
redirect_to dashboard_path, notice: "Welcome! Check your email to verify your account."
in { status: "failed", metadata: { code: :duplicate } }
redirect_to login_path, alert: "An account with that email already exists."
in { status: "failed" } if result.errors.any?
@errors = result.errors.to_h
render :new, status: :unprocessable_entity
in { status: "failed" }
redirect_to new_registration_path, alert: result.reason
end
end
end
Pattern matching makes the controller clean. Each failure type gets a specific response — metadata[:code] for tagged business failures, result.errors for input/output validation failures, and result.reason as the fallback.
Handling Partial Failures¶
What happens when the referral code is invalid but everything else succeeds? Right now, the workflow stops at Users::ApplyReferralBonus and the user never gets their welcome email.
That might not be what we want. A bad referral code shouldn't block registration.
In v2, failure always halts the pipeline — there's no opt-out toggle. That's correct for Users::Register (can't continue without a user) but too strict for referrals. The fix is to let the task decide its own severity:
class Users::ApplyReferralBonus < CMDx::Task
required :user
required :referral_code, presence: true
def work
referrer = User.find_by(referral_code: referral_code)
if referrer.nil?
skip!("Invalid referral code — skipping bonus")
return
end
Referral.create!(
referrer: referrer,
referred: user,
bonus_type: :signup,
status: :pending
)
referrer.increment!(:referral_count)
end
end
By using skip! instead of fail!, the referral step reports the issue in logs and on the result, but the workflow continues. The user gets registered, verified, and welcomed — the referral bonus is just missing.
This is the approach I prefer. The task decides its own severity. Critical steps fail!, non-critical steps skip!.
Observability for Free¶
Configure the JSON log formatter (the default Line formatter is human-readable; switch when you want machine-parseable output):
Run the workflow and the message field of each log line is the serialized result.to_h:
{"cid":"abc123","index":1,"root":false,"type":"Task","task":"Users::Register","status":"success","duration":45.2, ...}
{"cid":"abc123","index":2,"root":false,"type":"Task","task":"Users::SendVerification","status":"success","duration":12.1, ...}
{"cid":"abc123","index":3,"root":false,"type":"Task","task":"Users::ActivateTrial","status":"success","duration":8.0, ...}
{"cid":"abc123","index":4,"root":false,"type":"Task","task":"Users::ApplyReferralBonus","status":"skipped","reason":"Invalid referral code — skipping bonus","duration":3.4, ...}
{"cid":"abc123","index":5,"root":false,"type":"Task","task":"Users::SendWelcome","status":"success","duration":6.7, ...}
{"cid":"abc123","index":6,"root":false,"type":"Task","task":"Users::TrackRegistration","tags":["analytics","onboarding"],"status":"success","duration":2.1, ...}
{"cid":"abc123","index":0,"root":true,"type":"Workflow","task":"Users::Onboard","tags":["onboarding"],"status":"success","duration":76.5, ...}
One cid links every step. The skipped referral bonus is visible without digging through exception trackers. The root workflow result is at index: 0 (Runtime unshifts the root onto the chain). The workflow still reports success because skips are considered good outcomes (result.ok?).
Testing the Pipeline¶
Test each task in isolation, then test the workflow as an integration:
RSpec.describe Users::Register do
it "creates a user with valid inputs" do
result = Users::Register.execute(
email: "ada@example.com",
password: "securepass123",
plan: "trial"
)
expect(result).to be_success
expect(result.context.user).to be_persisted
expect(result.context.user.status).to eq("pending_verification")
end
it "fails on duplicate email" do
create(:user, email: "ada@example.com")
result = Users::Register.execute(
email: "ada@example.com",
password: "securepass123",
plan: "trial"
)
expect(result).to be_failed
expect(result.metadata[:code]).to eq(:duplicate)
end
end
RSpec.describe Users::Onboard do
it "runs the full onboarding pipeline" do
result = Users::Onboard.execute(
email: "ada@example.com",
password: "securepass123",
plan: "trial",
referral_code: nil
)
expect(result).to be_success
expect(result.context.user).to be_persisted
expect(result.context.verification_token).to be_present
expect(result.context.trial_ends_at).to be_present
end
it "continues when referral code is invalid" do
result = Users::Onboard.execute(
email: "ada@example.com",
password: "securepass123",
plan: "free",
referral_code: "BOGUS"
)
expect(result).to be_success
expect(result.context.user).to be_persisted
referral_result = result.chain.results.find { |r| r.task == Users::ApplyReferralBonus }
expect(referral_result).to be_skipped
end
it "traces the root cause when registration fails" do
create(:user, email: "ada@example.com")
result = Users::Onboard.execute(
email: "ada@example.com",
password: "securepass123",
plan: "trial"
)
expect(result).to be_failed
expect(result.caused_failure.task).to be_a(Users::Register)
expect(result.caused_failure.metadata[:code]).to eq(:duplicate)
end
end
Unit tests for individual tasks, integration tests for the workflow. The chain.results API lets you inspect specific steps within the pipeline.
The Full Picture¶
Here's the directory structure for our onboarding feature:
app/tasks/
application_task.rb
users/
register.rb
send_verification.rb
activate_trial.rb
apply_referral_bonus.rb
send_welcome.rb
track_registration.rb
onboard.rb
Seven files, each under 40 lines. The workflow reads like a checklist. Every step has input validation, output contracts, structured logging, and chain correlation. Add a new onboarding step (say, GDPR consent for EU users) and you write one task, add one line to the workflow with an if: :eu_user? conditional, and you're done. Nothing else changes.
That's what real-world CMDx looks like.
Happy coding!