Monday, April 7, 2025

Modelling software liabilities via state, coupling, complexity, and code size

These are notes from a tech talk I gave at work in late 2024. I've removed some details specific to the company.

This talk is completely indebted to this this life-changing Hacker News comment. Also thanks to the author of this Medium post, who independently came across that comment and wrote their own interpretation.

What makes "bad design" bad?

Over years of working with code, developers develop intuitions about what makes for "good" or "bad" software design. These intuitions are aesthetic and often visceral – you might hear phrases like "elegant", "clean", "feels wrong", or "the genius who thought this was a good idea should be retroactively aborted". At the same time they can appear hazy and contradictory ("don't repeat yourself! Except here, where copy-pasting is clearly better"), and can be hard to articulate.


In this talk I'll describe a model for thinking about software design that tries to capture some of this intuition. The model proposes that most "bad design" can be attributed to the presence of four distinct liabilities, and it gives some simple, practical rules to juggle these liabilities to make your software more maintainable and evolvable. Plenty of examples throughout!


If it goes well, you'll understand when to ignore the "DRY" rule, when to hand-roll your own CSV parser, and when to use inheritance – and you'll be able to explain why.


This talk might be useful for those who'd like to develop their taste for design. It might also be useful for those who have already developed this taste, but struggle to articulate it during code reviews or mentorship.




  • As a programmer, you learn from making mistakes. e.g.

    • You learn to use functions because when you didn't, you ended up with spaghetti mess

    • You learn to use global variables because when you didn't, it was impossible to reason about each  piece of code

    • You learn to write backwards compatible code because you broke prod

  • There are patterns in the mistakes you make, and patterns in how you solve them

  • Over time you develop intuitions based on these patterns you notice.

  • These intuitions are very powerful because they let you anticipate situations where you need to make an important choice, and they nudge you towards the correct choice

  • These intuitions are also mostly universal, surprisingly - most experienced developers made similar mistakes, so they learned the same lessons and share most of the same intuitions.

    • there are exceptions. e.g. functional programmers vs imperative will have some different intuitions.

  • It's useful to give words to these intuitions for two main reasons:

    • To be able to have meaningful discussions and justify design decisions, trade-offs, etc. to others

    • To help shape the still-forming intuitions of less experienced developers

  • Lots of examples of models/heuristics that try to capture this intuition: YAGNI, CUPID, DRY, SOLID, KISS, …

    • But some of these are vague or easy to misunderstand

  • This talk is about a model that I believe works better than all of these. It's more general/flexible, easier to use, more prescriptive, harder to misunderstand.


Four liabilities


  • The only system with no liabilities is the one that doesn't exist.

  • As soon as you start designing a system / writing code, you immediately start dealing with liabilities:

    • You now have code, and the code takes time to read, understand, to learn (for others), to maintain.

    • You've made some language choices, which impacts subsequent maintainability and expressibility and features etc.

    • You pick a DB, which needs to support all your data and has to be backed up and versioned and sometimes migrated etc.

    • You pick dependencies, which need to be managed.

    • You need to version and release it, so have to think about compatibility.

    • You make it a distributed system, with all the issues that come with that.

  • Liabilities are not bad things. But if not properly controlled and managed, they will lead to problems.

  • Software design is about taking on the right liabilities for your task, and reducing the remaining liabilities or trading them off each other.


The model

  • The model I will present asserts that there are essentially 4 categories of liabilities that are most important to the software design process.

  • The model asserts that there is a strict hierarchy of risk/danger to these liabilities. The first liability is much worse than the others, the second liability is worse than the third and fourth, and so on.

  • Therefore you should prioritise reducing the first liability; only then should you reduce the second; and so on.


Liability 1: State

  • State is when the behaviour of your code depends on what it's done in the past.

  • You need to think about configurations of state to understand code.

  • State is an incubator for bugs.

  • State is enemy of concurrency

  • Makes testing harder

  • Makes debugging harder

  • You learn early on that state is a bad thing. in CS101 you hopefully learned not to use mutable global variables. 

  • Code smell suggesting that you have hidden state: methods on a class need to be called in a particular order, or you have a post-init "config" method.

  • Another code smell: caching. Caching is a form of state maintanence that can affect performance as well as correctness.


Liability 2: Coupling

  • Coupling is when you have two components such that changes to one induces changes to the other.

  • If you have a big network of coupled components, it is very hard to make a change to a component – or even to reason about a change – because of the knock-on required changes. "Change amplification"

  • Vicious cycle: highly-coupled systems are harder to refactor. So the more coupled a system is, the harder it is to uncouple. Eventually it becomes sort of frozen.

  • Avoid coupling by having clear stable interfaces between components

  • Coupling exists between functions, classes, modules, services, repositories


