Rails Service Objects: Keeping Domain Logic Out of Rails Models and Controllers

Learn how service objects keep Rails codebases maintainable at scale. Separate domain logic from models and controllers to speed delivery and onboarding.

Last Updated: November 27th 2025
Software Development
13 min read
Verified Top Talent Badge
Verified Top Talent
Silvio Orta
By Silvio Orta
Software Engineer15 years of experience

Silvio is a senior software engineer with 15+ years of experience building scalable web applications. He founded and managed Amaxonia ERP for over five years and has delivered solutions for government organizations.

Stylized Ruby on Rails icon with growth chart and branching arrows, symbolizing scalable model architecture and efficient data routing in Rails applications.

When a Rails team hits 10 developers, velocity drops. Features that took 2 weeks now take 5. Bugs in checkout break user profiles. New hires need months to ship confidently. The culprit? Business logic tangled into models and controllers.

In this article, we identify the symptoms of mixing business logic into Rails’s implementation of MVC, call out the implications, and enumerate practical solutions to keep core Rails code separate from domain logic. Clearly delineated code speeds development, debugging, and delivery.

MVC in Rails: A Quick Refresher

Rails implements the Model-View-Controller design pattern (or MVC) in Ruby. MVC divides the logic of an application into three concerns: 

MVC diagram showing how Rails models manage data, views present output, and controllers route requests—core to object oriented code organization.

  • Models represent items in the system
  • Views present information and collect input 
  • Controllers liaise between the user or API to facilitate change

For example, User is an oft-used model to represent an identity. A login and checkout form are common views. A prospective OrderController receives requests, creates and modifies orders as directed, affects model state, and presents an updated view in response.

While MVC describes logical boundaries for implementations and Rails provides a closely-aligned framework to organize code into the three concerns, neither MVC nor Rails enforces separation.

Further, MVC provides no unique concern for business logic, or those processes, steps, and rules the enterprise wants to express as an application. Without vigilance, it is easy for developers to blur the distinctions between M, V, and C, and worse, disperse domain logic throughout each concern. 

Fat Models & Controllers and Sprawling Views

In Ruby on Rails, a concern is considered overloaded or “fat” if it contains any code or logic unrelated to its intended and specific role. 

For example, a Rails model is fat if it does any of the following: 

  • Orchestrates the creation or mutation of other models
  • Transforms itself into a different model or object 
  • Provides helper functions to render a view
  • Embeds a business rule not specific to its responsibility

Similarly, a Rails controller is fat if it:

  • Contains any business logic
  • Constructs extensive context to render a view
  • Executes a long-running computation
  • Calls a third-party API or an internal API with moderate or long latency

A view is fat if it: 

  • Performs any domain logic 
  • Depends on external state information not provided by the controller 
  • Mutates any model or causes any side-effects 
  • Queries a data source

Each case violates the Single Responsibility Principle (SRP), a software development tenet that holds that each unit of code should be purpose-built for a solitary task. Put another way, each unit of code—here, a Ruby class or subclass of a Rails class—should perform one job well, “[collecting] the things that change for the same reasons.” 

For instance, consider the role of a model. If a model’s justification is solely to read and write valid records from a specific table in the database, creating a record in another table should be considered far out of its bounds. 

Rails’s many powerful tools, especially model validations and life-cycle callbacks, seemingly encourage conflation of responsibilities. Rails teams must resist. The more expansive a unit of code becomes, the more difficult it is to comprehend, write tests for, and maintain. For stateless utilities a class method can work.

An Overly Ambitious Model

Let’s look at an example of a Rails model that’s too extensive and how to refactor the code to make it better conform to object-oriented programming principles and the SRP. 

The Rails model code below represents an end-user in a system. It enforces a number of data consistency rules via Rails validations. To avoid reinventing authentication, password encryption, and secure tokens, it leverages Rails’s own `has_secure_password` and `has_secure_token`. 

