Design Principle: Composable Services
Composable Services
Services are small, predictable units that can be run in isolation, invoked directly, or chained via workflows. Each service has a uniform interface: it accepts input as a hash and returns aSuccess()
orFailure()
result. This consistency allows them to be reused, composed, and eventually extracted into serverless or distributed systems.
When I started building the foundation for Looping, I wanted to avoid the complexity and hidden coupling that creeps into large systems over time. A big part of that is making sure each part of the system can stand on its own, without relying on implicit behavior or unpredictable interfaces.
That’s where composable services come in. They’re part of a set of design principles I defined early on to guide Looping’s architecture. These principles are meant to promote clarity, maintainability, and long-term flexibility as the system grows.
Each service in Looping is a small, focused unit that does one thing. It accepts input, runs logic, and returns a clear result. That’s it. Just a consistent shape: hash input in, Success()
or Failure()
out.
This lets services be reused across features, chained into larger workflows, or extracted into separate systems altogether if needed. That composability isn’t an afterthought, it’s the core design.
A Uniform Interface
All services in Looping inherit from ApplicationService
, which enforces a few key patterns:
- Every service is invoked via
Class.call(payload)
- The payload is always a plain hash
- The result is always a Dry::Monads::Result, either
Success(value)
orFailure(error)
- The call method is private by design
By keeping the interface strict and consistent, services remain interchangeable and easy to test in isolation. Here’s the relevant part of the base class:
class ApplicationService
include Dry::Monads[:result, :do]
def self.call(payload)
new(payload).send(:call)
end
def initialize(payload)
Current.sources ||= []
Current.sources << self.class.to_source
@errors = []
@payload = payload
payload.each do |key, value|
instance_variable_set("@#{key}", value)
end
end
end
app/services/application_service.rb
There’s more under the hood, including automatic error handling, rollback helpers, method privacy enforcement (reach out if you're interested in more details), but the important part is the interface. Services are predictable and that consistency makes them composable.
Example: Auth Flows as Service Chains
Here’s a real example from Looping: a user signs in with an email and password. That flow touches three services, each with a single responsibility:
EmailService::Normalizer
strips and downcases the emailUserService::Authenticator
checks credentialsSessionService::Creator
creates a session
Each one returns either a Success(value)
or Failure(message)
. Each one can be run in isolation. And none of them knows—or cares—what happens next.
module EmailService
class Normalizer < ApplicationService
def call
return Success(@email) if @email.nil?
normalized_email = @email.strip.downcase
Success(normalized_email)
end
end
end
app/services/email_service/normalizer.rb
The Normalizer
is called from the UserService::Authenticator
:
module UserService
class Authenticator < ApplicationService
def call
result = EmailService::Normalizer.call(email: @email)
return Failure('Invalid email or password') unless result.success?
normalized_email = result.value!
user = User.find_by(email: normalized_email)
if user && (BCrypt::Password.new(user.password_hash) == @password)
Success(user)
else
Failure('Invalid email or password')
end
end
end
end
app/services/user_service/authenticator.rb
Which is called from the SessionService::Creator
:
module SessionService
class Creator < ApplicationService
def call
result = UserService::Authenticator.call(email: @email, password: @password)
return result unless result.success?
user = result.value!
prepare_session(user)
end
end
end
app/services/session_service/creator.rb
This layering is intentional. Normalizing an email is its own concern. Authentication is its own concern. So is session creation. But they compose easily because they follow the same rules.
Tracking What Ran (and Why)
If you looked closely at the base ApplicationService, you might have noticed this during initialization:
Current.sources ||= []
Current.sources << self.class.to_source
Every time a service runs, it appends its class name to a shared Current.sources
stack.
This isn’t just for curiosity. It’s a lightweight way to track what code paths executed during a request, which is especially useful when services are composed. That means if SessionService::Creator
calls UserService::Authenticator
, which calls EmailService::Normalizer
, the full chain is captured automatically.
This shows up in request logs, in error reports, and—most importantly—in event logs. It provides insight into how a given action came to be, and what pieces were involved.
I’ll cover that more in the next Design Principle post, which is all about Automatic Event Logging. But the key idea here is: composability isn’t just for code reuse. It also gives you clean, structured observability for free, without littering every service with extra logging logic.
Why It Matters
I’ve worked in codebases where every flow was a nested chain of conditionals and inline logic. It’s hard to refactor, harder to test, and nearly impossible to reuse.
And when someone needs to add an edge case or build a new feature on top of what’s there? It just becomes another branch in an already tangled flow. Over time, what started as a clean path turns into a maze of special cases.
Composable services prevent that from spiraling out of control. Instead of adding “just one more conditional,” you extract a service. Instead of wiring logic together inline, you compose it intentionally. The cost of doing it right stays low, so the right thing actually gets done.
When every service has a consistent shape, does one thing, and returns a clearly typed result, then it’s easy to build higher-order operations by composing lower-level ones.
Want to extract part of the logic into a serverless function later? Easy. Want to dry-run a change without side effects? You can, because each service is self-contained.
This approach isn’t just about clean code. It’s about long-term flexibility and building a system that can adapt as your product grows.