Using Ruby Model Classes, Service Objects and Interactors
Source: Dev.to
Why Use a Model Class for Features?
Using a model class (even a simple PORO in app/models) for features has several advantages in Rails.
Developers often prefer “a model class” over scattering logic in controllers, helpers, or initializers.
Below are the key advantages:
1. Centralized, reusable logic
If a feature’s logic lives in a model class (e.g., FeatureFlag, BetaAccess, Onboarding), you can reuse it from:
- Controllers
- Views
- Background jobs
- Services
- Pundit policies
…instead of duplicating the logic in multiple places.
2. Keeps controllers and views clean
Rails controllers and views should stay thin.
Putting domain logic in a model keeps your design clean (Fat Model, Skinny Controller).
Instead of:
if user.admin? && SomeConfig.beta_enabled?
# …
end
Do:
if BetaAccess.allowed_for?(user)
# …
end
3. Better testability
Models are the easiest to test:
RSpec.describe BetaAccess do
describe ".allowed_for?" do
# …
end
end
No need to spin up controllers or simulate web requests.
4. Encapsulation of rules
If your feature’s logic may grow, a model keeps it in one place.
class Onboarding
def completed?(user)
user.profile_filled? && user.verified? && user.tutorial_done?
end
end
Add new onboarding rules later – just update the class.
5. Better naming + improved readability
A dedicated model communicates intent clearly:
if FeatureFlag.enabled?(:new_ui)
# …
end
…is more readable than:
if Rails.configuration.x.new_ui_enabled
# …
end
6. Supports persistence easily later
You might start with a simple PORO:
class FeatureFlag
FLAGS = { new_ui: false }
end
Later you can switch to an ActiveRecord model without changing the public interface:
class FeatureFlag e
context.fail!(error: e.message)
end
end
Models vs. Services vs. Interactors
| Concept | Responsibility | Examples |
|---|---|---|
| Model | Represents domain state & rules; encapsulates attributes & behavior | FeatureFlag, User, Subscription |
| Service | Performs a discrete action; can use multiple models | PaymentProcessor, EmailSender |
| Interactor | Orchestrates a workflow/transaction using models & services; handles success/failure | CreateOrder, SendWeeklyReport, EnrollUserInCourse |
Key difference
- Services = do one thing.
- Interactors = orchestrate multiple things as a single business operation.
How Interactors Fit Between Models and Services
-
Models → hold state and domain logic
FeatureFlag.enabled?(:new_ui) -
Services → perform actions related to one model or domain
PaymentProcessor.charge(order) -
Interactors → coordinate multiple models and services into a single, transactional workflow
CreateOrder.call(params: order_params)
Analogy
- Model = the Lego bricks
- Service = a single Lego creation (e.g., a door or wheel)
- Interactor = the full Lego set (puts multiple creations together into a working system)
When to Use Interactors vs. Services vs. Models
| Use case | Recommended pattern |
|---|---|
| State, rules, calculations, or queries | Model |
| Single actions that act on models | Service |
| Multi‑step workflows that may fail and need clean orchestration | Interactor |
Example Workflow
# Model
class User; end
class FeatureFlag; end
# Service
class WelcomeEmailSender; end
# Interactor
class OnboardNewUser
include Interactor
def call
user = User.create!(context.params)
WelcomeEmailSender.send(user)
context.success_message = "Welcome #{user.name}!"
rescue => e
context.fail!(error: e.message)
end
end
Summary
| Pattern | Best for | Bad for |
|---|---|---|
| Model (PORO or ActiveRecord) | Domain concepts, rules, states | One‑time actions |
| Service | Executable actions (“do X”) | Representing domain objects |
| Initializer / config | Static rules | Rules that may grow or need dependencies |
| Interactor | Orchestrating multi‑step workflows / transactions | Single‑purpose state or simple rules |
Originally posted at DevBlog.