Ne v kontakte Antisocial programmer's blog

Facets of simplicity

Facets of simplicity

    software development     thoughts     golang     dev

Simplicity is com­pli­cat­ed. In the Golang community, this statement is most often attributed to Rob Pike but it turns out a lot of people said something like that. A couple of weeks ago I en­coun­tered this yet again in a debate with a colleague (someone with lots of experience and whose opinion I respect a lot). Both of us considered ourselves advocates for simplicity, yet we were leaning towards radically different technical approaches. Both, of course, were sure that our own solution is much simpler than the other, and even had a good set of technical arguments to support that.

Without going into too many details, we needed a bunch of small business logic snippets executed ever so often against a certain dataset. The whole thing was supposed to be pretty small and simple (no high-load, out of the critical path, latency in­sen­si­tive, etc.), so the main concern was to minimize main­te­nance and debugging effort. In this particular case, the language was Go, but frankly, this can be applied to any language. The two options at the table were:

  1. Define a concrete struct type to hold snippet metadata and snippets themselves would be stand-alone, stateless functions. Execution manager will be another struct that would hold a list of snippets and manage their execution. The execution manager would also hold a bundle of all extra de­pen­den­cies (e.g. static data, RPC clients, etc.) a snippet function might need and that bundle would be passed over to all snippets as a function argument.

    Pros: Everything is explicit and visible at-a-glance: all external de­pen­den­cies (which should be very few) are in one place, every snippet is a stateless function; minimal amount of in­di­rec­tion. Concrete im­ple­men­ta­tion leaves minimal amount of “creative freedom” for those who will be writing the snippets, dis­cour­ag­ing them from using the system for things it wasn’t supposed to be used for.

    type Logic func(state State, deps Dependencies) error
    type Snippet struct {
      Name string
      Run Logic
    }
    
    type Manager struct {
      Snippets []Snippet
      States []State
      Deps Dependencies
    }
    func (m *Manager) Run() { /* Runs all snippets against all states. */ }
    
  2. Define snippet as an interface, which exposes methods to both return metadata about the snippet and to execute the snippet itself. The execution manager would be agnostic of the snippet im­ple­men­ta­tion; if a snippet needs some external dependency it must carry it with itself. A default im­ple­men­ta­tion of the snippet would be provided (very much like the one in option #1), but whether to use it or not is up to the snippet im­ple­men­tor.

    Pros: Snippet im­ple­men­ta­tions are self-contained and explicit on what they need to execute, execution manager is not concerned with snippet’s im­ple­men­ta­tion details, a default snippet im­ple­men­ta­tion will cover 90% of the cases, and the rest can provide a custom im­ple­men­ta­tion without forcing their de­pen­den­cies onto the rest of the system.

    type Metadata struct { Name string }
    type Snippet interface {
      Metadata() Metadata
      Run(state State) error
    }
    
    type SimpleSnippet struct {
      Meta Metadata
      Logic func(state State) error
    }
    func (s *SimpleSnippet) Metadata() Metadata { return s.Meta }
    func (s *SimpleSnippet) Run(state State) error { return s.Logic(state) }
    
    type Manager struct {
      Snippets []Snippet
      States []State
    }
    func (m *Manager) Run() { /* Runs all snippets against all states. */ }
    

So, this is the problem and the two solutions we were choosing from. I’d be curious which option seems simpler to you at this point :-) At the time, after discussing pros and cons of each solution our reaction was an uneasy “yeah, I see your point, but my approach is actually simpler and easier to maintain”. So the choice was made pretty much with a coin flip (for a small project like this it didn’t matter enough to keep arguing), but I walked away wondering why my opinion wasn’t dead obvious to my colleague.

After a few days of mulling over this in the back of my mind it dawned on me that the point of contention, and the root cause of our dis­agree­ment, was that we were optimizing for different kinds of simplicity.

Option #1 was optimizing for what I’d call “straight­for­ward­ness”:

  • minimal code (less code => less to maintain and debug => less effort),
  • at-a-glance un­der­stand­ing of the code (for example, all de­pen­den­cies in a single bundle => you know the total sum of the stuff the service needs to run at a glance),
  • minimal in­di­rec­tion (no “go figure all the types which implement a Go interface” problem, you can find all snippets just by looking at Snippet struct in­stan­ti­a­tions),
  • restricted flex­i­bil­i­ty (very pre­scrip­tive im­ple­men­ta­tion => fewer ways to misuse it).

And indeed, you can see that even in my sketches above, the first one is ~30% less lines of code. As a trade-off, the system ends up fairly tightly coupled and is betting heavily on not needing to morph into something more complex, at least without a sig­nif­i­cant rewrite.

Option #2, on the other hand, was optimizing for “locality”:

  • each component of the system is self-contained (you only need to study the component you want to modify to do so ef­fec­tive­ly),
  • formally defined interfaces establish a protocol between a component you are looking at and the rest of the system (so you know exactly which part of the behavior you can change at will),
  • no “dependency bleed” for the lack of a better term (an oddball snippet with a funky dependency won’t force that dependency into the rest of the codebase),
  • increased flex­i­bil­i­ty (re­quire­ments tend to grow in complexity over time => snippet im­ple­men­ta­tions can be changed without affecting the rest of the system).

In this case the bet is on the system living long enough to evolve into a more so­phis­ti­cat­ed version of itself (that is, you wouldn’t be able to skim through all the code in 20 minutes or less), but every part of it could be still understood quickly in separation of the rest of the system. The flip side of that is that right now the code would be a bit more indirect and verbose.

I have to note that often there is a spectrum of im­ple­men­ta­tions between these two options, with slightly different trade-offs. Either option can be taken to the extreme, at which point it will fail to serve the original desire for simplicity and main­tain­abil­i­ty. No one wants to deal with tightly in­ter­twined spaghetti, nor with a million of self-contained tiny ab­strac­tions.

So yeah, there you have it. Simplicity is not only com­pli­cat­ed, but also subjective. There can be many goals one can be optimizing for while calling it “simplicity and main­tain­abil­i­ty”. In the hindsight can’t say I’m really surprised, but it wasn’t at all obvious in the beginning.

P.S. OVERWERK - Toccata

blog comments powered by Disqus