Hacker News new | ask | show | jobs
by graypegg 821 days ago
If 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?

3 comments

Pretty much this.

I want traces to go to actual code as much as possible.

I also want my IDE to work.

One of the most annoying things in my life right now is aasm. It’s a good idea, but I hate having to know this new dsl, I hate the dynamic methods it defines, I hate having to constantly explain to the confused folks that this random bang method is actually triggering this `event :event_name` over here. I would just so much rather have none of the DSL and meta-programming stuff, even if it’s twice as much “boilerplate”, if that means people who understand normal code can actually trace what the program is doing using the tools they already know how to use.

Certainly don’t mind abstraction like a standardized Result class, it’s more the chaining and custom halt with error patterns that I can tell are going to complicate debugging.

What do you think of the Rack interface?

Or ActiveRecord's query chaining?

ex. User.where(admin: true).joins(:account).order(id: :asc)

It's all versions of the same approach.

And yes all of the above _can_ complicate debugging, for sure. But so can most abstractions. Only use them if the specific problem they solve is bigger than the drawbacks.

I think some abstractions are fine and warranted, even ones you yourself come up with. Rails is a framework consisting of tons of opinionated abstractions. We need mental models to grasp complicated, large codebases.

My point is more to think twice about adding your own, and after thinking twice about it, think about it ten more times. Most people using Rails will already have an understanding or mastery of the abstractions you mentioned, but that understanding guarantee drops off quick with third-party gems, and is a guaranteed no for custom in-house solutions.

Rack and query chaining are great for two different reasons.

Rack because it's simple.

Query chaining in things like AR is fine because it is mostly debuggable up to a reasonable point with things like to_sql, etc. Since the actual query only runs in the end, it is decoupled enough, and the "escape hatch" of Raw SQL is not really a leaky abstraction, is just the of the abstraction itself.

The problem with things like Trailblazer is that it intertwines itself too much with the business logic, so using breakpoints is very difficult. And its internals are also super complicated, not to mention people build even more complex abstractions on top of it (since it's a bit lacking). It works fine for toy examples but anything multi-layer deep ends up making it too difficult to debug and make sense of.

To me, Rack and AR are scoped to a specific domain. HTTP request handling and database-backed models are naturally going to involve some internal noise since I don’t want to write a web server or a database interface layer. I want the things I don’t want to write to be called.

Here, this is closer to general purpose flow control. There isn’t really a library I wouldn’t want to write myself here. You show a lot of examples of things that could be accomplished by objects and messaging passing (“plain Ruby”) which might be preferable for some.

All in all though, it’s a really interesting idea! I still really like the Ruby community’s love of bending ruby to their will. (Even if I do think it can cause issues sometimes)

I tried to keep this as idiomatic to Ruby as possible. No meta-programming, no FP machinery like monads, etc. Little more to it than an array of objects and a reduce function. All plain Ruby in my book.

I wonder if just using "throw :halt" (like in AR callbacks, to halt callback chains) or an Enumerator with "raise StopIteration" (which would work) would throw people off a bit less, just on account of those things being more familiar.

Eh, don’t let myself or others influence your library. You will know what’s best, and hacker news will mostly elicit lots of nitpicky reasons as to why you are wrong.
To be clear this is not a library. I'm just describing a pattern and a bare-bones implementation.
It's always worth reading Against Railway-Oriented Programming[1] before you commit to using it.

Personally I've dealt with enough Trailblazer code now that for business logic I think I'd much rather work with a simple ruby class with a procedural interface.

I like the use of a Result object but not at the expense of exception handling or bloated stacktraces full of library code.

Being lightweight, easy to debug and upgrade is underrated IMHO.

Having said all that, I still think this is a really interesting article and great food for thought depending the usecase.

1. https://fsharpforfunandprofit.com/posts/against-railway-orie...

Very good article, thank you.

I would add that the tagline says "when used thoughtlessly".

For example, I agree that using Result objects to reinvent exceptions is a bad idea. There's a reason they're called "exceptions". Error results should only be used for domain-level errors. ie. things that _your expect_ to go wrong in the domain.

Also note that in my article I use continue/halt, not Ok/Error. At the library level it's just a way to compose functions together with a mechanism to halt processing. Whether something halted execution because it was an error, or any other reason (ex. caching), is up to your app's semantics.

Good 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.

yes, it is similar to rack. But what rack has this requirement of being pluggable from multiple decoupled codebases (rails or other framework, your app, some gems you're adding to your app). And that also warranted things like "insert_before" etc.

Even with rack, decoupling of code definition (where you've added your middleware) and code execution could be pretty hard to debug, but at least with enough exposure you know where to look, and you usually have only one rack stack in your app.

If all those steps in your pipeline are defined in the same codebase, rack approach becomes much less useful.

> If all those steps in your pipeline are defined in the same codebase

I find that rule a bit arbitrary. For example, I have data-import pipelines where some steps are unique to each task, but some others are shared across tasks. Why does it matter whether the steps are defined in the same codebase or not?