notion image

What Does it Mean to be a Good Software Engineer?

Hello, early-career Software Engineers!
In this post, I want to precisely articulate what we should care about and why it matters when it comes to Software Engineering. I am amplifying the arguments that have been the most influential in my own early career.
I want to show a direct link between maximising value generation and the gestures we commonly associate with “being a good Software Engineer” - a pragmatic framework for Software Engineering. This framework attributes merit to the gestures of the Software Engineer based on the successes of their practical applications in generating value effectively.
I find that the majority of learning resources early on focus on tools and technologies to build software, but very few learning resources take a step back and take a look at the important lesson of what it even means to build good software.

What is my Job?

The what of every profession is the same - that is to generate value.
I then trade whatever “value” I generate for money. The more value I generate (if I can leverage this efficiently), the more money I will make. If I generate more value for my employer than my costs (in salary, training, etc.) and they can continue to leverage this value to the market, then there will remain a sustained position for me there - it’s simple arithmetic.
There are many configurations of how people go about performing this job (generating and leveraging “value”), resulting in a plethora of professions. Software Engineer is one value-generating configuration comprised of a set of complementary skills and tasks.
There are many dynamics to explore within this model (including the effects that uncertainty and risk play), however, I only want to highlight that the “what” and the “how” of your job are two different things - don’t mistake them!
Considering your job in terms of value generation means you can raise your eyes above your keyboard and look around at how to achieve this beyond tapping away at your laptop. There are many good reasons to care about maximising the value you generate at work, chief among which is that we tend to enjoy doing things that we are good at. Often, the better we make ourselves at generating value, the more rewarding we will find our jobs.

How do I Perform my Job?

At the time of writing, I work as a Full-Stack (web and mobile) Architect Developer at a software consultancy. When interfacing with clients, the way that I generate value is to “maximise the money I generate or save for the client” (this is a nice measurable metric).
These are the main value-generating activities I spend my time on:
  1. (Product Development) Develop features which meet the business requirements:
      • with speed whilst not compromising future speed for changing requirements.
  1. (Methodology) Reduce waste in product development:
      • identify the highest value features by measuring their impact on our heuristic for value (e.g. conversion rate when onboarding customers) and focusing on those;
      • reduce development costs by focusing on the minimum viable product (MVP) first;
      • problem-solving to increase team efficiency;
      • reduce the batch size to deliver value as soon as possible.
  1. (Expertise) Consult with non-technical clients to avoid costly mistakes in technical decisions.
Notice, that there are several activities that I spend some time on because - although they don’t directly involve programming - they are generating value. A well-intentioned challenge about the value a certain feature brings to users can often save our clients time and money from focusing on it more than necessary. I try to spend my time working on activities that generate value and on which I am best placed to contribute.
Aside: value generation in your workplace
Value generation in your workplace is likely very different from mine. I would recommend dedicating some time to figuring out what these activities look like for you.
If you notice value-generating activities in your workplace that you are best placed to work on (or no one else is doing), try allocating some time to doing them. Neglectedness of value-generating activities can be vital as to the impact that doing it will have (if everyone spends all their time programming, an extra 30 minutes of programming by you has diminishing returns; if you spend those 30 minutes on some other value-generating task, it can deliver proportionally more value - think the 80:20 principle).
You may need to convince your manager that spending time not programming is worth it. I could go into strategies to do this, but they are often very context-dependent. It’s often good to try a cost-benefit analysis in terms of whatever KPI your manager is optimising. Middle management can introduce misaligned incentive structures - know how your manager’s performance is measured and put everything in terms of this (speak the language). Use a shiny graph to visualise how much value you are generating.
Sometimes you may need to take a risk and do something without permission to show its benefits if you really believe in it.
Larger companies demand their employees to become more specialised - “let the Software Engineers focus on programming and we’ll get a Product Manager to specialise in other value-generating activities which don’t require a degree in Computer Science”, goes the thinking! For this reason, Software Engineers tend to spend the majority of their time doing one highly specialised value-generating activity: programming!
I wanted to highlight that programming is just one sub-set of the value-generating activities that might make up your real job. From here onwards, I want to focus on answering one question: what does it mean to maximise the value we generate whilst programming?
(Or equivalently, how can we be good Software Engineers?)

