Ne v kontakte Asocial programmer's blog

Facets of simplicity

Feature image

Simplicity is complicated. 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 encountered 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 insensitive, etc.), so the main concern was to minimize maintenance 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 dependencies (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 dependencies (which should be very few) are in one place, every snippet is a stateless function; minimal amount of indirection. Concrete implementation leaves minimal amount of “creative freedom” for those who will be writing the snippets, discouraging them from using the system for things it wasn’t supposed to be used for.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    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 implementation; if a snippet needs some external dependency it must carry it with itself. A default implementation 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 implementor.

    Pros: Snippet implementations are self-contained and explicit on what they need to execute, execution manager is not concerned with snippet’s implementation details, a default snippet implementation will cover 90% of the cases, and the rest can provide a custom implementation without forcing their dependencies onto the rest of the system.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    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 disagreement, was that we were optimizing for different kinds of simplicity.

Option #1 was optimizing for what I’d call “straightforwardness”:

  • minimal code (less code => less to maintain and debug => less effort),
  • at-a-glance understanding of the code (for example, all dependencies in a single bundle => you know the total sum of the stuff the service needs to run at a glance),
  • minimal indirection (no “go figure all the types which implement a Go interface” problem, you can find all snippets just by looking at Snippet struct instantiations),
  • restricted flexibility (very prescriptive implementation => 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 significant 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 effectively),
  • 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 flexibility (requirements tend to grow in complexity over time => snippet implementations 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 sophisticated 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 implementations 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 maintainability. No one wants to deal with tightly intertwined spaghetti, nor with a million of self-contained tiny abstractions.

So yeah, there you have it. Simplicity is not only complicated, but also subjective. There can be many goals one can be optimizing for while calling it “simplicity and maintainability”. In the hindsight can’t say I’m really surprised, but it wasn’t at all obvious in the beginning.

P.S. OVERWERK - Toccata