7 ms·
Practical Railway-Oriented Pipelines in Ruby
- ismaelct 2y ago[flagged]
- Draiken 2y ago> If you prefer inheritance over composition That's not what this boils down to. These are procedures. OOP is generally about objects sending messages to each other. So this solution is all about executing procedural calls, which has nothing to do with inheritance/composition. You can very easily have OO code with composition without having to rely on everything having one single interface "call/run/execute".
- deleted 2y ago[deleted]
- ismaelct 2y agoPerhaps I phrased this badly. I don't think the entire article boils down to inheritance vs composition. But in discussing these patterns elsewhere, some of the pushback has been that many Ruby devs prefer to decompose problems via sub-classing instead of composition of command objects, so I tried to cater to that objection with that line. Pipeline steps are command objects (a pretty standard OO pattern), so they have a single entry point / public method. But they can still fully leverage any other OO pattern in their implementation. The more complex ones I use may instantiate other objects, pass messages between them, etc. But the single-method #call API is what makes composition easy. See Rack, or any number of middleware-style designs, for other common uses of this in Ruby.
- Draiken 2y agoYeah, my main gripe is with the phrasing. It gives the impression that if you like composition over inheritance (which people mostly take as true without even thinking about it), you should use this. I feel like that's the kind of over-simplification that then makes people pick a style like this without fully considering the implications. Every single place that I've seen use these "command pattern" systems devolved into a complete utter mess of procedures. People forget the basics of OO and write everything into "steps" because that's now the hammer and everything is a nail. If they stuck to the outer layers as the orchestrator for everything, that'd be great. But that never happens in my experience.
- ismaelct 2y agoGood observations, thank you. I take the point about properly delimiting the boundaries of the orchestration layer in a system. I think you're right about how that line comes across, I'll try and improve it.
- corytheboyd 2y agoI’m glad people find value in patterns like these, but I’m so sick of them. Plain Ruby code is so much easier to debug, which is all that matters in the long run. The senior engineer who introduced the thing like this inevitably leaves and nobody cares to learn some bespoke abstraction enough to keep using it. I’m sure this is great and solves real problems for OP and friends. I just ask that you think twice about it before forcing it on coworkers. Please, this poor soul can’t take it anymore.
- ismaelct 2y agoThanks for your feedback. Out of curiosity, when you say "plain Ruby code" what do you mean, exactly? Presumably you're still making use of _some_ patterns that you think are Ok.
- graypegg 2y agoIf I had to guess, “plain Ruby” is ruby that fails with a sensible stack trace without internal things showing up. That’s always been my trouble with complex abstractions, it can make it very difficult to use with a debugger especially, which is how I always work with Ruby. New flow control models like this are the main culprits to weird debugger issues. I haven’t gotten a chance to mess around with this specifically, how much of its internals are mixed into execution?
- ismaelct 2y agoGood observation about stack traces and abstractions. Re. your question, the pattern itself is no different than, say, Rack middleware, so you'd see similar cost and benefits. In essence you're running one callable object after the other. A pipeline is essentially this steps = [ ->(r) { r }, ->(r) { r }, ->(r) { r }, ] Wrap initial data in a common Value object initial = Result.new(some_data) Run the Result through the steps, in order result = steps.reduce(initial) { |r, step| step.call(r) } That's the pattern, really. A reduce operation. Re. stack trace, it can add noise because you're iterating over steps instead of calling them procedurally one by one, and you may want to decorate steps (put steps inside steps) for encapsulation, caching, etc. but again no different than Rack.
- jtms 2y agoI might be a bit biased (I wrote and maintain a similar gem), but I think this is a great pattern that solves a real problem that is encountered and does so in a very Ruby kinda way. I particularly like the mental model of stringing together multiple service objects into a "pipeline" and the semantics chosen for the API. Kudos to OP for putting this out there!
- ismaelct 2y agoThanks. Where can I find your library? I'd love to compare notes!
- nathanappere 2y agoI use a functional version of this that can chain any "callables" + support extensions on the calling interface https://docs.rubykit.org/kit-organizer/edge/Kit.Organizer.Services.Organize.html https://docs.rubykit.org/kit-organizer/edge/Kit.Organizer.Se...
- ismaelct 2y agoNice. Yes the pattern I describe in the article supports any callable too. I should point out that this is not a specific library, just a very bare-bones pattern.
- yakshaving_jgt 2y agoI'm still bitter about having to work on a team with some Clojurists who thought Haskell and monads were bad and stupid, but then forced ROP on the rest of us in a Clojure project.
- ismaelct 2y agoOdd to see ROP and monads as an either/or problem (see what I did there?). Most ROP implementations I've seen rely on the result monad.
- yakshaving_jgt 2y agoExactly my point.
- ismaelct 2y agoI just wanted to make the either/or pun.
- yakshaving_jgt 2y agoI appreciate you.
- tome 2y ago> see what I did there? Maybe
- ismaelct 2y agoVery nice
- throwaway918274 2y agoWe use the `interactor` gem at $DAY_JOB and it does 90% of the stuff described here, and the last 10% came naturally and we just kinda engineered it ourselves intuitively. definitely works better than "fat models" that do everything and ruby on rails callback hell
- mitchellh 2y agoThis pattern is effectively how Vagrant (for anyone who remembers that) always worked, also in Ruby! I even gave a talk on it at MountainWest RubyConf back somewhere around 2013, although I compared it moreso to a "middleware" pattern. Even the API/DSL is almost identical. The middleware pattern had a lot of the same concepts present in this post: we called context "state" and you could use special exceptions to halt or pause a middleware chain in the middle. This was a really great way for over a decade (to this day!) to represent a long-running series of steps that individually may branch, fail, accumulate values, etc. I don't recall the exact count, but an old `vagrant up` used to execute something like 40 "actions" (as we called each step). I'm not trying to disregard this blog post in any way, I'm only pointing out this pattern is indeed very useful and has been proven in production environments for a very long time!
- ismaelct 2y agoThanks Mitchell (big fan, btw!). Indeed!https://github.com/hashicorp/vagrant/blob/main/lib/vagrant/action/builder.rb#L15 https://github.com/hashicorp/vagrant/blob/main/lib/vagrant/a... Yes, it's not a new pattern by any means, and there's many ways to "halt" the pipeline as you say. For example ActiveRecord stops callback chains if any callback throws ":halt". Other examples are Redis.rb's pipelining API https://github.com/redis/redis-rb?tab=readme-ov-file#pipelining https://github.com/redis/redis-rb?tab=readme-ov-file#pipelin... Or more generally any builder-style pattern that composes a set of operations for later execution, including again ActiveRecord's query chaining. In my article I tried to show a specific implementation using the Railway pattern (where the result must only respond to "#continue?() => Boolean")
- lamontcg 2y agothe code in the blog post has an error that you fixed in the gist: def step(callable = nil, &block) you're missing the nil default in the blog post so it ArgumentError's when passed only a block
- ismaelct 2y agoWell spotted. Fixed. Thank you!
- Fire-Dragon-DoL 2y agoI wrote something like this in the past, it doesn't make sense in ruby. The closest ruby way is: result = DoStuff return something if result.nil? # error, early exit result = OtherStuff(result) return something if result.nil? # error, early exit No additional complexity. Otherwise usually it ends up just rewriting "programming". With pattern matching there are also some alternatives now.
- ismaelct 2y agoWhat do you think of Ruby's built-in function composition? https://ruby-doc.org/3.2.2/Proc.html#method-i-3E-3E https://ruby-doc.org/3.2.2/Proc.html#method-i-3E-3E
- Fire-Dragon-DoL 2y agoInteresting, never used it. Usually I have methods but not proc, by the time I have to write `.method(something)` a bunch of times I'd it doesn't make much sense anymore. I love curried functions by default, but if it's not the default, it never works out even in functional languages like Elixir. You need to have every developer (and new hired developer) pretty much on board with that and exclude every library that doesn't do this, or wrap it (huge burden). Given those, I'd rather stick to what I wrote. Not my favorite, I loved my brief experience with Elm
- ismaelct 2y agoI understand the sentiment but I think it's highly dependent on context. Where you work at, who you work with, org size, what the problem is, the cost and benefits of each abstraction, etc etc. I think it's our job as developers to put all those things on the scales when deciding what abstractions to use or not use. Whether a pattern is "the default" or familiar is certainly a big factor, but not the only one.