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:

  1. 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.
  2. 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.