Using Ruby Model Classes, Service Objects and Interactors

5 minute read

Using a model class (even a simple PORO in app/models) for features has several advantages in Rails. This is why 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 is placed 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).

Example: Instead of:

if user.admin? && SomeConfig.beta_enabled?

You do:

if BetaAccess.allowed_for?(user)

✅ 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 has logic that may grow, models keep it in one place.

Example: onboarding flow:

class Onboarding
  def completed?(user)
    user.profile_filled? && user.verified? && user.tutorial_done?
  end
end

If later you add new onboarding rules — you update one class.


✅ 5. Better naming + improved readability

A dedicated model communicates intent clearly:

if FeatureFlag.enabled?(:new_ui)

is more readable than:

if Rails.configuration.x.new_ui_enabled

or random constants.


✅ 6. Supports persistence easily later

You might start with:

class FeatureFlag
  FLAGS = { new_ui: false }
end

Later decide to store flags in DB:

class FeatureFlag < ApplicationRecord
end

Same interface, no major changes in the rest of the app.


✅ 7. Integrates cleanly with Pundit / CanCan / services

A model class naturally fits into authorization and service layers.

Example:

class FeatureFlagPolicy < ApplicationPolicy
  def enable?
    user.admin?
  end
end

This wouldn’t be as clean if the logic lived in a helper or initializer.

Why use model classes for features?

Using a model (ActiveRecord or PORO) gives you:

  • Clean architecture
  • Centralized logic
  • Reusability
  • Easier testing
  • Readable code
  • Extensibility if the feature grows
  • Smooth integration with Pundit and other layers

What about Service Object?

You can use a service or other patterns. A model class is not the only valid approach.

The reason some teams choose a model instead of a service depends on what kind of logic they are modeling.

Here’s a clear breakdown.


✅ Why not a service? (When a service is the wrong fit)

1. Services are for actions, not state

A service object usually represents something the system does:

  • SendEmail.new.call
  • ImportCsv.new.call
  • CreateSubscription.new.call

Services are “verb-like.”

Feature flags (or similar domain rules) are not actions. They’re state + rules about state.

Example:

FeatureFlag.enabled?(:new_ui)

A service would feel unnatural:

FeatureFlagChecker.new(:new_ui).call

You’re not doing anything; you’re querying domain state.


2. Services don’t naturally hold reusable domain logic

If you put your logic in a service, you often end up with this anti-pattern:

  • One service to check feature flags
  • Another to read them
  • Another to list them
  • Another to validate something
  • Another for authorization

The logic becomes scattered.

A model consolidates it.


3. Services can’t be easily plugged into Rails tools

Rails gives you conveniences for models:

  • Cleaner testing
  • Namespacing (app/models)
  • Integration with Pundit
  • Lazy loading / autoload
  • DB migrations if you later add persistence

Services don’t get these benefits.


✅ Why a model is often the better fit

1. It represents a domain concept

Feature flags, onboarding rules, eligibility rules, user states — these aren’t actions. These are entities in your domain.

Rails convention: entities → model classes.

Example domain models that aren’t ActiveRecord:

  • Authenticator
  • Cart
  • Checkout
  • Onboarding
  • SubscriptionRules
  • FeatureFlag

Rails devs often keep PORO domain models in app/models.


2. Models provide a clear, consistent API

Models give you intuitive, domain-driven APIs:

FeatureFlag.enabled?(:new_ui)
Eligibility.for(user).allowed?
Onboarding.completed?(user)
PlanPrice.for(:premium)

Services… not so much:

FeatureFlagService.new(:new_ui).enabled?
EligibilityService.new(user).allowed?
OnboardingService.new(user).completed?

This is more verbose, less “domain-sounding.”


3. Models grow better over time

Feature flags start simple but later evolve:

  • per-user access
  • groups / roles
  • rollout percentages
  • time-based enabling
  • database storage
  • admin UI
  • audit logs

Models evolve cleanly.

Services become spaghetti when they accumulate state + logic.


🆚 So when would a service be better?

A service is appropriate if:

  • The feature is about executing a task
  • Something that has a start and end
  • It produces a result or side effect

Examples:

  • GenerateReport
  • ChargeCreditCard
  • SendWelcomeEmail

Not good for:

  • feature flags
  • eligibility checks
  • rules
  • state machines
  • business constraints

Those map better to models.


What is an Interactor?

In Rails (especially with gems like ActiveInteractor or Interactor), an Interactor is a pattern for encapsulating a single unit of business logic — usually a transactional action that may:

  • Take input
  • Perform a multi-step operation
  • Return a result (success/failure)
  • Handle errors cleanly

Think of Interactors as “coordinators of actions”, often orchestrating multiple models and services.

Example:

class CreateOrder
  include Interactor

  def call
    order = Order.create!(context.params)
    PaymentProcessor.charge(order)
    NotificationMailer.order_created(order).deliver_later

    context.order = order
  rescue StandardError => e
    context.fail!(error: e.message)
  end
end

2️⃣ 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 or 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

3️⃣ How Interactors Fit Between Models and Services

  1. Models → hold state and domain logic

    FeatureFlag.enabled?(:new_ui)
    
  2. Services → perform actions related to one model or domain

    PaymentProcessor.charge(order)
    
  3. 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 (one feature like a door or wheel)
  • Interactor = the full Lego set (puts multiple creations together into a working system)

4️⃣ When to use Interactors vs Service vs Model

  • Use a Model: for state, rules, calculations, or queries
  • Use a Service: for single actions that act on models
  • Use an Interactor: for multi-step workflows that may fail and need clean orchestration

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