Liability 3: Code complexity

  • Complexity here means anything that makes the process of reading code more difficult for the code reader.

  • Including use of advanced language features: reflection, regular expressions, higher-order functions, templates, decorators, generators, call-backs, context managers, async, dependency injection...

  • Complexity is cultural and dependent on the dominating programming paradigm. E.g. monads would probably be considered complex in C but not in Haskell.

  • Incudes bad naming, lack of comments

  • Cyclomatic complexity: number of paths through a piece of code. Functions with lots of boolean params, nested if-statements, long functions


Liability 4: Code length

  • Code itself is a liability, regardless of its characteristics.

  • All things being equal, prefer to solve your problem with a shorter piece of code than a longer one. All things being equal.

  • BUT: you shouldn't prefer the shorter solution if the code is more complex, if the system is more coupled, or if it's stateful.


How to use the model


It wouldn't be interesting to say that you should reduce coupling, or reduce complexity by writing simpler code. 


The model is more opinionated than this.


The model makes 3 controversial statements:

  1. You should be willing to write more verbose code if it reduces the code complexity, coupling, or statefulness.

  2. You should be willing to add complexity at the code level through, say, advanced language features, or writing a long function with 20 parameters, if it decrease the coupling or statefulness in the design of the system.
  3. You should be willing to increase the coupling in your system if it reduces the amount of state.

Cross-cutting factors

There are two cross-cutting factors that can make a liability much more tolerable or much riskier:

Factor 1: Scale

  • The scale at which a liability manifests multiplies the impact of a liability.
  • State might not be too bad if it's carefully contained to one component. But if your entire app runs on shared global state, you're gonna have a bad time.
  • You might prefer to have a self-contained instance of state change, to something that adds complexity to every function of your app. 

Factor 2: Longevity

  • The duration for which a liability is present multiplies the impact of a liability.
  • It might be fine to introduce a disgusting bit of state in a midnight hotfix of production. You'll fix it properly the next day.
  • But if state is at the core of the design of a new system, and will be hard to remove later, tread very carefully.
  • Same goes for the other liabilities.

Examples


DRY

  • Inexperienced programmers hear "don't repeat yourself". So as soon as they see two pieces of code that look the same, they factor out the repeated logic into a function.
  • But two pieces of code can look the same without expressing the same infomation. They can look the same by coincidence, or because they encode logic that is the same right now but will differ soon.
  • Code length has been reduced, but now there is coupling between the two callsites and the function.
  • So what happens when the two pieces of code need to differ? The inexperienced developer introduces a boolean flag to the function! And then needs to update both callsites! The flag introduces code complexity to the function, and the extra coupling leads to annoying changes.
  • An experienced developer knows that copy-pasting is frequently the right thing to do – it incurs an increase in code length to avoid increasing complexity, coupling, or state.

Inheritance

  • The advice "composition over inheritance" is just a special case of this more general theory.
  • Inheritance is usually justified as a way to reduce code size, but it leads to extra coupling (between the classes in the hierarchy) and extra complexity for the code reader.

Information-passing between processes

  • Imagine you have some structured data to send from a sender process to a receiver process, and you must choose between two options: (1) the sender sends it as encoded plaintext in an environment variable, or (2) the sender writes it into Redis and the receiver reads it.
  • Cons of using environment variable: it adds complexity. It's untyped, unstructured data, you need to decide what to name it and where to store the name, and you're exposed to OS-level constraints (e.g. limits on env variable length). But there's no state to worry about.
  • Writing into Redis might feel more robust, but it exposes you to state-related issues:
    • What if the sender runs twice in a row?
    • What if there are two instances of the sender?
    • What if the sender crashes and needs to be retried?
    • What cleans up Redis?
  • To me, these state-related consequences feel much more dangerous than the complexity-related consequences of the enviroment variable. So in the abscence of other information, I would pick the env var approach. What do you think?
  • Obviously neither option is ideal. But in this job we often must pick between two evils. The model gives us a framework for this.

Testing command-line flags

  • Suppose you're writing an integration test in Python's unittest framework to check that your app respects certain command-line flags.
    • Option 1: in setUp(), set the global state of command-line flags. In tearDown(), undo it.
    • Option 2: in each test function, set/unset the global flag state at the start/end respectively.
    • Option 3: monkey-patch the flag state in the code under test using e.g. mock.patch.
    • Option 4: rewrite the code-under-test so that it consumes a flags/argparse object, and then inject this dependency in the test code.
  • The model would argue that these options are listed in order of worst to best. Can you see why? Do you agree?

List comprehensions

  • The model is also useful for small-scale design nits.
  • Do you prefer an explicit for-loop x=[]; for ...: x.append(...) or a list comprehension x = [...]? How would you analyse each one in terms of state, complexity, and code length?

No comments:

Post a Comment