@davetron5000 suggested this might be an interesting question for the Rails Developer Survey, and I totally agree, so taking this as an opportunity to learn more about what others are doing.
What approaches have you used to handle business logic in Rails or other Ruby frameworks?
What has worked/not worked for you? What are the trade-offs?
Iāve mostly stayed with the āboringā path: fat models, thin controllers, and service objects only when things get complex enough to justify it.
The pattern I keep coming back to is just plain Ruby objects in an app/services or app/operations directory when a piece of logic doesnāt naturally belong to a model. No base classes, no DSL, just objects with a single public method. Easy to test, easy to delete.
Iāve tried Form Objects, Interactors, and Trailblazer at different jobs. They all solve real problems, but they also add a layer of indirection that new team members have to learn. The maintenance cost is often underestimated.
What hasnāt worked: putting too much trust in callbacks. They feel convenient early on but make behavior hard to trace, especially in import/migration contexts where you want more explicit control over what runs and when.
The trade-off Iāve come to accept: sometimes a fat model is actually fine. The urge to extract everything into a service layer can be premature. Context matters a lot.
If youāve read my book Sustainable Rails, it outlines what I have done and had a lot of success with: a service layer comprised of cohesive classes, each with one or more stateless methods.
(The āone class one methodā approach is extremely limiting and can be very confusing at even moderate scale.)
As an example of my approach, Iād have a class whose job is to create widgets. Day one of the app, this class seems very silly as itās called WidgetCreator and has a method called create_widget that probably just calls Widget.create. The point, though, is this is a seam that will persist for the duration of the app.
Changes to the app over time would never need to alter this methodās signature or the classā name.
If widget creation becomes complex, create_widgetās internals grow as needed.
If there are new ways to create widgets, there would be new methods of WidgetCreator.
If the ways of creating widgets need to share logic, you use private methods within WidgetCreator.
If widget creation becomes the sole center of gravity for the app, you can turn WidgetCreator into a namespace with more classes as needed.
This approach is:
conceptually simple
difficult to mess up/does not generate massive architectural debate once accepted
allows for reliable and rudimentary re-use through functional decomposition
can scale to quite a bit of complexity while still basically just being method calls on objects created from classes.
I mean, imagine a world where your entire architecture is based on create objects from classes and calling methods on those objects. Almost anyone from any language and any level of experience can understand it. That is powerful.
I have used this approach many times and it works well. You just have to get over code like WidgetCreator.new.create_widget(widget). You have to let go of the concept of ābeautiful codeā - itās a lie anyway - and embrace code that is obvious, easy to follow, easy to test, and has behavior that is predictable.
Widget.create(...) with a ton of callbacks is the opposite of all that.
I put them in business logic classes that each have a single public .call method. I havenāt quite settled on the naming, but in various projects theyāll be under app/domain, app/actions, or app/commands. Theyāll be nested under whatever module/class is most relevant, so something like User::Create.call(**user_params). This helps keep classes small and focused, and avoids tangled concerns with ābag of methodsā style classes. Alongside this, weāve also got a CallableJob class, so itās super easy to background work as-needed, e.g. CallableJob.perform_later(āUser::Createā, **user_params)
@eayurt Totally agree that context matters and that premature optimization can be worse than a fat model when a lot of indirection is added without any justification.
@davetron5000 Thanks for sharing your approach! Iāve also found that an architecture based on composition (objects callings methods) itās easier to understand and maintain.
@mockdeep I used app/domain in a recent project and was happy with the outcome because it created an explicit distinction between the framework and the business logic. The approach you describe seems very similar to Dry Operations. Have you used it (or something similar)? Or you just have a convention to create the call methods?
Follow up question. Do you think thereās value in standardization or having these type of architecture as part of the framework? Or is it better that anyone can use their own toolkit/approach depending on the application and context?
I create a service object for every CRUD action and my controllers and workers only interact with the models through said services. Some services are literally just a MyModel.create!, but having it standardized as such makes life so much easier when I start having to include complex business logic. Then, in the models, I usually only include shared logic and define associations and the interactions between them.
The obvious downside is that I generate more code (although I scaffold the services so in reality itās just executing a rails generate). Testing also becomes more granular and less coupled so thatās nice
For the structure of the service objects, I usually stick to a single, imperative point of entry, a self-contained instation and to essentially be idempotent mediators (meaning, if I have a service for creating a blog post, the only way of creating a blog post should be through said service, period)
I donāt believe in classes with single methods. I just use plain old modules whenever I can and write code in a functional style. This also saves on memory pressure, garbage collection and is more JIT friendly.
On Dry Operations: I havenāt used it directly, but Iāve looked at it. My approach is simpler, just a convention: every service object has a call method, and thatās it. No base class, no result objects by default. If I need explicit success/failure handling, I sometimes return a simple struct or use Result pattern manually. Dry-rb is powerful but for most projects I work on, the overhead isnāt worth it.
On standardization: Iām a bit skeptical. Rails succeeded partly because it made strong conventions but also because those conventions fit a wide range of apps. Business logic is where apps diverge the most, so a one-size-fits-all solution feels hard to get right at the framework level.
That said, I think thereās value in a community-level convention, something like āhereās a reasonable defaultā without baking it into Rails itself. Basecampās approach (just use models and concerns until you canāt) and the Hanami approach (explicit architecture from day one) are both valid, just for different contexts and team sizes.
The class itself may require state in the future and using modules or concerns makes it impossible to do that later.
You cannot subclass modules the same way you can classes
Using mixins/concerns also leads to objects with large public APIs that can be difficult to understand and whose behavior can be hard to predict if you arenāt careful about name clashes.
Itās just a lot of downside to save typing four characters and itās not worth it. Itās simple (make objects via .new, call methods with good names) vs easy (call a method brought in from somewhere just donāt worry about it). I prefer simple.
Iām very much a fan of Blended Ruby (Object Oriented + Functional Programming ā essentially what my unpublished book is all about). Iāve been writing, teaching, speaking, and mentoring on this for over ten years now. This means:
Using the Command Pattern for classes where you have a single #call method. This is incredbily important for Ruby Function Composition and, frankly, an under utilized power of Ruby especially when you want to build robust fault-tolerant pipelines via the Railway Pattern.
Iām also a fan of using modules instead of classes ā when appropriate ā because they are one of the fastest and memory performant objects in Ruby. For that, I lean heavily on my Functionable gem to enforce this (influenced via Elm and Waxx). This allows you to compose your functions and/or objects into fault tolerant pipelines as well as inject dependencies (at the method level ā also not talked about enough in Ruby).
As for organization, Iāve settled on the concept of Aspects (but this might evolve more in the future ā I also need to publish a long form article on this in the future) as my top-level module/namespace for organizing all of my business logic. So in Hanami, for example, you still have your actions, relations, repositories, schemas, contracts, and so forth for general app scaffolding but then, at the same level, Iāll add an aspects folder where all of the business logic goes. These are basically the sinew objects that works across boundaries which can have sub-folders for additional organization around business concepts. Works extremely well and doesnāt have to be complex. You can easily refactor if namespaces need to be moved around or given better names (which will happen as your architecture evolves).
What the above does is it gets you away from object inheritance and more focused on object composition (i.e. the D in SOLID). This also means you stop turning your objects into multi-headed hydras due to inheritance, multiple inheritance, or worse ā Rails concerns ā which allows you to focus on objects with single responsibilities (i.e. the S in SOLID). Much easier to test, mix-n-match with other objects, and quickly delete when you no longer need an object.
The Tell, Donāt Ask principle is a great way to understand why is it architecturally good to use service objects to decouple units of action from the models and controllers.
I havenāt used Dry Operations, though Iāve seen it around. I think Iām sort of iterating in that direction. Thereās also Literal, which provides some validation to the inputs, but my gripe there is that it wants to take an instantiation approach by overriding initialize. Iād rather just have a module with a static .call method and pass params down as needed. I take a much more functional style when it comes to business logic and try to avoid instance variables. It makes it easier to trace where things come from and also easier to lint when something is misspelled or unused.
This is essentially how I use Literal for service objects too. Iām working on something now that I hope we can open source soon that lets you schedule and run these service objects like background jobs.
We introduced higher order types and serializers to Literal which is a big part of this effort. Basically youāll be able to say āthis kind of service object can only define properties with types where objects that match that type can be serializedā. And that check happens at boot time.