Back to all posts
13 min read

Operation: From State Hero to Zero

Operation: From State Hero to Zero

It was 3 AM when I stared down the beast.

The state_machines gem lay before me like a patient in critical condition—1,647 lines of tangled Ruby sprawled across a single file. Every method was a tumor, every global state an infection spreading through the codebase. The heart monitor was flat-lining, but somehow, this thing was still breathing.

By dawn, I’d either save it or put it out of its misery.

Code Triage: Assessing the Damage

Here’s what 15 years of “just add it here” gets you: a monolithic nightmare that makes ancient Egyptian mummies look well-organized. The machine.rb file wasn’t code—it was digital archaeology. If I didn’t know Ruby, I would have thought it was a GGUF file, an LLM model file, but this one was sentient and judging me… in Ruby.

But here’s the plot twist: of those 1,647 lines, 1,477 were comments. Yes, 90% documentation. The actual code? A mere 125 lines drowning in an ocean of explanations, examples, and apologies to future maintainers. It wasn’t a code monster—it was a documentation graveyard with a few lines of logic trying to breathe underneath.

# The patient: machine.rb (1,647 LOC)
class Machine
  # 47 instance variables (why not 50?)
  # 23 class variables (global state soup)
  # 156 methods (some doing 12 different things)
  # Comments from 2008 explaining Ruby 1.8 quirks
  # Methods named things like `really_really_final_state`
end

Looking at this code was like performing an autopsy while the patient was still talking. Every line had dependencies reaching into three other sections. Touch one method, break five others. It was a house of cards built during an earthquake.

The diagnosis was clear: Acute Monolithic Syndrome with Legacy Complications.

Mission Planning: The Extraction Strategy

You don’t just start hacking away at a 1.6k LOC beast. That’s not surgery—that’s a chainsaw massacre. I needed a plan, phases, and the surgical precision of someone who’s done this dance before.

Operation Objectives:

  • Extract focused modules without killing the patient
  • Maintain backward compatibility because I don’t have the emotional energy to open PRs across thousands of dependent repositories, pleading with maintainers to forgive my past architectural mistakes and begging them to accept that I’ve finally realized some DSL methods desperately need better names
  • Prepare for aggressive modern refactoring once legacy support drops
  • Transform chaos into comprehensible architecture

Rules of Engagement:

  1. One module extraction per deployment
  2. Tests must pass at every step
  3. No behavioral changes (yet)
  4. Document everything for the war crimes tribunal

Phase 1: Utilities Extraction 🔧

Codename: “Operation Listerine” - because you need mouthwash before talking

First rule of surgery: start with the parts that won’t kill the patient. Utility methods were the low-hanging fruit—helper functions that had somehow gotten lost in the monolith like spare change in couch cushions.

# Before: buried in the 1.6k LOC monster
class Machine
  def pluralize(word)
    # Inflection logic mixed with state management
  end

  def sibling_machines
    # Complex lookup buried in monolith
  end

  def owner_class_has_method?(scope, method)
    # Reflection logic tangled with everything else
  end

  # 1,500 more lines of documentation...
end

# After: clean extraction to StateMachines::Machine::Utilities
module StateMachines::Machine::Utilities
  def pluralize(word)
    word = word.to_s
    if word.respond_to?(:pluralize)
      word.pluralize
    else
      "#{word}s"
    end
  end

  def sibling_machines
    owner_class.state_machines.each_with_object([]) do |(name, machine), machines|
      machines << (owner_class.state_machine(name) {}) if machine.attribute == attribute && machine != self
    end
  end

  # Clean, focused, testable
end

Emotional Status: Cautious optimism

That first clean extraction felt like finding a perfect fossil in a pile of rubble. 84 lines moved to their own module, with clear responsibilities and zero surprises. The monolith shrank by 5%, but more importantly, I proved it could be done without everything exploding.

Phase 2: Parsing & Validation Surgery 🔍

Codename: “Deep Cuts”

Now for the scary stuff. Parsing and validation logic was embedded so deep in the monolith that extracting it felt like removing someone’s spine while they’re still walking.

The parsing methods had tentacles reaching into state management, event handling, and configuration. Everything was coupled to everything else in a beautiful display of how not to write software.

# The horror: parsing mixed with state logic
def parse_state_definition(name, options = {})
  # Validate the name (validation concern)
  # Parse the options (parsing concern)
  # Update internal state (state management concern)
  # Trigger callbacks (event handling concern)
  # Log everything (logging concern)
  # Sacrifice a goat to the Ruby gods (legacy concern)
end

Breaking this apart required the patience of a bomb disposal expert and the precision of a neurosurgeon. Each extracted method needed careful testing to ensure it still played nice with the remaining monolith. Especially since it needed to be tested across CRuby, JRuby, and TruffleRuby—because apparently one Ruby implementation wasn’t enough suffering.

