Skip to content

Blog

Real-World CMDx: Multi-Tenant SaaS Patterns

Part 4 of the Real-World CMDx series

Built on CMDx 2.0 — see the v2 release post. v2's frozen Result makes pattern matching on tenant metadata safe across threads, and the per-fiber chain isolates tenant scopes naturally.

Multi-tenancy changes everything. That simple Users::Register task you wrote? Now it needs to know which tenant it's operating on. Your database queries need scoping. Your logging needs tenant context. Your middleware stack needs to enforce tenant isolation. And if you get any of it wrong, Customer A sees Customer B's data, and you're writing an incident report instead of features.

I've built three multi-tenant SaaS products with Ruby and CMDx. Each one taught me something about where tenant boundaries belong and, more importantly, where they don't. The pattern I've settled on keeps tenant concerns out of business logic entirely — tasks don't know they're multi-tenant. The middleware and base classes handle it.

Real-World CMDx: Background Jobs + CMDx

Part 3 of the Real-World CMDx series

Built on CMDx 2.0 — see the v2 release post. v2 ships only one Fault class (no FailFault/SkipFault), drops the built-in Correlate middleware in favor of Telemetry pub/sub, and exposes result.duration directly.

There's a natural tension between CMDx tasks and background jobs. Tasks are synchronous, deterministic, and observable. Jobs are asynchronous, retry-prone, and fire-and-forget. When you need both—and you almost always do—the question is how to combine them without losing what makes each one good.

I've seen two bad extremes. The first: every task becomes a Sidekiq job, scattering your business logic across app/jobs/ and app/tasks/ with duplicated validation and no shared observability. The second: a single monolithic job that calls a workflow synchronously, tying up a Sidekiq thread for 30 seconds while it processes an order end-to-end.

The sweet spot is using CMDx tasks as the unit of work and Sidekiq as the execution engine. Your Ruby business logic stays in tasks. The job is just the thin wrapper that triggers it. Here's how I set this up in production.

Real-World CMDx: Integrating External APIs

Part 2 of the Real-World CMDx series

Built on CMDx 2.0 — see the v2 release post for the runtime changes this post depends on, especially Task#rollback and the output pipeline.

External APIs are where clean code goes to die. You write a beautiful service object, ship it, and then the real world hits: Stripe times out, the shipping API returns HTML instead of JSON, the geocoding service rate-limits you at 2 PM on a Tuesday. Suddenly your elegant PaymentService is a nest of rescue blocks, retry loops, and sleep statements.

I've been on the receiving end of enough 3 AM pages to know that the problem isn't the API—it's how we integrate with it. CMDx gives you a layered defense against flaky I/O: retries for transient failures, timeouts for slow responses, circuit breakers for cascading failures, and structured error handling for everything else. Let me build a complete Stripe payment integration in Ruby to show you how these pieces fit together.

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.

CMDx 2.0 Is Here: Rewriting the Runtime

v1 shipped in March 2025. Over the next year, a lot of real applications pushed it in directions I hadn't planned for: fiber-based schedulers, high-throughput workflows, middleware stacks that wanted to introspect results, pattern-matching consumers. Every one of those pressures exposed the same underlying problem — the v1 runtime was a state machine bolted onto a mutable Result, and the longer I tried to extend it, the more it fought back.

v2 is the rewrite those cracks justified. Same DSL surface you already know — required, optional, returns, on_success, settings, CMDx::Workflow — but a different engine underneath. This post is about why I rewrote the runtime, what actually changed, and how to get your app onto it.

CMDx Patterns: Debugging and Observability

Part 4 of the CMDx Patterns series

Targets CMDx v1.21.

It's 2 AM. Your pager fires. A customer reports that their order went through but they never got a confirmation email. You open your log aggregator and search for the user ID. In a typical Rails app, you'd find a scattered trail of puts-style logs, maybe a Sentry exception if you're lucky, and no clear picture of what actually happened.

With CMDx, you search for the chain_id and see every task that ran, in order, with timing, status, and metadata. The confirmation email task shows status: "skipped", reason: "User unsubscribed from order notifications". Mystery solved in under a minute.

That's the power of observability built into the framework. This post covers how to use CMDx's logging, chain correlation, result inspection, and tagging to debug problems fast—in both development and production.

CMDx Patterns: The Error Handling Playbook

Part 3 of the CMDx Patterns series

Targets CMDx v1.21.

I used to think error handling was simple. Something goes wrong, you rescue it, done. Then I started building systems where "something went wrong" had fifteen different flavors—each requiring a different response. A missing record is not the same as a network timeout. A user's expired subscription is not the same as a billing system outage. Treating them identically is how you end up with generic "Something went wrong" error pages and support tickets that take hours to triage.

CMDx gives you four distinct mechanisms for handling problems: skip!, fail!, throw!, and letting exceptions propagate. Knowing which one to reach for—and when—is the difference between a system that degrades gracefully and one that falls over at the first sign of trouble.

CMDx Patterns: Advanced Middleware Stacks

Part 2 of the CMDx Patterns series

Targets CMDx v1.21.

Middleware is one of those features that's easy to understand and hard to use well. You write a simple wrapper, register it, and it works. Then you write another. And another. Before long, you've got six middlewares on every task, they're firing in an order you didn't intend, and you're spending more time debugging the middleware stack than the business logic it wraps.

I've been through that cycle enough times to develop opinions about how to compose middleware stacks in CMDx. This post covers the patterns that survived contact with production Ruby applications—from simple wrappers to sophisticated multi-layer stacks.

CMDx Patterns: Defensive Contracts

Part 1 of the CMDx Patterns series

Targets CMDx v1.21.

I have a rule when building Ruby tasks: if a task can be misused, it will be misused. Not out of malice—out of haste, incomplete documentation, or the natural entropy of a growing codebase. Someone passes a string where you expected an integer. Someone forgets to read the context key you set. Someone calls your task from a new workflow and the whole pipeline falls over because the inputs were subtly wrong.

Defensive contracts are CMDx's answer to this. By combining required/optional attributes, validations, coercions, and returns, you build tasks that are impossible to misuse silently. Bad data fails loudly at the boundary. Missing outputs fail immediately at the source. The contract is the code, and the code enforces itself.

Returns and Contracts: Making Your Tasks Predictable

Targets CMDx v1.20.

I used to have a recurring nightmare. Not the falling kind—the kind where I'm staring at a service object and trying to figure out what it puts into the context. The work method sets context.user on line 12, context.token on line 28, but only if the conditional on line 15 passes. Oh, and there's a context.session_id that gets set inside a private method three screens down.

Every consumer of that task is making an implicit assumption about what the context will contain after execution. When those assumptions break, the error shows up somewhere else entirely—a NoMethodError in a downstream task, a nil where a mailer expected a user object.

That's why I built returns into CMDx. It makes the output contract explicit, enforced, and impossible to forget.