class User < ApplicationRecord
  has_secure_password 
  has_secure_token :activation_token


  # Validations
  validates :birth_date, presence: true
  validates :email, presence: true, uniqueness: true
  validate :strong_password
  validate :age_must_be_over_18

  # Scopes
  scope :active, -> { where.not(activated_at: nil) }


  # Business Logic
  def activate!
    update(activated_at: Time.current)
  end

  def activated?
    activated_at.present?
  end

  def full_name 
    [first_name, middle_name, last_name].compact.join(' ')
  end 

  private

  def strong_password
    unless password =~ /(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}/
      errors.add(:password, "must be at least 8 characters and include upper/lowercase letters and a number")
    end
  end

  def age_must_be_over_18
    if birth_date.present? && birth_date > 18.years.ago
      errors.add(:birth_date, "You must be at least 18 years old")
    end
  end
end

Considering the characteristics of a fat model listed in the previous section, it’s plain this code is too broad and violates the single responsibility principle in three ways. 

  • The Rails validation named `strong_password` enforces what characters a password may contain. This implements business logic not germane to the persistence of user data, and is likely subject to change. Additionally, if the application has another form of user, such as an Administrator, the `strong_password` validation would likely apply to both. The validation is best extracted and made central as domain policy.  
  • Similarly, the validation `age_must_be_over_18` is another business rule better enforced by the domain, not the User model. It too is a candidate for separation. 
  • While `full_name` is computed directly from the attributes of the model (assuming `first_name`, `middle_name`, and `last_name` are columns) and is useful, it is orthogonal to reading and writing records to the underlying table. This method is best relegated to a decorator class (sometimes called a presenter class).  

Refactoring the Model for Better Intent

Extracting the four methods listed above from the User model would narrow its purpose. Moreover, and adhering to the tenet of the SRP, it’s best to create multiple new Ruby classes: one to enforce the business rules for `User`, one to decorate the model to display a full name, and another to generate a legitimate activation token. 

All provide a self-contained service. Indeed such classes are commonly referred to as service classes or service objects and sport names reflective of each object’s intent, such as `SaveUser` and `DecorateUser`. This is the service object pattern we use to keep domain logic out of models and controllers.

Omitting the latter two classes for brevity, `SaveUser` might be written as concisely as: 

class SaveUser
  attr_reader :user 


  def initialize(user)
    @user = user 
  end

  def save
    valid? && user.save
  end

  private

  def valid? 
    user.valid? && 
      Password.valid?(user.password) && 
      Age.valid?(user.birth_date)
  end
end

This code assumes password and age validation are performed by domain methods `Password.valid?` and `Age.valid?`, respectively, implemented as class public methods. Push steps into private methods so orchestration remains readable.

`SaveUser` now has a singular purpose: Save a `User` record if it passes the model’s own Rails validation rules and the business’s rules for password composition and age. Most services expose a simple entrypoint such as def call to coordinate the workflow.

Given `SaveUser` exists as a Rails service object, the User model can be refocused and narrowed. Controllers invoke a single call method so actions stay small and testable.

class User < ApplicationRecord
  has_secure_password 
  has_secure_token :activation_token


  # Validations
  validates :birth_date, presence: true
  validates :email, presence: true, uniqueness: true

  # Scopes
  scope :active, -> { where.not(activated_at: nil) }

end

In a full refactor, the activation logic is moved to a dedicated service object, and the status check is handled via the scope or a decorator. The model remains lightweight and implements no business rules.

Applying the Pattern to Controllers and Views

Refactoring the model is only the first step. When developers aggressively clean up models, the displaced business logic often tries to migrate into Controllers or Views. To maintain a truly clean architecture, we must apply the same separation of concerns to the rest of the MVC stack.

The Burdened Controller 

Controllers often become dumping grounds for logic that doesn’t fit neatly into a model. For example, a “fat” checkout controller might attempt to process payments, update inventory, and trigger email notifications all within a single method. This makes the controller difficult to test and ties the request handling logic too tightly to backend processes.

To fix this, we move the orchestration into a Service Object (e.g., ProcessOrder). The controller returns to its primary job: handling the HTTP request, calling the service, and rendering the appropriate response based on success or failure. The controller no longer needs to know how a payment is processed, only that it was processed.