Oh, and we also had to test all the integrations. Because it’s all fun and vibe coding until you release version 0.30.0 and discover that state_machines-active_model and state_machines-activerecord are completely broken, because the main gem doesn’t have tests that assert the Transformer assembly stuff actually works. Nothing like learning your architectural surgery killed half the ecosystem after the fact.

And here’s the thing: the gem works. So why refactor it? It’s not about fixing bugs. It’s about sending a message. It’s about emitting a signal that clean architecture matters, that maintainable code is worth fighting for, even when the old mess technically functions.

Result: parsing.rb (77 LOC) and validation.rb (38 LOC) emerged as separate modules. The monolith was down to 1,532 lines and starting to look like it might survive.

Emotional Status: Sweating but hopeful

Phase 3: The Protected Layer Excavation 🏗️

Codename: “Architecture Archaeology”

This is where it got interesting. The protected methods were like the building’s foundation—mess with them wrong, and the whole structure collapses. But they were also the key to understanding how this thing actually worked.

Helper generators, action hooks, and scoping methods were all tangled together like headphone cables in a pocket. Each one seemed to depend on the others in ways that violated every principle of good design.

# Before: a beautiful mess of circular dependencies
protected

def generate_state_helpers
  generate_state_readers
  generate_state_writers
  generate_state_queries
  hook_into_action_methods # Wait, why is this here?
  scope_helper_methods     # And this?!
end

The breakthrough came when I realized these weren’t actually dependencies—they were just victims of poor organization. Like finding out your “messy” room just needs better storage solutions.

Extraction Results:

  • helper_generators.rb (124 LOC)
  • action_hooks.rb (78 LOC)
  • scoping.rb (91 LOC)

Emotional Status: Starting to see the light

Phase 4: Public API Liberation 🚀

Codename: “The Final Countdown”

The public methods were the grand finale—the actual interface that thousands of developers depend on. These had to be perfect because any mistake here would break real applications in production.

But here’s the thing about well-designed public APIs: they’re usually clean already. The chaos lives in the implementation, not the interface. So extracting these modules felt like polishing already beautiful gems.

# Clean public interfaces emerged naturally
module StateMachines::Configuration
  def state(name, options = {})
    # Clean, focused state configuration
  end
end

module StateMachines::Events
  def event(name, &block)
    # Clear event handling
  end
end

module StateMachines::Callbacks
  def before_transition(&block)
    # Obvious callback management
  end
end

Each module now had a single, clear responsibility. No more hunting through 1,600 lines to find where state transitions were defined. No more wondering if adding a callback would break event handling.

Final Extraction Results:

  • configuration.rb (156 LOC)
  • state_methods.rb (134 LOC)
  • event_methods.rb (189 LOC)
  • callbacks.rb (145 LOC)
  • rendering.rb (98 LOC)
  • integration.rb (112 LOC)

Plot Twist: The IDE’s Final Judgment

Just when I thought the surgery was complete, my IDE started screaming. Two more files glowed red with warnings, like malevolent eyes staring back at me from the darkness:

  • macro_methods.rb (522 lines) - DSL definition methods
  • test_helper.rb (568 lines) - Test utilities

MacroMethods: The true horror show. This file looked like the 10 Commandments’ boilerplate—522 lines of what appeared to be harmless DSL definitions. But here’s the kicker: 514 of those lines were comments. The actual code? Seven lines. SEVEN. I had been terrorized by a 98.5% documentation file with seven lines of actual metaprogramming. It wasn’t a zip bomb—it was a documentation novel with a haiku of code at the end.

TestHelper: The ironic twist. I’d created this 568-line monster just days earlier, a safety net for developers who still believed in the old myth: “it worked on the maintainer’s machine.” This file contained helpers from the entire state_machines ecosystem—ActiveRecord integrations, audit trails, every adapter ever written. A digital ark of compatibility that I’d built to prevent other developers from harming themselves.

The good news? test_helper.rb gets a stay of execution. It’s not loaded at runtime, so it can’t hurt production. It’s just there, waiting patiently like a first-aid kit, ready to help developers who venture into the ecosystem without proper protection.

macro_methods.rb, however? That metaprogramming abomination needs to be dismantled with the same surgical precision. But that’s a battle for another day—and probably another blog post.

The Resurrection: Before & After

Before: The Monolith

machine.rb (1,647 LOC)
├── Everything
├── Mixed together
├── In one giant file
└── Good luck finding anything

After: The Constellation

state_machines/
├── utilities.rb (84 LOC)      # Helper methods
├── parsing.rb (77 LOC)        # Private parsing
├── validation.rb (38 LOC)     # Private validation
├── helper_generators.rb (124) # Protected helpers
├── action_hooks.rb (78 LOC)   # Protected hooks
├── scoping.rb (91 LOC)        # Protected scoping
├── configuration.rb (156 LOC) # Public config
├── state_methods.rb (134 LOC) # Public state ops
├── event_methods.rb (189 LOC) # Public events
├── callbacks.rb (145 LOC)     # Public callbacks
├── rendering.rb (98 LOC)      # Public rendering
├── integration.rb (112 LOC)   # Public integrations
└── machine.rb (117 LOC)       # Core coordination

