State Machines in Ruby: An Introduction
blog.appsignal.com
blog.appsignal.com
The article starts with examples that just define the state machine by hand. This is a much better approach and scales to larger code much better. You can grep "def start!" and just read what the method does. A state machine DSL is really not providing much value and eventually just gets in the way.
There's a take on state machines in a railsconf talk further down in the thread and it seems like an awesome way to reap a lot of the benefits while keeping a grip on the complexity. Sometimes complexity can't be buried safely and you kind of need to frame it instead. I'd argue that state machines bury that complexity but breaking those states into ruby objects w/ their own validation frames and delineates them instead.
Lots of models I've encountered eschew being organized as state machines in favour of having "if salad" strewn throughout their code. That being said, refactoring to a simple state machine and trying to maintain it as such in perpetuity isn't always the correct solution.
Sometimes, a hierarchal state machine is needed, and if it is expressed as a simple state machine, it's just as messy.
Sometimes, a portion of it needs to be a state machine, and the right thing to do is to delegate some of the methods to a strategy that reflects the current state, but not all of them.
Sometimes, the whole thing is just too fat, and a state machine won't save it, the right thing to do is to get very aggressive about refactoring to a composite object.
Any time you have a big, messy model, it's very easy to write a blog post espousing a single solution, like this one:
http://raganwald.com/2018/02/23/forde.html
HN discussion: https://news.ycombinator.com/item?id=16468280
But the reality is that a big, messy model is always going to be some kind of problem, and unless you can break it down into parts, you're going to have a problem. A state machine is conceptually a way to break a big thing into parts based on its "state," but that's just one approach to factoring.
p.s. Another problem is that even if a simple state machine is the right answer, "rolling your own" usually isn't. Grab a well-tested and documented library already. This isn't your passion project, this is industrial programming. Rolling your own is one of the best ways to learn how state machines work. Once you've learned how, reach for a professional tool.
Implementing classes on top of pre-ES2015 JS wasn’t exactly rocket science either, so there was a “Cambrian Explosion” of home-grown implementations and OSS libraries all over the place. Frameworks like Ember.js rolled their own too, and everything was incompatible with everything else, plus while the basic principles were the same, the details differed from implementation to implementation.
And when organizations are rolling their own, they tend to do just enough to serve their immediate needs. As their needs grow, different internal contributors add patches and bodges to the implementation, using different approaches.
Over the long haul, even though the concept is simple enough to roll your own state machine, it’s often a win to build on top of something which is well-supported and can grow with your organization.
Just as it was with classes in JS.
Using explicit state action services, which are well tested and intentionally invoked are the ways to go. They encapsulate the code, and don't create these callback hells when things inevitably get complex.
More generally, I would recommend to avoid any DSL. All DSLs are a downgrade over ruby language and there is a really narrow list of opportunities where they are an upgrade. Everyone knows ruby when working on a Rails application. Everyone knows what a class, an instance is and what methods are. Ruby is extendable, incredibly flexible and it doesn't need "special options" to be extended. DSLs every time attempt to recreate the flexibility of ruby and require studying a new mini language. When I'm in a dsl, I don't know if I can use an instance variable, if I need a method I don't know if I can have one. More often then not, this question is not answered by the documentation and digging in the source code is needed.
Most libraries with a DSL violate the first rule of a DSL: a DSL method should delegate to a normal object performing the operation, so that DSL API has also a corresponding normal ruby API. If you do this, your DSL is now extendable and replaceable, it can also be integrated into other libraries.
As for state machines, the libraries recommended push toward objects violating the single responsibility principle. A state machine has already a super important responsibility: state-transitions. That's already a lot of work, if it has to trigger side effects, now it does way too much.
Some alternatives: pub/sub! let the state machine be observable, whatever needs to do work when a certain state is reached, can do it when the event is published
Alternatively as someone recommended below, service objects take care of: transition, persistance of the new state and side effects. If a new action is needed, a new service object can be written. The state machine can be used to validate the transition while the service object takes care of the rest.
Here are somebody's notes from a Railsconf talk that made this point, plus a link to the video:
https://gist.github.com/benoittgt/05521e272e13c7b7c9c89eb4a9...
But the short version is the takeaway quote:
> State machines are magnetic : they attract more states, transitions, events, callbacks and they accumulate callback logic where our bugs hide
And going beyond that, to the state machine, if each state (or really, value of a particular field) is important enough to have it's own contextual validation, transition points, etc, then it's important enough to expose each state as a resource with it's own controller.
Super cool stuff and I've got some thinking to do about some side projects now.
It started out small and easy, when there were two states. A third state was manageable but to me already looked like it was going the wrong direction. The fourth state (out of IIRC 6) was never finished.
It took me two weeks to do a trivial change because I had to reverse engineer a ton of it. And the only debugger I had was a couple of LEDs on the board I could wiggle on and off.
That's literally the example in the literal textbook of why you shouldn't write code like this!
This pattern is something which I've evolved over the years and it's fantastic (if I do say so myself).
At some point you'll have to think about query performance on the state transition table, but it'll go further than you think and is firmly in the realm of problems of success.
[1] - https://www.crunchydata.com/products/crunchy-bridge
[2] - https://www.citusdata.com/blog/2016/08/12/state-machines-to-...
[3] - https://brandur.org/fragments/large-scale-ruby-refactoring
Full disclosure, I think I wrote my first state machine in around 1996. So yea... Grey hair etc. Also it was a big part of my computer science degree, studied formally, so perhaps that's why I'm biased in favour of them.
It got to the point where this state machine approach was a real bottleneck and though I left before it was resolved last I heard they had to put real engineering time in to move off of it.
Be very careful when deciding to go this route.
It was also really easy to write tests for.
I guess I could amend my statement to "be careful about which library or implementation of a state machine you go with"
How in the world is updating a column from "in cart" to "purchased" to "complete" slow? There are only three things a state machine has to do:
1. Determine if the event can happen
2. Set the record to the new state
3. Call lifecycle hooks
None of that should be "very slow".After playing around different JS frameworks I'm back to Rails and I've been able to iterate quickly. That gem solves a few pain points.
State changes are hard and most involve more than object, there are usually associations that need to be taken care of, validated or maybe also transitioned to a different state, there can be a myriad of conditions and things can go wrong at any moment. And I feel that being able to define state transitions this easily, almost as an afterthought, with a simple DSL, doesn't do all this justice (just like those friendly before/after callbacks can turn into massive spaghetti abominations).
The way I do it is that any state transition more complex than checking two attributes and updating a third one is an object. Transitioning a Campaign object from :reservation to :order? That's a new PORO called CampaignOrder, with a #perform! public method or something. All the conditions, assumptions and side effects have their private methods (check_showings!, update_occupancy!). Passed a dirty record into something delicate? That's a raise, I want something clean and valid to work with. Is this something where we expect a dirty record? Go right ahead, we'll save it later in the transition, plus it will be documented right in the constructor. Does it need to raise? It will. Custom error messages on any record? Right here in this private method. Does any model need to know anything about the state transition or own any related code? Nope.