This message was imported from the Ruby/Rails Modularity Slack server. Find more info in the import thread.
Message originally sent by slack user U71QB9ZK541
I’m curious what other frameworks/tools you are using with packwerk to modularize your business logic. I’m reading up about tools like trailblazer, dry-rails, surrounded, and u-case and they seem like they’d slot right in with packs, but I’m curious what experience the community has had with using any of these tools/frameworks (or others not in this list.)
We’ve done some experimentation w/ dry-types & dry-schema for public API boundaries, but I think we’re starting to lean more into looking at Sorbet (particularly we’re interested in preventing ActiveRecord models from crossing public API boundaries)
@iMacTia We’re in the early stages of using service objects, started with just POROs but have started using dry-transactions. I’d be curious what made your team decide to move away from frameworks ans stick to plain POROs?
There are a variety of reasons, but the most important are probably the following:
These frameworks have a tendency to introduce “magic” for the sake of saving keystrokes, which developers can end up abusing, resulting in the code actually becoming harder to maintain. For example, dry-transactions doesn’t make it clear to the reader what the input should be (i.e. which parameters should be passed and what’s their type) without having to read the whole implementation.
We really wanted to promote simplicity because we’ve been burned by over-complicated, over-engineered service objects, so making it simple and clean will help reinforcing this concept. One service object does one thing (SRP) and multiple service objects can be organised in a transaction.
These frameworks pull in a bunch of other dependencies. This is especially true for the dry suite.
They can have a performance impact as they make abundant use of dynamic classes/methods
They’re extremely hard to type with Sorbet (for the same reason as point 4)
Those are pretty much the reasons I was expecting . I think the main thing I like about dry-transactions is it handles the Railway-Oriented programming flow (i.e. the Result monad) in an easy way. How do your POROs handle individual step failures? Or specifically, what pattern do you use to compose operations into a transaction while handling errors?
We rely on really the only external dependency which is sorbet-result
This gives us a sorbet-friendly implementation of a result monad (heavily inspired by Scala’s Either ) and it also supports chaining which is handy for implementing transactions
This has been working fine so far, but 2 disclaimers are in order:
We have embraced sorbet in our org, so that’s a big driver for us to use these tools. I understand you might now feel the same without that same drive
This was a recent addition to our codebase, and although I’ve already converted some complex flows to the new format, I’m far from converting all of them, so who know what kind of issues I’m gonna find later on
I’ve been meaning to look at adding Sorbet to our codebase but just haven’t found the time. That Typed::Result is very interesting though, I like that you can know all the kinds of errors that could be returned. dry-transactions kinda has that by being able to handle failures from different steps, but that’s a much more lightweight solution. We only have 4 transactions at the moment, and I’m already noticing shortcomings (e.g. dry-transactions makes it tricky to only have some steps in an ActiveRecord transaction).
I like that you can know all the kinds of errors that could be returned
This summarises probably the most important gain for us: with an explicit initializer and return type, you can immediately see the service interface (input/output) AND sorbet’s static type-checker will help you respect that
Thanks for the discussion here (it’s taken a bit for me to get back to it and digest what’s here…)
It sounds like a lot of the use of dry.rb and Sorbet is to ensure consistency of the public interface, but my original question was intended to be more about within the packs handling composition (ala Railway-Oriented programming) with skinny models, skinny controllers, and service objects to hold business logic/use cases.
I see packwerk as a tool to help modularize at the team or organization level, and I’m wondering if you’re using any strategies to modularize a little lower down at the use case or user story level (or something in between that and the org level.)
It sounds like a lot of the use of dry.rb and Sorbet is to ensure consistency of the public interface
To be honest, that’s all you really need. That’s the only thing that matters.
In my experience, fancy framework are great and fund, but they make the code more obscure (unless you really know the framework, which might not be the case for new starters) and tend to make it easier to introduce unnecessary complexity.
When you start having things like “the current_user will be automatically injected into the service, and authorized if you implement this optional method” or “the service will do this thing automatically for you, as far as you remember to do this other tiny thing”, you inevitably end up making things harder to assimilate and reason about, as well as cases where people forget to do the tiny thing and your service ends up missing authorization or something else
Our going back to PORO is an attempt to make things more explicit, more predictable and easier to understand for new joiners. And also arguably faster, considering how much overhead these frameworks bring, but that’s secondary.
Granted, you might need to do some more typing of boilerplate code, and that’s somehow seen as negative in the Ruby world, but luckily we leave in the times of Copilot and all sort of things to make that not as painful as one might think