Which Properties of a Software System Deliver Value?

There’s a simple formula for calculating the worth of a software system:
  1. Behaviour of the system (behavioural constraints): what it allows users to accomplish.
    1. If it doesn’t solve a problem for the user → no users → no one pays for it → no value.
  1. Implementation of the system: how it was coded to work.
    1. If it’s difficult to correct undesirable behaviour → software soon becomes useless.
α and β are coefficients describing the ratio of importance of behaviour to implementation.

Behaviour is Independent of the Implementation

Consider two implementations of Hello World in JavaScript:
console.log('Hello world!')
// Full code: // Output: 'Hello world!' (()=>{})[({}+[])[+!![] + +!![] + +!![] + +!![] + /* [truncated] */ + +!![] + +!![] + +!![] + +!![] + +!![]))()
Both implementations have identical behaviour - the user can’t tell the difference between which program is producing the result (there are no noticeable performance differences).
If requirements change, and users will now only pay for a program which prints Hello James!, the second program is rendered worthless by its implementation.
By definition, if a user receives different feedback from two different programs, those programs have different behaviour. Varying implementations can change the behaviour of the system (e.g. two algorithms with similar but distinguishable results), however, there exist many different implementations which produce identical behaviour; therefore we can say, that behaviour and implementation are independent from one another.
I like using the words “behavioural constraints”, because it’s a useful way to frame that meeting user requirements is necessary but not sufficient for a good solution. There are almost an infinite number of ways to deliver the same features (different languages, frameworks, architectures); the only thing that non-technical stakeholders care about is the behaviour - we engineers are free to implement the system however we want - only being constrained by ensuring the system behaves as users expect.
Non-functional requirements are part of the behaviour of the system - these are still behavioural constraints of the system which we negotiate with stakeholders of the product.
Non-functional requirements change from system to system because behaviour is entirely driven by the value it delivers to users (if we care about performance, for example, we only care because the users are demanding better performance - we don’t care implicitly!).
By considering non-functional requirements as behaviour (alongside user stories), we also highlight them for negotiation with our stakeholders which prevents assumptions about them on both sides.

Defining Tech Quality

I’ve taken the time to define Software Craftsperson as someone who is working towards this goal.
Software Craftsperson: implements the appropriate tech quality standards to minimise the resources required to build and maintain the desired behaviour of a software system.
“maintain” is highlighted here, because the system is in maintenance far longer than it is in the “build” phase of its life, so there is potentially more value gained by focusing on the long-term.
It’s time to introduce my definition of Tech Quality (roughly from Clean Architecture). This puts “maximising value” into slightly more concrete terms:
Tech Quality: a measure of how tolerant a system is to changing the requirements without introducing defects.

How can we Maximise Tech Quality in our Systems?

Assuming we have two systems, A and B, the best implementation is “that which is the most tolerant to changing requirements”. This factor is more important than the considerations given to non-functional characteristics.
Consider A is more tolerant to changing requirements and B is more performant and more secure.
A is the better implementation as it meets the users’ expectations in terms of performance and security, and although B offers more performance and more security, the extra value delivered comes at diminishing returns (do the users care about an extra 0.5ms render time?). In delivering this extra non-functional value, system B is sacrificing the greater value - tolerance to changing requirements.
If both implementations are equally tolerant to change, then the one which can be implemented quickest is the best - if both are equally quick to implement, then the one which satisfies more non-functional requirements becomes the best implementation (delivers most value).
So, why is tolerance to changing requirements so important? Let’s consider a thought experiment.
Between two implementations satisfying the same behavioural constraints:
  • The program that behaves perfectly but is impossible to change won’t work when requirements change, and I won’t be able to make it work. The program will cease generating value and becomes worthless.
  • The program that does not behave correctly but is easy to change can be made to work quickly, and I can keep it working as requirements change. The program will perpetually generate the maximum value (once I make it behave as expected initially).
When we look at two implementations of a solution, we often have a gut feeling that one is preferable; now we have a definition and criteria by which we can make technical decisions on how to implement a system! This definition is directly linked to maximising the value you generate whilst programming!
When evaluating a solution, ask yourself, “Will this implementation enable another engineer to come back and easily change the behaviour of the system in the future?”. This is a metric we can use to orient ourselves when creating technical standards or reviewing code.

