Software Coach Nick

Building value, not code


Code does not matter. Not to your users, anyway. They don’t see it, they don’t care about it - they only care about the value your software delivers. Too many engineers, especially at the mid-level (but not exclusively!), lose sight of this.

In my experience coaching engineers, this is one of the most common hurdles for those moving past the mid and senior level. These engineers are usually strong problem solvers, comfortable with fundamentals, and fluent with tools. But they often stumble into some common problems:

  1. Constantly tweaking lint / static rules or adding custom ones — instead of adding tests.
  2. Negative views of TDD or unit testing (“you write twice as much code”; “it complicates the design”).
  3. Their name clutters version history with lots of irrelevant changes (“cleaning”; “refactoring”; “fixing types”).
  4. Disproportionate responsibility for outages or regressions.
  5. Frequent requests for “time to refactor” or “fix tech debt.”
  6. Persistent negative opinions about others’ code, mostly on style or readability.

I’m going to address each of these common problems in turn, and suggest strategies for fixing these problems (or coaching someone with these problems).

Particularly now with the rise of AI coding tools, it’s very important to understand how to specify and evaluate software in terms of the value it’s producing for someone else, not in terms of the code. You won’t get much value from AI tools if your focus is squabbling over stylistic concerns. Because, fundamentally:

Code does not matter

Consider the classic contrived example: FizzBuzz.

The program should count from 1 to 100. It should print Fizz when it lands on a multiple of 3, and Buzz when it lands on a multiple of 5. If it is a multiple of both 5 and 3 it should print FizzBuzz, and if it’s a multiple of neither it should simply print the number.

Here are two solutions in pseudocode:

for n in range(1, 100):  
if n % 5 == 0 and n % 3 == 0:  
  print FizzBuzz  
elif n % 5 == 0:  
  print Buzz  
elif n % 3 == 0:  
  print Fizz  
else:  
  print n

A tidy, respectable program. Let’s look at another example:

print 1  
print 2  
print Fizz  
print 4  
print Buzz  

print 98  
print Fizz  
print Buzz

Most engineers will argue the first is “more correct.” But why? Both satisfy the requirements. In fact, the second runs faster (it’s branchless!) and is simpler in one sense: it contains no logic at all.

If the customer later asks for a range change, or “Bang” on multiples of 7, sure - the first may be easier to extend. But that’s not part of the original requirements. Both are equally valid answers to the problem posed.

The point is: users don’t care about the code. They care about whether their problem is solved. Why their problem is censoring specific multiples I have no clue whatsoever - perhaps it’s related to why Johnny’s always running around with so many apples.

Code quality does matter to us, but only insofar as it helps us deliver that value.

Let’s address each of the common problem behaviours and explain how to correct them.

1. Over-reliance on static analysis (lint, type checkers, etc.)

Static analysers evaluates the structure and content of your code at rest - which is precisely when it is least valuable. Testing, however, evaluates the behaviour of your code in action - which is precisely where its value lies.

Learn about testing strategies. There are unit tests, snapshots, integration tests, visual regressions, end-to-end testing, UI testing, etc. A cornucopia of options are available to you, and they work best when layered together appropriately.

Here is a “rule of thumb”: if a problem you are trying to address might affect any engineer writing software in your chosen language and framework, not just in your codebase specifically, then lint is probably the right tool. Otherwise, unit testing (or any higher level automated test) is often more appropriate.

Static analysers are (mostly) workarounds for imperfect languages and frameworks, not for evaluating correct behaviour.

2. Dismissing TDD and/or unit testing

Learn and practice both TDD and ATDD properly - correctly written unit tests produce good, maintainable software design. Testable is maintainable, because it requires exactly the same set of traits.

If changing code structure forces you to rewrite tests (rather than just restructure / rename), they’re too coupled. Aim for black-box tests, and choose the right level (sometimes integration or E2E fits better).

Without a thorough, multi-layered automated testing strategy, you are prone to subtle, and often dangerous, bugs. Lint cannot save you. Unit tests are so effective and so cheap and actively help you produce good, maintainable design, there’s almost no reason not to do them.

3. Frequent “clean-up” commits

Any change made to software introduces risk. It should only ever be done in service of some realisable business goal. That means you should only change code in service of your user - not of yourself.

Ask yourself “Why am I doing this? How does it help my end user?” If you can honestly and thoroughly answer why this change helps your user, go right ahead. If your answer is anything along the lines of “because it’s bad/wrong/ugly” or “because it needs to be done” then this is no answer at all - you’re likely making this change unnecessarily.

Unnecessary changes also muddy up version control history, which is more detrimental to developer productivity than things like extra blank lines, or non-lexicographically ordered import statements.

