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.
The Tenant Context Problem¶
In a typical multi-tenant Rails app, tenant scoping is everywhere:
class OrderService
def create(user, items)
# Who sets Current.tenant? The controller? A middleware?
# What if this runs in a background job?
Order.where(tenant: Current.tenant).create!(user: user, items: items)
InventoryService.new(Current.tenant).reserve(items)
NotificationService.notify(Current.tenant, user, :order_created)
end
end
Current.tenant is a global, mutable, thread-local variable. It works until it doesn't — until a background job runs without setting it, or a before_action forgets to set it, or two requests race on the same thread in a non-threadsafe scenario.
With CMDx, the tenant flows through the context — explicit, immutable, and traceable.
Tenant-Aware Base Task¶
Start with a base task that enforces tenant presence:
class TenantTask < ApplicationTask
required :tenant
before_execution :set_tenant_scope
private
def set_tenant_scope
ActsAsTenant.current_tenant = tenant
end
end
Every task that touches tenant-scoped data inherits from TenantTask. The tenant is a required attribute — not a global, not a thread-local, not an implicit assumption. If you forget to pass it, the task fails at validation before any code runs.
ActsAsTenant (or whatever scoping library you use) gets set in a callback, keeping it out of the business logic.
Tenant Middleware for Isolation¶
For defense-in-depth, add middleware that ensures tenant scoping is active:
class TenantIsolation
def call(task)
tenant = task.context[:tenant]
if tenant.nil?
throw(CMDx::Signal::TAG, CMDx::Signal.failed("Tenant context missing", metadata: { code: :tenant_required }))
end
ActsAsTenant.with_tenant(tenant) { yield }
end
end
Register it globally for extra safety:
In v2, middlewares can't mutate Result — it's frozen, built once at the end of the lifecycle. To halt a task from middleware, call task.fail! (or skip! / success! / throw!) before yielding; Runtime wraps the middleware chain in catch(Signal::TAG) and converts the signal into the appropriate Result.
The middleware uses with_tenant, which scopes all ActiveRecord queries within the block and restores the previous tenant when the block exits — safer than setting current_tenant directly: if the task raises, the tenant scope is still restored.
Why Both Callback and Middleware?¶
The before_execution callback sets the tenant for the task's work method. The middleware scopes the entire execution, including callbacks and nested tasks. Belt and suspenders — one can't run without the other, and both are harmless if duplicated.
Tenant-Scoped Logging¶
Subscribe to the :task_executed Telemetry event and ship the tenant slug to your log aggregator. Result is frozen in v2, so middleware can't bolt fields onto metadata after the fact — Telemetry is the v2-native seam:
CMDx.configure do |config|
config.telemetry.subscribe(:task_executed) do |event|
tenant = event.payload[:result].context[:tenant]
next unless tenant
Rails.logger.info(
cid: event.cid,
task: event.task_class.name,
tenant_slug: tenant.slug,
status: event.payload[:result].status,
duration: event.payload[:result].duration
)
end
end
Filter your log aggregator by tenant_slug:"acme" and see every task execution for that tenant. Cross-reference with cid to trace a single request — the per-fiber chain (lib/cmdx/chain.rb) keeps concurrent tenant requests isolated automatically.
Per-Tenant Configuration¶
Different tenants have different needs. Enterprise tenants might need longer timeouts, different retry policies, or additional middleware:
class TenantConfigMiddleware
def call(task)
tenant = task.context[:tenant]
task.logger.level = :debug if tenant&.feature?(:enhanced_logging)
yield
end
end
For tasks that behave differently per tenant:
class Reports::Generate < TenantTask
required :report_type, inclusion: { in: %w[summary detailed] }
output :report
def work
context.report = case report_type
when "detailed"
fail!("Detailed reports require Enterprise plan",
code: :plan_required) unless tenant.enterprise?
DetailedReportBuilder.new(tenant).build
when "summary"
SummaryReportBuilder.new(tenant).build
end
end
end
Feature gating happens inside the task with fail!. The caller gets a structured error they can display to the user.
Tenant-Scoped Workflows¶
Workflows compose tenant-scoped tasks naturally:
class Onboarding::SetupTenant < CMDx::Task
include CMDx::Workflow
settings(tags: ["onboarding", "tenant-setup"])
task Tenants::Create
task Tenants::ProvisionDatabase, if: :dedicated_database?
task Tenants::SeedDefaultData
task Tenants::CreateAdminUser
task Tenants::ConfigureBilling
task Tenants::SendWelcome
private
def dedicated_database?
context.plan == "enterprise"
end
end
Create the Tenant¶
class Tenants::Create < ApplicationTask
required :name, presence: true, length: { min: 2, max: 100 }
required :slug, format: /\A[a-z0-9-]+\z/, length: { min: 2, max: 50 }
required :plan, inclusion: { in: %w[starter growth enterprise] }
required :owner_email, format: { with: URI::MailTo::EMAIL_REGEXP }
output :tenant
def work
fail!("Slug already taken", code: :slug_taken) if Tenant.exists?(slug: slug)
context.tenant = Tenant.create!(
name: name,
slug: slug,
plan: plan,
status: :provisioning
)
end
end
Seed Default Data¶
class Tenants::SeedDefaultData < TenantTask
output :seed_summary
def work
roles = Role.insert_all([
{ name: "admin", tenant_id: tenant.id },
{ name: "member", tenant_id: tenant.id },
{ name: "viewer", tenant_id: tenant.id }
])
categories = Category.insert_all(
default_categories.map { |c| c.merge(tenant_id: tenant.id) }
)
context.seed_summary = {
roles: roles.count,
categories: categories.count
}
logger.info "Seeded #{roles.count} roles and #{categories.count} categories"
end
private
def default_categories
[
{ name: "General", color: "#6366f1" },
{ name: "Billing", color: "#22c55e" },
{ name: "Support", color: "#f59e0b" }
]
end
end
Create the Admin User¶
class Tenants::CreateAdminUser < TenantTask
required :owner_email
output :admin_user
def work
context.admin_user = User.create!(
email: owner_email,
tenant: tenant,
role: Role.find_by!(tenant: tenant, name: "admin"),
status: :pending_verification
)
end
end
Every task after Tenants::Create inherits from TenantTask, so they all have the tenant attribute required and the scoping middleware active. The tenant flows through context automatically — Tenants::Create sets context.tenant, and every subsequent task reads it as a required attribute.
Cross-Tenant Operations¶
Admin tasks that operate across tenants need to bypass the tenant scope:
class Admin::BaseTask < ApplicationTask
deregister :middleware, TenantIsolation
end
class Admin::GenerateUsageReport < Admin::BaseTask
required :billing_period, coerce: :date
settings(tags: ["admin", "billing"])
output :report
def work
context.report = Tenant.active.map do |tenant|
{
tenant_id: tenant.id,
tenant_name: tenant.name,
plan: tenant.plan,
active_users: tenant.users.active.count,
storage_mb: tenant.storage_used_mb,
api_calls: tenant.api_calls_for(billing_period)
}
end
end
end
By deregistering TenantIsolation, admin tasks can query across all tenants. This is explicit and auditable — you can grep your codebase for Admin::BaseTask to find every cross-tenant operation.
Tenant-Aware Background Jobs¶
Combine the patterns from Part 3 (which defines the lightweight Correlate module) with tenant scoping:
class TenantJob
include Sidekiq::Job
def perform(args)
tenant = Tenant.find(args["tenant_id"])
Correlate.use(args["correlation_id"]) do
ActsAsTenant.with_tenant(tenant) do
args["task_class"].constantize.execute!(
args["context"].merge("tenant" => tenant)
)
end
end
end
end
Enqueue with tenant context:
class Billing::EnqueueInvoiceGeneration < TenantTask
def work
TenantJob.perform_async(
"tenant_id" => tenant.id,
"correlation_id" => Correlate.id,
"task_class" => "Billing::GenerateInvoice",
"context" => { "billing_period" => Date.today.to_s }
)
logger.info "Enqueued invoice generation for tenant #{tenant.slug}"
end
end
The tenant ID is serialized with the job. When it executes, the tenant scope is restored before the task runs. The correlation id bridges the async boundary for tracing.
Testing Multi-Tenant Tasks¶
Test with explicit tenant context:
RSpec.describe Orders::Create do
let(:tenant) { create(:tenant) }
let(:user) { create(:user, tenant: tenant) }
before do
CMDx.reset_configuration!
CMDx::Chain.clear
end
it "creates an order scoped to the tenant" do
result = Orders::Create.execute(
tenant: tenant,
user: user,
items: [{ product_id: 1, quantity: 2 }]
)
expect(result).to be_success
expect(result.context.order.tenant).to eq(tenant)
end
it "fails without a tenant" do
result = Orders::Create.execute(
user: user,
items: [{ product_id: 1, quantity: 2 }]
)
expect(result).to be_failed
expect(result.errors[:tenant]).not_to be_empty
end
end
For tenant isolation integration tests:
RSpec.describe "tenant isolation" do
let(:tenant_a) { create(:tenant) }
let(:tenant_b) { create(:tenant) }
it "does not leak data between tenants" do
Orders::Create.execute(
tenant: tenant_a,
user: create(:user, tenant: tenant_a),
items: [{ product_id: 1, quantity: 1 }]
)
result = Orders::List.execute(tenant: tenant_b)
expect(result).to be_success
expect(result.context.orders).to be_empty
end
end
The tenant is always explicit. No test setup that silently sets Current.tenant. No test pollution across examples.
The Architecture at a Glance¶
CMDx.configure
└── TenantIsolation middleware (global)
└── ErrorTracking middleware (global)
ApplicationTask < CMDx::Task
└── DatabaseTransaction middleware
TenantTask < ApplicationTask
└── required :tenant
└── before_execution :set_tenant_scope
Telemetry subscribers
└── :task_executed → tenant-scoped log emission
Admin::BaseTask < ApplicationTask
└── deregister TenantIsolation (cross-tenant access)
Domain Tasks (Orders::Create, Billing::Charge, etc.)
└── inherit from TenantTask
└── business logic only — no tenant plumbing
The business logic layer doesn't know about multi-tenancy. Orders::Create creates an order. Billing::Charge charges a card. The tenant scoping, isolation, logging, and gating all happen in the layers below.
That's the whole point: tenant boundaries are an infrastructure concern, not a business logic concern. CMDx's layered architecture — middleware, base classes, callbacks — gives you the right places to put infrastructure without polluting the code that matters.
Happy coding!