The Total Cost of Owning a Mess - Poor Implementation

If you have been a Software Engineer for a while now, you will have been slowed down by someone else’s code.
Once rapid teams can be brought to a snail’s pace in a short amount of time. As the team slows, they try to counteract this by cutting corners and so the mess grows. It’s easy to make clean code messy; it’s even easier to make messy code messier!
Eventually, the code gets knotted up - one change here breaks something unrelated over there. No change is trivial and the knots must be understood before new behaviour can be added. Those who understand the twists of this particular mess become bus factors (I’ve seen real-life examples where an entire system has been rendered practically unchangeable because the one guy maintaining it got “hit by a bus”).
As productivity grinds to a halt, management does the one thing it can think to do - “we need more developers” (hint: they don’t need more developers). As more developers join, they pile on more mess, making the team even slower.
Before too long we’re working with a legacy codebase. Developers resent being put on “that project” and eventually a coup d’êtat brews. Changes to the system become too expensive and a new team is formed to rewrite the system.
Initially, work is fast and developers fight to get on the project, however, it won’t be put into production until “it does everything that the old one does”. In the meantime, development continues on the old system - and so a race begins which may take years to finish; by the time that it’s over the “new” system may already a be legacy codebase itself!
So, how do good implementations (or, building software which is tolerant to changing behavioural constraints) deliver value? Good implementation means that:
  1. we can quickly and continuously improve the system to optimise the value delivered;
  1. the system is long-lived, so delivers value to users for longer;
  1. costs of maintaining the system are minimal so the net value is greater;
  1. developers enjoy working with the system so talent is retained within the company.

Tradable Quality Hypothesis: Competing Incentives

Hopefully, I’ve articulated the significance of implementing good solutions, but now that you care about Tech Quality, you start to come to blows with your stakeholders, “You’re not delivering what we want in time! Why are you spending all this time on ‘refactoring’? We don’t have time to be spending on automated tests when we have to develop the features that our users want”.
Sometimes, non-technical stakeholders expect that spending less time on “quality exercises” (e.g. refactoring/ writing tests) and more time coding features leads to increased speed. This is called the Tradable Quality Hypothesis.
In reality, the good Software Engineer is always doing everything he can to develop as fast as possible whilst not sacrificing future speed. These quality exercises are the things that enable us to continue coding new features quickly (you will know this if you have worked on a mess before). This impression of clashing incentives is an illusion - it’s just difficult for stakeholders to see all the things that aren’t going wrong in the future because we’re not rushing (it’s a thankless task!).

Professionalism - Who Champions the Implementation?