4. Too many outages / regressions

Remember: every change carries risk. Even updating comments, minor/patch version dependency upgrades, reworking types - every change.

Remember: without a thorough, multi-layered automated testing strategy, you are prone to subtle, and often dangerous, bugs.

If you are taking unnecessary risks by changing things that are unimportant to your end user (see #3) and you are not protected by a strong testing strategy (see #2) then you are probably going to end up breaking things very frequently. Keep an ear out for the word “should” in your inner monologue - it’s dangerous - especially when it’s saying “this shouldn’t affect anything” or “it should be fine”. This is almost certainly justifying risk to yourself that’s completely unnecessary. (Ask me how I know!)

“Refactoring” is one of the most frequent causes of these kinds of subtle bugs, a term coined by Martin Fowler that’s often misused, and one he regrets the regular misuse of.

5. Complaints about “time for refactoring/tech debt”

Refactoring is done as a part of building software, it’s not a standalone task. It should never appear on a roadmap. If you are asking for time to do “refactoring”, or explaining that something “needs to be refactored” before you can proceed on xyz project, then you are using the term incorrectly.

As per #3, code changes should only be introduced in service of some business outcome. Refactoring, by definition, changes code without changing behaviour - and so it cannot, on its own, have any real business outcome. If making changes to existing code before starting on xyz project would have some strategic benefit, then say so, but it’s not a seperate “refactoring” task - it’s just part of the xyz project build.

Tech debt is another overused term, and is only appropriate when referring to a trade off that the business has previously accepted, in favour of time or cost. So often it’s used to refer to “code I/we don’t like any more” or “code I/we don’t understand”. It’s a business decision when to pay down tech debt, based on managing the risk associated with it.

It’s your job to communicate that risk, and decide together within the applicable decision making structure when that risk is no longer acceptable and it should be paid down.

It is usually obvious, even to non-engineers, when the tech debt is charging too much interest, because it will be delaying releases, limiting features, etc. It won’t be because it’s annoying engineers - we get paid a tonne of money to deal with complexity, it’s our job. It will be when it’s affecting users.

6. Harsh opinions about others’ code

Practice reading code. You should almost certainly be doing that more often than writing code. Almost every working environment you find yourself in will have an established codebase you will need to work in. And almost every one of those codebases will have neglected parts.

Try to catch yourself when you say code is “bad” or “hacky” or anything along these lines. Force yourself to explain why the code is bad in real terms that affect your user - there might be bugs, there might be a security risk, it might be non-extendable in a part of the system that’s undergoing active development, it might be insufficiently tested.

If you can’t specify why you think code is bad in an objective manner that affects your user in some way, then you’re likely just saying “I don’t like it” or “I don’t understand it”. This almost always boils down to the fact that you’re a lot better at writing code than reading it, so you naturally reach for the tool you’re most comfortable with and attempt to rewrite or “refactor” - and that’s almost always unnecessary.

Persistent, negative opinions of others’ code (including your own) without the ability to objectively explain why that code has some negative impact on your user is one of the clearest signs (to me, as a coach) that you lack code reading skills or practice. Perhaps that’s unintuitive, perhaps it’s exceedingly obvious! Try to remember that code is in production and likely paying your paycheck, and in that way is responsible for you even having a job. It can’t be that bad.

So, what have we learned?

Think of writing code as building a bridge. On one side: code. On the other: value for your user.

Many engineers spend their time polishing their side of the bridge - solid foundations, elegant arches - while on the user’s side, people are playing hopscotch with crocodiles to reach dry land.

This is the difference between art and engineering. Art can exist without objective purpose. Engineering cannot. All bridges are engineering; only some are art.

Our job is not to make beautiful code for its own sake. It’s to deliver a bridge that serves its purpose: carrying people safely across. If it also happens to be beautiful, then that’s a bonus.

Remember:

  • Code does not matter to your users. The value in its behaviour does.
  • Every change introduces risk - make changes only in service of user value.
  • Respect testing: it’s how you transfer complexity away from users1.
  • Focus less on the code you write, more on the outcomes you create.

That’s how you move beyond mid-level engineering into senior and beyond.

It’s why many senior engineers shy away from fancy patterns, despise “clean code” and DRY and all these other rules that focus on some arbitrary structural quality of the code itself and not the problem it’s trying to solve.

It’s how so-called 10x or 100x engineers can go from an idea to selling software in under a week (with the right idea and the right customer, of course).

It’s why so many of us have a thousand half-finished side projects - because we were only ever building one end of the bridge. And it’s the lonely end.


Footnotes

  1. This, fundamentally, is the value of software - transferring complexity away from your users. This is a big, fundamental topic I intend to cover in upcoming articles.