Where Business Logic Should Live
Callbacks and fat models are a common failure point in Rails applications. While Grouper recently wrote about using interactors to keep ActiveRecord models lean, a major refactor of the Heroku API independently converged on a nearly identical approach. The pattern is called the Mediator pattern: a plain Ruby object (PORO) that defines how a set of other objects interact.
The core idea is straightforward: instead of scattering business logic across endpoint bodies and model methods, consolidate it into dedicated mediator classes. This keeps models focused on associations, validations, and accessors, while making the actual units of work explicit, testable, and reusable.
Thin Endpoints, Thick Mediators
The goal for API endpoints is to reduce them to three responsibilities: request checks (authentication, ACL, parameter validation), a single call down to a mediator, and response logic such as serialization and status codes. Here is what that looks like in practice for an SSL Endpoint creation endpoint:
module API::Endpoints::APIV3
class SSLEndpoints < Base
...
namespace "/apps/:id/ssl-endpoints" do
before do
authorized!
@ap = get_any_app!
check_permissions!(:manage_domains, @ap)
check_params!
end
post do
@endpoint = API::Mediators::SSLEndpoints::Creator.run(
auditor: self,
app: @ap,
key: v3_body_params[:private_key],
pem: v3_body_params[:certificate_chain],
user: current_user
)
respond serialize(@endpoint), status: 201
end
end
...
end
end
This convention keeps critical logic out of endpoints and in mediator classes where it is easier to access and reason about. It also sharpens unit tests: endpoint tests only cover authentication, permissions, parameters, and serialization. In success cases, the mediator is mocked and the endpoint test stays focused on endpoint concerns:
# endpoint unit tests
describe API::Endpoints::APIV3::SSLEndpoints do
...
describe "POST /apps/:id/ssl-endpoints" do
it "calls into the mediator" do
mock(API::Endpoints::APIV3::SSLEndpoints).run(hash_including({
app: @app,
key: "my-private-key",
pem: "my-pem",
user: @user,
})
authorize "", @user.api_key
header "Content-Type", "application/json"
post "/apps/#{@app.name}/ssl-endpoints", MultiJson.encode({
private_key: "my-private-key",
certificate_chain: "my-pem",
})
end
end
...
end
The mediator gets its own exhaustive unit tests, which exercise the real business logic in depth:
# mediator units tests
describe API::Mediators::SSLEndpoints::Creator do
...
it "produces an SSL Endpoint" do
endpoint = run
assert_kind_of API::Models::SSLEndpoint, endpoint
end
it "makes a call to the Ion API to create the endpoint" do
mock(IonAPI).create_endpoint
run
end
...
private
def run(options = {})
API::Mediators::SSLEndpoints::Creator.run({
app: @app,
key: @key_contents,
pem: @pem_contents,
user: @app.owner,
}.merge(options))
end
end
Thin Jobs Too
Async jobs benefit from the same separation. When all business logic lives in a mediator, a job has only two jobs of its own:
- Model materialization. Jobs receive data through backchannels like a database table or Redis queue and must reconstruct the needed models. How failures are handled varies by context: if an app was deleted before a logging-channel job runs, the job should silently no-op; if a destroy-app job cannot find its app, that is unexpected and should raise.
- Error handling. The job rescues exceptions and decides what to do. A connection error to a downstream service might warrant retrying the job from the queue; a configuration error might mean alerting the error service and failing permanently.
An async job wrapping the SSL Endpoint creation mediator looks like this:
module API::Jobs::SSLEndpoints
class Creator < API::Jobs::Base
def initialize(args = {})
super
require_args!(
:app_id,
:key,
:pem,
:user_id
)
end
def call
# If the app is no longer present, then it's been deleted since the job
# was dequeued; succeed without doing anything.
return unless @app = App.find_by_id(args[:app_id])
# If the user is no longer present, then they may have deleted their
# account isince the job was dequeued; succeed without doing anything.
return unless @user = User.find_by_id(args[:user_id])
API::Mediators::SSLEndpoints::Creator.run(
auditor: self,
app: @app,
key: args[:key],
pem: args[:pem],
user: @user
)
# Something is wrong which will prevent the job from ever succeeding. Fail
# the job permanently and notify operators of the error.
rescue API::Error::ConfigurationMissing => e
raise API::Error::JobFailed.new(e)
# Something has caused a temporary disruption in service. Queue the job
# again for retry.
rescue Excon::Errors::Error
raise API::Error::JobRetry
end
end
end
Note that the above is simplified: a sensitive value like an SSL key would need encryption before passing through an insecure channel.
Strong Assumptions Make Mediators Simple
Mediators are written under three preconditions:
- Parameters: already present and in the expected form.
- Models: already materialized from identifiers, so no lookup logic is needed internally.
- Security: authentication and access control already handled.
These assumptions dramatically cut complexity: no defensive checks for object presence or shape. Testing is easier because parameter-validation boilerplate is consolidated upstream. And mediators become callable from unusual contexts like a debugging or operations console, since they do not depend on request plumbing.
Composing Mediators
Mediators encapsulate a discrete unit of work that might otherwise have become a sprawling model method. Since units of work compose, mediators often call other mediators. An app-deprovision mediator, for example, also removes the app’s add-ons:
module API::Mediators::Apps
class Destroy < API::Mediators::Base
...
def destroy_addons
@app.addons.each do |addon|
API::Mediators::Addons::Destroyer.run(
addon: addon,
auditor: @auditor,
)
end
end
...
end
This is safe as long as the call hierarchy stays acyclic; developers should avoid creating mediators too eagerly.
Conventions Built Into the Pattern
Once mediators become the default unit of work, useful conventions can be attached. One pattern is built-in auditing, so a trail of audit events is produced even when work runs from unexpected places such as a console:
module API::Mediators::Apps
class Destroy < API::Mediators::Base
...
def call
audit do
...
end
end
private
def audit(&block)
@auditor.audit("destroy-app", target_app: @app, &block)
end
end
end
Another is structuring a mediator’s call body as a readable sequence of one-line helper calls, making the operations performed by the mediator easy to follow:
module API::Mediators::Apps
class Destroy < API::Mediators::Base
...
def call
audit do
App.transaction do
destroy_addons
destroy_domains
destroy_ssl_endpoints
close_payment_method_history
close_resource_histories
delete_logplex_channel
@app.destroy
end
end
end
...
end
After years with the mediator pattern, the trade-off is clear: mediator calls are a bit more verbose than model methods, but the payoff is significant. Models become lean, the codebase is less coupled to ActiveRecord, and eliminating callbacks reduces production incidents caused by innocent-looking code triggering major side effects. Test code is also more transparent, since business logic has a single, obvious home.