Although implementation has a significant impact on project delivery, I do not expect non-technical stakeholders to be champions of good implementation. This is because they don’t look at the code, and even if they did, it would be difficult for them to identify the quality of the implementation and precisely how it makes their developers slower. They want the system to behave a certain way and don’t really care how we do it (as long as we do it quickly). They often have no concept of how the costs of making a change can vary massively between different implementations.
It is our job as Software Engineers to communicate what is difficult for non-technical stakeholders to see and understand. Once clearly communicated, the decision which generates the most value can be made. Put it in terms of the things non-technical stakeholders care about - the cost of extra speed now is the hit taken to the speed of future delivery of all features for the lifetime of the system.
It would be a strange world if other professionals behaved like some Software Engineers. For example, when I get a plumber in to fix my sink, I don’t negotiate with her how many leaks I want in it by the time she’s finished - I accept that the professional knows how best to do her own job. In the same way, I doubt many stakeholders would be happy if I designed a system that was so complex that in a year’s time, it would be cheaper to start over completely than add new functionality to the existing system. Please don’t ask your manager for permission to refactor - imagine if your plumber asked for permission to fit a new valve in order to prevent an impending leak (if you’re anything like me, and know nothing about plumbing, you’d be confused as to why she was asking you how to do her job well).
Don’t let non-technical stakeholders dictate how you should be doing your job (even worse, don’t ask them how you should be doing your job - things will get really rough!). You shouldn’t need to ask permission to do a refactor - you’re the expert, you know best!
If we craftspeople won’t defend the implementation, who will?
Aside: can you still be a good Software Engineer whilst not maximising the Tech Quality in your systems?
By this definition, I can think of some instances where it’s OK to not maximise Tech Quality - exceptional circumstances where a system doesn’t need to be maintained.
Perhaps we are building an MVP which will be scrapped and rebuilt once a value hypothesis is validated - in this case, we might accept the costs of a quick-and-dirty implementation.
Perhaps the cost of building the most maintainable system is so high that it's not worth it. In this case, you may design the second most maintainable system which has ninety per cent of the maintainability of the most maintainable system for a fraction of the cost.
Although it’s frustrating when people don’t let me practice my craft - I’m OK with not delivering the highest quality solution at all costs, so long as it’s a conscious decision to sacrifice quality for bigger gains in value elsewhere (or perhaps we just don't have the budget for the highest quality solution). Usually, I am sceptical when people call for quick-and-dirty - it usually comes back to bite later on down the line!
I’m not advocating for writing the best implementation at all costs, just ensuring that we are always working on the most value-generating activities in the most effective way.

Tech Quality Heuristics

When I ask someone why they made a technical decision, or what they value in Tech Quality, I get varying responses. Below are some things that people mention; see how all of these fall under the umbrella of increasing tolerance to changing requirements:
  • KISS (keep it simple stupid) - reduce the complexity of solutions;
  • DRY (don’t repeat yourself) - reduce dev time to build the same behaviour;
  • DRY (do repeat yourself) - don’t write an abstraction that increases coupling of the system;
  • functions should SLAP(!) (Single Level of Abstraction Principle);
  • create clean boundaries in the code - allowing old code to be easily swapped out;
  • ensure high-level business logic doesn’t depend on low-level implementation details;
  • write automated tests - reduce the likelihood of regressions (TDD’ers will find it ironic that I wrote about tests last!).
The way that we build quality into our systems is by applying the many patterns and practices (e.g. the Dependency Inversion Principle) that make the systems more flexible to change. Applying patterns in the wrong place or using an extensive enterprise framework where it’s not needed is anti-quality as it takes more time to implement and is more difficult to change. Most software engineers go through a phase of learning some cool patterns or frameworks and applying them everywhere - I’m not an advocate of this.

Putting it All Together: How to be a Good Software Engineer

The Goal: to become the best Software Engineers we can be.
What this means: maximising the value we generate whilst programming.
How can we do this:
Deliver the best possible implementation of a system which meets the behavioural constraints.
(how) → The best implementation is that which minimises the resources required to build and maintain the desired behaviour of the system.
(how) → We minimise the resources required to build and maintain a system by making it tolerant to changing requirements.
(how) → We learn to follow coding patterns and optimise for other heuristics which make the system more tolerant to change.
(how) → Learning (through practice, listening to experts, and hard work!).
Other learnings:
  1. Everyone’s job is to generate value.
  1. There are many value-generating configurations (professions) - Software Engineers mostly focus on one value-generating activity - programming!
  1. The value of a software system is roughly behaviour * implementation.
  1. The behaviour and implementation of the system are independent variables - non-functional requirements are behavioural constraints.
  1. Being a good Software Engineer is “delivering the best possible implementation of a system which meets the constraints of the desired behaviour”.
  1. The “best possible implementation” is “that which is the most tolerant to changing requirements” (we write software not hardware).
  1. Bad implementations of systems can lead to disastrous net losses due to maintenance costs.
  1. We can deliver value by communicating the costs of worse implementations.
  1. Managers and Engineers do not have competing incentives - quality exercises (e.g. refactoring) enable us to continuously make changes to the behaviour of the system.
  1. Tech Quality is defined as a property of technical systems that minimises the resources required to build and maintain the desired behaviour of the system.

But Wait, There’s More…

In this article, I wanted to share my main learnings from reading books such as Clean Architecture, Clean Code, Refactoring, The Software Craftsman etc..
I want to amplify the years of wisdom contained in these books so that we early-career Software Engineers are not doomed to relive the mistakes of the past.
If you’re interested in learning more, I’d recommend picking up something in the Robert C. Martin Series!
Happy reading!