The 1,647-line documentation monster became 13 focused modules totaling 1,447 lines. But the real win wasn’t the line count—it was the comprehensibility. Each module does one thing well, instead of one file doing everything poorly.

What Legacy Support Cost Me

Here’s the brutal truth: I could have done this refactoring three years ago. The code was begging for it. The architecture wanted to be clean. But every time I tried, I hit the same wall:

“What if this breaks Ruby 2.7?”

Legacy support didn’t just constrain my features—it constrained my thinking. I couldn’t use modern module composition because old Ruby didn’t support it. I couldn’t clean up method signatures because someone might be passing positional arguments. I couldn’t even extract simple utilities without worrying about backward compatibility.

The moment I dropped legacy support, the refactoring became trivial. Not easy—trivial. The code wanted to be organized. It just needed permission to use modern patterns.

When I had to test a matrix of 6 Ruby versions, plus JRuby, plus TruffleRuby, by the time one build finished, I had lost the wish to do more open source. Legacy support doesn’t just kill your code—it kills your soul.

The Freedom of Modernity

Now, with Ruby 3.2+ as the baseline, I can finally write code the way it should be written:

# Modern, clean module composition
module StateMachines::Configuration
  def state(name:, initial: false, **options)
    # Keyword arguments! No more parsing hashes!
    # Pattern matching! No more conditional soup!
    # Proper error handling! No more mysterious failures!
  end
end

Each module can use:

  • Keyword arguments for clear, self-documenting APIs
  • Pattern matching for complex logic branches
  • Proper module composition for clean dependencies
  • Modern Ruby features that make code safer and faster

The architecture isn’t just cleaner—it’s more expressive. The code says what it means, instead of hiding behind compatibility hacks.

Lessons from the Operating Table

1. Start with utilities. They’re usually safe to extract and give you confidence for the harder stuff.

2. Test everything, twice. When you’re performing surgery on a living system, paranoia is your friend.

3. Legacy support isn’t just technical debt—it’s architectural paralysis. You can’t build for the future while carrying the past.

4. Monoliths don’t happen overnight. They’re the result of a thousand small compromises. Break them apart the same way: one careful extraction at a time.

5. The code wants to be clean. Most architectural problems are organizational problems in disguise.

The Aftermath

The patient survived. More than survived—it thrived.

The new modular architecture isn’t just easier to maintain; it’s easier to understand. New contributors can focus on one module instead of deciphering a 1,600-line documentation maze. Features can be added without touching unrelated code. Tests run faster because they can target specific concerns.

But the real victory? I’m not embarrassed of my own code anymore.

No more apologizing for the gem’s complexity. No more steering people toward “simpler” alternatives. No more deprecation warnings flooding their terminals.

The gem is finally free to be what it always wanted to be: a clean, modern, focused tool that does state machines well.

And all it took was the courage to stop dragging corpses through my codebase.

The Unintended Consequence

Of course, there’s one tiny side effect I didn’t anticipate: now that there are more modules, they’re contained… but there are also more opportunities to monkey patch them. In a team of five developers, each team member can now add their own random, untested hack to different modules without conflicts.

Before, everyone had to fight over the same 1,647-line file. Now? Perfect distributed chaos. Each developer gets their own playground to break things in isolation.

I may have just weaponized my architecture. 🙃

The Final Push

By 2:44 AM, my eyes were closing, but the refactoring was complete. Time for the ultimate test: opening a pull request with the only reviewer available at this ungodly hour—GitHub Copilot, my relentless digital companion who never sleeps and critiques everything from code to life choices.

This is the same AI that once spotted an unfinished recipe in my markdown file and helpfully suggested: “Add salt and pepper to avoid potentially bland food.” Like, dude, this is a Gist—why are you reviewing my cooking?

Unlike Claude, who needs a proper invitation like it’s cinema night, Copilot just shows up uninvited with suggestions. And yes, he keeps adding that robot emoji to every comment like it’s his signature move.

I hit “Create Pull Request” and immediately got two notifications from my tireless reviewer:

🤖 Consider handling the case when @helper_modules[scope] is not found in ancestors to avoid potential nil slicing errors.

🤖 [nitpick] Consider refactoring the compound condition for clarity; using positive conditions may improve readability.

Classic Copilot. Even at 3 AM, finding edge cases and nitpicking my boolean logic.

I stared at the screen for a moment, then typed my response: “Will see that tomorrow, good night.”

Some battles are worth fighting. Others can wait until you’ve had coffee.


If you enjoyed this surgical adventure, you might also like:


Next time: How to modernize your APIs without breaking the world (or: “The Great Interface Migration of 2025”)

Related Posts