A Rails controller often starts small. A user selects a subscription plan, the application processes a payment, and the subscription becomes active.
Then the requirements grow. You need to handle failed payments, prevent duplicate charges, activate features, and deal with external services that occasionally fail.
Moving each operation into a service object helps, but the controller may still be responsible for coordinating all those services.
The Facade pattern offers a cleaner approach: expose one entry point for a complex workflow while keeping its individual responsibilities separate.
From a Fat Controller to a Facade
Consider a subscription checkout that involves payment processing, subscription activation, and feature provisioning.
class SubscriptionsController < ApplicationController
def create
plan = Plan.find(params[:plan_id])
payment = PaymentProcessor.call(
user: current_user,
plan: plan
)
if payment.succeeded?
subscription = current_user.subscriptions.create!(
plan: plan,
status: "active"
)
FeatureProvisioner.call(subscription)
redirect_to subscription_path(subscription)
else
redirect_to plans_path, alert: "Payment failed"
end
end
end
The problem isn't the number of lines. The controller knows too much about the workflow: which operation runs first, what depends on payment success, and what happens next.
As the feature evolves, more conditions and failure scenarios end up in the same action.
Introducing the Facade
We can move that coordination into a dedicated class:
class SubscriptionActivation
def initialize(
payment_processor: PaymentProcessor.new,
subscription_activator: SubscriptionActivator.new,
feature_provisioner: FeatureProvisioner.new
)
@payment_processor = payment_processor
@subscription_activator = subscription_activator
@feature_provisioner = feature_provisioner
end
def call(user:, plan:, idempotency_key:)
payment = @payment_processor.call(
user: user,
plan: plan,
idempotency_key: idempotency_key
)
return ActivationResult.failure(:payment_failed) unless
payment.succeeded?
subscription = @subscription_activator.call(
user: user,
plan: plan,
payment: payment
)
@feature_provisioner.call(subscription)
ActivationResult.success(subscription)
end
end
The Facade coordinates the workflow without implementing every operation itself. Payment processing, subscription activation, and feature provisioning remain separate responsibilities.
Dependency injection also makes testing easier because each collaborator can be replaced with a test double.
The controller now has a simpler job:
class SubscriptionsController < ApplicationController
def create
plan = Plan.find(params[:plan_id])
result = SubscriptionActivation.new.call(
user: current_user,
plan: plan,
idempotency_key: checkout_attempt.idempotency_key
)
if result.success?
redirect_to subscription_path(result.subscription)
else
redirect_to plans_path, alert: "Unable to activate subscription"
end
end
end
Here, checkout_attempt represents a persisted checkout attempt whose idempotency key is reused on retries. The result objects and supporting services are application-specific, not built-in Rails classes.
The Detail That Matters: Failure Handling
A Facade organizes a workflow, but it doesn't automatically make that workflow reliable.
Imagine the payment provider confirms a charge, but the database update fails. A database rollback cannot undo a charge made by an external service.
Retries create another risk. If a customer submits checkout twice, the application must avoid charging them twice for the same attempt.
Use a stable idempotency key for payment requests. Stripe documents how idempotent requests help prevent duplicate operations when requests are retried.
External side effects also need careful handling. If feature provisioning happens through another service, a failed request may require a retry or reconciliation process. For workflows where losing an event is unacceptable, a transactional outbox can help ensure that database changes and the corresponding event are recorded together.
These mechanisms solve different problems. The Facade coordinates the steps; idempotency and reliable event delivery protect the workflow when things go wrong.
Testing the Workflow
A useful Facade test verifies the order and conditions of the operations, rather than testing the internals of every collaborator again.
For example, a declined payment should prevent subscription activation:
RSpec.describe SubscriptionActivation do
it "does not activate a subscription after payment fails" do
payment_processor = instance_double(PaymentProcessor)
activator = instance_double(SubscriptionActivator)
provisioner = instance_double(FeatureProvisioner)
allow(payment_processor)
.to receive(:call)
.and_return(
instance_double(
PaymentResult,
succeeded?: false
)
)
facade = described_class.new(
payment_processor: payment_processor,
subscription_activator: activator,
feature_provisioner: provisioner
)
facade.call(
user: build_stubbed(:user),
plan: build_stubbed(:plan),
idempotency_key: "checkout_123"
)
expect(activator).not_to have_received(:call)
expect(provisioner).not_to have_received(:call)
end
end
This example assumes the application's result objects and test factories are defined accordingly.
When Should You Use a Facade?
A Facade is useful when a single business operation requires several components to work together and callers shouldn't need to know the sequence.
It isn't necessary for every controller action. If an operation is simple, adding another class can create unnecessary indirection. And if the Facade starts implementing every business rule itself, it may simply become another oversized object.
Keep the distinction clear:
- Controller: handles the HTTP request and response.
- Facade: coordinates the workflow.
- Collaborating services: implement their own responsibilities.
The goal isn't to create more classes. It's to make a complicated operation easier to understand, test, and change without spreading its coordination logic throughout the application.
Top comments (0)