Rails Service Object Patterns
Analyze and recommend patterns for extracting and organizing business logic in Rails applications.
Quick Reference
| Pattern | Use When | Entry Point |
|---------|----------|-------------|
| Basic Service | Single operation with transaction | CreateOrder.new(...).call |
| Result Object | Caller needs success/failure + data | Result.new(success?: true, data:) |
| Form Object | Multi-model form submissions | RegistrationForm.new(params).save |
| Query Object | Complex reusable queries | UserSearchQuery.new(scope).call |
| Policy Object | Authorization decisions | PostPolicy.new(user, post).update? |
Supporting Documentation
- patterns.md - Result objects, form objects, and profile-aware guidance
Core Principles
- VerbNoun naming:
CreateOrder,SendInvitation-- neverOrderServiceorUserManager - One public method: Expose only
call(orperform) - Explicit return values: Use Result objects, never exceptions for expected flow control
- Profile-aware extraction: See "When to Extract" below
When to Extract a Service (Profile-Dependent)
| Scenario | Omakase | Service-Oriented / API-First |
|----------|---------|------------------------------|
| Logic on a single model's own data | Model method or concern | Model method |
| Shared behavior across models | Concern | Concern |
| Domain logic for one model | Concern | Service object |
| Multi-model workflow with rollback | Model method + transaction | Service object |
| External API call | Model method wrapping client | Service object |
| Simple side effect (email, log) | Callback (after_commit) | Service object |
Omakase: Only extract to a service when the workflow genuinely spans multiple unrelated models or external systems. Prefer concerns and enriched model methods.
Service-oriented / API-first: Service objects are the default extraction target for any non-trivial business logic.
Result Object Pattern
Use Struct.new(keyword_init: true) for lightweight results. Never raise exceptions for expected failures (validation, auth, payment decline).
class AuthenticateUser
Result = Struct.new(:success?, :user, :error, keyword_init: true)
def initialize(email:, password:)
@email = email
@password = password
end
def call
user = User.find_by(email: @email)
if user&.authenticate(@password)
Result.new(success?: true, user: user)
else
Result.new(success?: false, error: "Invalid credentials")
end
end
end
See patterns.md for the enhanced monad-like ServiceResult with on_success/on_failure chaining.
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|-------------|---------|-----|
| God service (100+ lines) | Does too much | Split into composable services |
| Raising exceptions for flow control | Expensive, hard to handle | Use Result objects |
| Deep service-calls-service chains | Hidden coupling | Orchestrate from controller or coordinator |
| self.call class method pattern | No instance state, limits DI | Use instance methods with constructor DI |
| No return value | Caller can't react to failures | Always return Result or meaningful value |
| Service modifying passed-in objects | Surprising side effects | Return new objects or be explicit |
| VerbNoun naming violation (UserService) | Unclear responsibility, attracts god service | One service = one operation = one verb |
Output Format
When analyzing or creating services, provide:
- Service file in
app/services/with VerbNoun naming - Result struct if callers need success/failure status
- Controller integration showing how to call and handle results
- Test outline covering happy path, failure cases, and edge cases
- Error handling strategy (Result objects for expected failures, exceptions for unexpected)