The Sprawling View 

Views should be “dumb,” as their only job is to present data. A “fat” view often violates this by including logic to query the database (e.g., counting orders to see if a customer is a VIP) or complex formatting rules. If the definition of a VIP changes, developers are forced to hunt through HTML templates to update the logic.

We can clean this up using the Decorator pattern (also known as a Presenter). A Decorator wraps the model and encapsulates the logic for presentation. Instead of the view calculating VIP status, it simply asks the decorator for the answer. This keeps the view file clean and semantic, while the logic remains testable in a plain Ruby object.

Objects for Everything Else

While Rails is based on the MVC pattern (as implemented by the classes `ApplicationRecord` and `ActionController::Base` and the module `ActionController::Rendering`, respectively), a Rails application is not limited to objects representing database tables, user interfaces, and controllers.

Rails itself includes specialized classes and modules to send and receive email, manage background jobs, and provide a consistent global identifier for every record.) 

Rails application code can be extended to include any number of custom Ruby classes and modules. The service class `CreateUser` is just a Plain Old Ruby Object (PORO) tailored to coordinate and implement a specific use case within the application. Keep domain objects separate from the data layer to preserve SRP.

In addition to orchestration, a PORO can control access, filter data, and compose other objects. Use value objects for immutable concepts like Money or Email.

  • Authorization: It’s common for a multi-user application to limit access to processes based on a user’s identity, role, or express permissions. For example, editing a policy might be limited to only users with a valid license. A policy object can encapsulate access rights. Given a user, the object can return a set of permissible operations. 
  • Scope: Akin to authorization, a multi-user or multi-tenant application typically limits access to data. Files belonging to one organization cannot be cataloged, read, or modified by another organization. Purview in Rails is called a scope. Given an entity—perhaps a user, organization, or even another server—a scope object can compute the set of readable records. 
  • Aggregation: Every application mutates its data. Data is displayed via a form or an API response and is acted upon by an end-user or an API request. Simple Create-Read-Update-Delete (CRUD) operations may align a model closely to a single form or API request, but those are exceptional cases. Single-page web applications (SPAs) and mobile apps routinely consume amalgams of data for efficiency. The request generated by a complex form might affect multiple models. 

A form object can be crafted and shaped to fit specific input or output requirements. As an example, consider a form to file an auto insurance claim. It would  contain driver, location and vehicle information for ease-of-use. A PORO object, perhaps named AutoInsuranceClaimForm, could capture all the fields. (Another PORO service object would transform and persist the data into the proper tables.)

  • Inquiry: Another use of input is to search. A query object represents search parameters and results. Think of a query object as a kind of liaison between criteria and one or more models. 

Both a form object and a query object can also validate parameters and verify access (using policy objects), keeping controllers “skinny”. 

Not every use case requires its own set of custom supporting objects. In fact, given an amassed collection of fine-grained PORO actors, complex sequences can be constructed by combining, interweaving, and composing existing objects. 

Good Governance is as Important as Good Code

Rails famously prioritizes convention over configuration (CoC). Rails has plenty of options to fine-tune application behavior, to be sure (it supports many databases, supports multiple deployment environments, and provides for structured logs, among others), but the overall structure of every Rails application is the same. 

  • Each Rails application has its own “root” directory somewhere in the file system
  • Within the project root, the subdirectory app/models contains model classes (the M of MVC); app/controllers contains controllers classes (the C); and app/views contains the project’s layouts and templates (the V)
  • Within the Rails root, the subdirectory config contains YAML and Ruby files to customize the behavior of Rails
  • Files inside db defines the application’s database schema 
  • test or spec, for Minitest and RSpec, respectively, contain automated tests. 

There are other standard directories, and a team can extend the Rails foundation to add its conventions. As an example, many teams put decorator objects in app/decorators

The namespaces and class names used for local practices are as important as the use of conventions. Organize folders and files into hierarchies to reflect responsibilities. Good governance of a source repository makes it easy for developers to navigate and read existing code and review and critique new code. 

Best Practices for Rails Applications

