đź§ Refactoring Rails: From Service Objects to Clean Maintainalbe Code
When building real-world Rails apps, it’s common to hit the limits of traditional service objects. Here’s a practical breakdown of what you need to know — and what to do instead — based on our recent conversation.
âť“ Should I use .new or .for for services?
Use .for(config) when your service needs to select a subclass (e.g., inbound vs. outbound logic).
âś… Why .for is better here:
- It acts as a factory, returning the correct subclass.
- Avoids messy branching in controllers or the initializer.
- Keeps your API consistent (
.fetch_and_persist_callsstays the same). - Used in Rails itself (
ActiveStorage::Service.for) and by trusted teams (like Thoughtbot).
Example:
Nlpearl::ApiService.for(config)→ returnsInboundServiceorOutboundServicebased on direction.
❓ What does “Instance Methods and Associations” mean?
Instead of putting logic in a service object, you can:
- Add instance methods directly to your model
- Use ActiveRecord associations to access related data
- Encapsulate logic where it belongs (next to the data)
âś… Benefit: Calling
order.checkoutis more natural thanCheckoutService.new(order).call.
âť“ What if my model becomes too fat?
According to this article, when a model gets too bloated:
👇 Do this instead of writing service objects:
- âś… Extract behavior into Concerns
- âś… Use POROs (Plain Old Ruby Objects)
- âś… Add meaningful instance methods
- âś… Rely on ActiveRecord associations to reduce logic complexity
- đźš« Avoid cramming everything into
.callservice objects unless absolutely needed
❓ Aren’t POROs and service objects the same?
No — but they overlap.
- PORO = Any plain Ruby class (e.g.,
Money,Coordinate,MessageParser) - Service object = A PORO used for a specific process (e.g.,
CreateBooking,SyncTickets) - Not all POROs are service objects, but all service objects are POROs.
✅ Tip: Don’t overuse
.call. Break logic into small POROs or model methods where possible.
âś… Summary
| Situation | Best Practice |
|---|---|
| Selecting between inbound/outbound | Use .for(config) factory method |
| Adding call logic | Use model_instance.checkout instead of service |
| Fat models | Extract to Concerns or POROs |
| Business process | Small, focused service object (only if needed) |
| General reusable logic | Use POROs (not necessarily service objects) |