Every Rails codebase I've worked on eventually needs to talk to an external API. Stripe for payments, Slack for notifications, some internal microservice for data sync. And every time, the same problem shows up: the service that handles the business logic also handles HTTP clients, authentication tokens, error parsing, and retry logic. One class, too many responsibilities.
Over time I settled on a pattern that fixes this cleanly — splitting services into two layers: API services that handle transport, and application services that handle business logic. Both layers are built with Servactory, which makes the separation natural through its typed attributes and declarative actions.
The idea
The separation is simple. API services know how to talk to an external system. Application services know what your app needs to do. They never cross responsibilities.
API services initialize the API client, prepare credentials (tokens, keys), format domain data into what the external API expects, and make the HTTP call. They don't know about your ActiveRecord models, your jobs, or your business rules. Their only dependency is the API client itself — and maybe a cache for access tokens.
Application services orchestrate the business flow. They work with entities, check statuses, enqueue jobs, update records. When they need to reach an external API, they delegate to an API service and handle the result.
The key convention: application services are called with .call! (raising an exception on failure), while inside them, API services are called with .call (returning a result object for manual inspection). This creates a clean error boundary that I'll explain later.
The base class
Before diving into the two layers, here's the shared foundation. All services inherit from one base class that includes Servactory's DSL:
class ApplicationService::Base
include Servactory::DSL
configuration do
input_exception_class ApplicationService::Exceptions::Input
internal_exception_class ApplicationService::Exceptions::Internal
output_exception_class ApplicationService::Exceptions::Output
failure_class ApplicationService::Exceptions::Failure
result_class ApplicationService::Result
action_shortcuts %i[
assign build check create
fetch handle perform validate
]
end
end
The action_shortcuts configuration is worth noting — it lets you write perform :api_request! instead of make :perform_api_request!, and the method is automatically mapped to def perform_api_request!. It's a small thing that makes services read better.
API services
An API service base class initializes the client and defines the request-response cycle. Here's what one looks like for a payment gateway integration:
class PaymentGateway::API::Base < ApplicationService::Base
output :response, type: Hash
perform :api_request!
private
def perform_api_request!
outputs.response = api_request
rescue PaymentGateway::Client::Error => e
fail!(message: e.message, meta: { code: e.code })
end
def api_request
fail!(message: "Subclass must implement #api_request")
end
def api_client
@api_client ||= PaymentGateway::Client.new(
access_token: access_token
)
end
def access_token
Rails.cache.fetch("payment_gateway/access_token", expires_in: 50.minutes) do
result = PaymentGateway::API::Auth::GenerateToken.call(
client_id: Rails.application.credentials.payment_gateway.client_id,
client_secret: Rails.application.credentials.payment_gateway.client_secret
)
return result.access_token if result.success?
fail!(message: "Authentication failed", meta: { reason: result.error.message })
end
end
end
A few things happen here. The base class owns the API client lifecycle — creating it, caching the access token, handling authentication. Even the token generation follows the same pattern: GenerateToken is itself an API service called with .call, its result checked explicitly. It inherits from ApplicationService::Base directly rather than from PaymentGateway::API::Base — otherwise you'd have a circular dependency where the base needs a token to initialize, but the token service inherits from that base. Child classes never deal with any of this.
A concrete API service becomes trivially simple:
class PaymentGateway::API::Charges::Create < PaymentGateway::API::Base
input :amount, type: Money
input :card_token, type: String
private
def api_request
api_client.charges.create(
amount: inputs.amount.cents,
currency: inputs.amount.currency.iso_code,
source: inputs.card_token
)
end
end
That's the entire class. It accepts a Money object from the application layer, extracts cents and currency code for the gateway's expected format, and inherits everything else — client setup, error handling, token management. The application service never thinks about how the gateway wants its amounts formatted.
If the gateway has another endpoint — say, refunds — you add another service with the same structure:
class PaymentGateway::API::Refunds::Create < PaymentGateway::API::Base
input :charge_id, type: String
input :amount, type: Money
private
def api_request
api_client.refunds.create(
charge_id: inputs.charge_id,
amount: inputs.amount.cents
)
end
end
Same pattern, zero duplication.
Application services
Now the business layer. An application service orchestrates a complete flow — validating preconditions, calling the API service, handling the result, updating entities.
class Payments::Charge < ApplicationService::Base
input :payment, type: Payment
internal :charge_result, type: Servactory::Result
output :payment, type: Payment
make :check_status!
make :perform_charge!
make :handle_response!
make :assign_payment
private
def check_status!
return if inputs.payment.pending?
fail!(
message: "Payment has already been processed",
meta: { status: inputs.payment.status }
)
end
def perform_charge!
internals.charge_result = PaymentGateway::API::Charges::Create.call(
amount: inputs.payment.amount,
card_token: inputs.payment.card_token
)
end
def handle_response!
internals.charge_result
.on_success { handle_success! }
.on_failure { handle_failure! }
end
def handle_success!
inputs.payment.complete!(internals.charge_result.response)
end
def handle_failure!
inputs.payment.fail!(internals.charge_result.error.message)
fail_result!(internals.charge_result)
end
def assign_payment
outputs.payment = inputs.payment
end
end
Let me walk through what's happening here.
The service takes a Payment entity as input and checks that it hasn't already been processed — a basic idempotency guard. Then it calls the API service with .call (not .call!), storing the result in an internal attribute typed as Servactory::Result.
The handle_response! step uses Servactory's .on_success and .on_failure hooks on the result object. On success, the payment gets completed through a domain method. On failure, the payment is marked as failed, and fail_result! propagates the API error upward.
Notice how the application service doesn't know anything about HTTP, API clients, or authentication. It works with a Payment entity and delegates the API work entirely.
The .call vs .call! convention
This is the architectural detail that ties everything together.
When a controller or a job calls an application service, it uses .call!:
class Payments::ChargesController < ApplicationController
def create
service_result = Payments::Charge.call!(payment: @payment)
render json: { payment: service_result.payment }, status: :ok
end
end
If the service fails, .call! raises an exception — specifically ApplicationService::Exceptions::Failure. This exception is caught at the controller layer by a shared concern:
module ExceptionsConcern
extend ActiveSupport::Concern
included do
rescue_from StandardError, with: :render_exception
end
def render_exception(exception)
service_result = ExceptionService::Handler.call!(exception:)
render json: service_result.json, status: service_result.http_status
end
end
The exception handler routes each exception type to a specific handler, formats the error response, and returns it as JSON. No manual if service.success? checks scattered across controllers.
Inside an application service, though, the API service is called with .call — without the bang. This means failures don't raise exceptions. Instead, you get a result object that you inspect explicitly:
# Inside an application service — .call, not .call!
internals.charge_result = PaymentGateway::API::Charges::Create.call(...)
# Then handle the result manually
internals.charge_result
.on_success { handle_success! }
.on_failure { handle_failure! }
This gives the application service full control over how to react to an API failure — retry, log, mark the entity with a specific status, or propagate the error up with fail_result!.
Service composition
Application services can call other application services, and the same convention applies. If a service needs to delegate to another application service that must succeed, it uses .call!:
class Payments::Process < ApplicationService::Base
input :payment, type: Payment
make :create_charge!
make :send_notification!
private
def create_charge!
Payments::Charge.call!(payment: inputs.payment)
end
def send_notification!
Notifications::Payment::Completed.call!(payment: inputs.payment)
end
end
The notification service might itself call a Slack or Telegram API service internally — again with .call — but from the perspective of Payments::Process, it's just another step that either succeeds or raises.
What stays where
To keep the boundaries clean, I follow a simple rule:
API services can only depend on the API client, a cache (for tokens), and configuration. They never import models, never call jobs, never reference anything specific to your application domain.
Application services can depend on anything in the project — models, jobs, other application services, API services, caches, configuration. They're the orchestration layer.
This constraint makes API services portable. If you extract a payment gateway integration into a separate gem or engine tomorrow, the API service layer comes out cleanly because it has no ties to your application. The application services stay behind — they're the glue between your domain and the external world.
File structure
In a Rails app, the directory layout mirrors the separation:
app/services/
├── application_service/
│ └── base.rb
├── payment_gateway/
│ └── api/
│ ├── base.rb
│ ├── auth/
│ │ └── generate_token.rb
│ ├── charges/
│ │ └── create.rb
│ └── refunds/
│ └── create.rb
├── payments/
│ ├── charge.rb
│ └── process.rb
└── notifications/
└── payment/
└── completed.rb
API services live under their provider namespace with an api/ layer. Application services live under their domain namespace. The structure itself tells you what talks to the outside world and what stays internal.
Getting started with Servactory
If you want to try this pattern, add Servactory to your Gemfile:
gem "servactory"
Then generate the base class:
rails g servactory:install
The generator creates ApplicationService::Base with customizable exception classes, result class, and configuration. From there, you build your API and application service layers on top.
Links
- 📚 Documentation: servactory.com
- 💻 GitHub: github.com/servactory/servactory
- 💎 RubyGems: rubygems.org/gems/servactory
This is the pattern I use in production across multiple projects. It scales well — from a single API integration to dozens of providers, the structure stays the same. If you've been wrestling with service objects that do too much, try splitting them into these two layers. The clarity is immediate.
I'd love to hear how you structure API integrations in your projects — especially if you've found a different approach that works. Let me know in the comments.
Top comments (0)