First and foremost, mind the Single Responsibility Principle. Measure your code against it and refactor to keep your classes and modules focused, short, and reusable. 

Here are some additional simple rules to improve the maintainability of Rails applications. Each rule can be adopted immediately for new code and retrofitted to existing code as ongoing refactors.

Hexagon diagram of Rails practices: service object pattern, thin controllers, restrict models, simple query scopes, database-level constraints; value objects.

  • Narrowly restrict a model to only save, read, and update operations, including state, validation, and normalization. 
  • Keep scopes simple within each model, restricting those scopes to columns defined solely in the model. 
  • Enforce Rails uniqueness constraints, manage foreign keys, and speed queries using database indices wherever possible. 
  • Separate a model’s presentation-specific logic into its own decorator
  • Limit each controller to read and validate a request and assemble a response. 
  • Delegate all domain logic—input processing, transactions, and interaction with other systems—to either a service object or a Plain Old Ruby Object (PORO). For stateless utilities a class method can work.

Ride Ruby on Rails

Rails is an incredibly powerful, robust, performant, and stable development platform. Like Ruby itself, Rails makes developers happy and productive. 

Clean architecture pays dividends when you’re scaling a team or accelerating delivery. Service objects and POROs reduce onboarding time, limit the blast radius of changes, and keep feature work moving in parallel. The upfront discipline of separation prevents the technical debt that slows teams down after a few quarters.

Combine Ruby, Rails, the Single Responsibility Principle, and a modicum of vigilance to make the entire extant and future development organization happy and productive.

Frequently Asked Questions

  • Separation of concerns enforces clear ownership and boundaries between persistence, presentation, and business rules. As the code base or team grows, well-defined responsibilities prevent code overlap and conflicting changes. Clear delineation of roles also makes onboarding faster and parallel development safer, especially when multiple squads contribute to the same product.

  • When models, controllers, or views become “fat,” changes in one area can break unrelated features. Testing becomes complex, and every release carries higher regression risk. Clean separation limits the surface area of impact and code duplication, making it easier to deliver new features or bug fixes without destabilizing core functionality. 

  • POROs are lightweight classes built to handle a specific business function, such as saving a user or processing an order. They’re easier to test as a unit and reuse, and evolve independently of Rails conventions. For large code bases, this modularity supports cleaner abstractions and fewer interdependencies.

  • Establish a consistent hierarchy early, one that mirrors your domain. Create directories for service objects, decorators, and policies under the app/ namespace. Use clear naming conventions and enforce them through code reviews. A predictable structure reduces cognitive load and makes it easier for new engineers to find, read, and trust the codebase.

  • Adopt automated tools like RuboCop for code style and Reek for code smell detection. Reinforce patterns through peer review and architecture documentation. Finally, treat code organization as part of engineering governance. Assess it regularly, not just when something breaks. Encourage teams to use the pull request review process to enforce conventions. 

Verified Top Talent Badge
Verified Top Talent
Silvio Orta
By Silvio Orta
Software Engineer15 years of experience

Silvio is a senior software engineer with 15+ years of experience building scalable web applications. He founded and managed Amaxonia ERP for over five years and has delivered solutions for government organizations.

  1. Blog
  2. Software Development
  3. Rails Service Objects: Keeping Domain Logic Out of Rails Models and Controllers

Hiring engineers?

We provide nearshore tech talent to companies from startups to enterprises like Google and Rolls-Royce.

Alejandro D.
Alejandro D.Sr. Full-stack Dev.
Gustavo A.
Gustavo A.Sr. QA Engineer
Fiorella G.
Fiorella G.Sr. Data Scientist

BairesDev assembled a dream team for us and in just a few months our digital offering was completely transformed.

VP Product Manager
VP Product ManagerRolls-Royce

Hiring engineers?

We provide nearshore tech talent to companies from startups to enterprises like Google and Rolls-Royce.

Alejandro D.
Alejandro D.Sr. Full-stack Dev.
Gustavo A.
Gustavo A.Sr. QA Engineer
Fiorella G.
Fiorella G.Sr. Data Scientist
By continuing to use this site, you agree to our cookie policy and privacy policy.