A CTO’s 3 Simple Steps to Writing Better Code

Riaan Nel
10 min readOct 4, 2024

--

I started programming when I was at school and I haven’t stopped since then — going through my career as a developer, architect, and eventually a CTO. I’ve written lots of code in my life. I can’t tell you exactly how much code, but it might be millions of lines by now.

In this article, I want to share some lessons that I’ve learnt throughout my career that I believe will help you to write better code. You should see an underlying theme — it’s all about reducing complexity and making it easier to work with code. This isn’t about particular tools or frameworks or architectural styles — it’s about fundamentals.

I’ll be honest and admit that it’s been a while since I actually wrote code as part of my day job, but side projects make up for that. Though you write less code as an architect, CTO, or engineering manager than you did as a a developer, it’s important to not completely lose touch with the detail — even if that just means that you do the occasional code review. While I might not be writing as much code anymore, I still look at lots of code.

I’m not going to promise you a magical recipe that will instantly make you a great developer. After all, there are no silver bullets. But with that said, I believe programming is a craft that you can continue to practice and get better at. There’s always more to learn.

I should note that my background is primarily in object-oriented languages like Java and C#, so these steps are biased towards OO languages. My examples use Java, but the lessons will apply to other languages as well.

Photo by Mohammad Rahmani on Unsplash

Step 1: Keep it Simple

“Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. …[Therefore,] making it easy to read makes it easier to write.”

Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship

We spend more time reading code than writing code.

Whenever you start working on a piece of existing code, whether it was written by you or someone else, you’ll start by reading through the code and figuring out what it does. When you debug a piece of a code, you have to read through it in order to understand it. It stands to reason, therefore, that the simpler and easier your code is to read, the more productive your time becomes.

So, how do you simplify your code then?

Write small methods

Keep your methods small. Small methods fit on a screen, and if you can see the whole method, it reduces the cognitive load of trying to remember pieces of code you’ve scrolled past. There are no rules that say you can’t have methods with only two or three lines of code.

Use names to express purpose

Give names to things. Sometimes, it’s not immediately obvious what a piece of code does – especially when it combines a few different statements or runs over multiple lines. One of the options you have is to add comments to your code, but comments get out of sync and no longer accurately reflect what the code is doing, or someone else adds a line of code between your code and the comment and it no longer makes sense. An alternative, safer option is to extract a piece of code into its own method. This allows you to give it a name, which expresses its purpose. In fact, this provides a justification for one-liner methods.

Imagine that we’re coding a game, and we have this bit of code:

if (health < 10 && distance < 100 && other.getType() == EntityType.OPPONENT && !shield) {
//Run away
}

We can see it does something that results in a character running away — but what do all those values mean?

Here’s an alternative version.

if (playerInDanger(health, distance, other, shield)) {
//Run away
}

...

private boolean playerInDanger(int health, int distance, Entity other, boolean shield) {
return health < 10 && distance < 100 && other.getType() == EntityType.OPPONENT && !shield;
}

Yes, we do have more code now — but it’s much easier to interpret it and see what it’s doing, because we’ve taken the big bulky if check and we’ve essentially given it a name.

Use descriptive names

Following on from the previous point – when you name your methods and variables, give them good, descriptive names. X is a poor variable name, unless it refers to the x-value of a set of coordinates. Even accepted standards should be questioned sometimes. i and j are widely accepted names for iterator variables, but if the 2D array that you are looping through represents some kind of table structure, wouldn’t row and column be better names?

Photo by Mika Baumeister on Unsplash

Step 2: Make it Easy to Test

If code is testable via unit tests, it easier to get right because you give yourself a very short feedback loop for finding issues. That is, you click a button to run your tests and seconds later you can know if something is broken.

The cost of fixing defects increases as you move through the different stages of development. Fixing bugs at as soon as they are introduced is quick and easy. If a bug makes it all the way to production before being discovered, fixing it becomes an expensive, painful exercise.

Which brings us to the next question – how do you make it easy to unit test code? The items discussed in step #1 all contribute to making testing easier, but I’m adding a few more to the list here.

Be explicit about what methods do

Methods should be explicit about what they do. When a method makes a change to state outside of the scope of the method itself, it’s called a “side-effect”. Side effects introduce complexity because they can force you, as a developer, to jump around throughout the code base to try and figure out how a value is set, or worse — you won’t realize that a value has been changed, and end up using incorrect data.

Imagine that we are calculating a tax on a transaction object, using the code below.

public double calculateTax(Transaction transaction, double taxRate) {
double tax = transaction.getAmount() * taxRate;

transaction.addTax(tax); //Oh no, side-effect! The Transaction object is being changed.

return tax;
}

...

//Calling code
double tax = taxCalculator.calculateTax(transaction);
transaction.addTax(tax);

See the issue? It’s not immediately obvious that the calculateTax(…) method also adds the tax to the transaction. The net result here is that tax on the transaction is now double. You might think that we can just remove the line to update the tax from the calling code — but there are two problems with that approach. 1.) What if we just wanted to calculate tax to display to a user, but we’ve already updated the transaction? 2.) It’s not intuitive that it’s being updated in the calculation method, so chances are you (or another developer) will make the same mistake again.

Make state changes obvious and avoid side effects when you can. This means that it’s less likely that your tests will miss unintended details.

Code to interfaces

The Dependency Inversion Principle states that “Abstractions should not depend on details. Details should depend on abstractions”. Alternatively, popular programming wisdom dictates that you must “code to an interface”. In summary, this means that you should limit the dependency your code has on anything that’s outside of your control. Think back to our TaxCalculator example — imagine that the TaxCalculator makes a call to a database to obtain the tax rate.

public double calculateTax(Transaction transaction) {
double taxRate = taxDao.getEffectiveTaxRate();

double tax = transaction.getAmount() * taxRate;

transaction.addTax(tax); //Oh no, side-effect! The Transaction object is being changed.

return tax;
}

Now, let’s imagine that we want to write a test to ensure that the code below correctly adds tax to a transaction.

public Transaction createTransactionWithTax(String item, double price, int quantity) {
Transaction transaction = new Transaction();
transaction.setItem(item);
transaction.setAmount(price * quantity);

transaction.addTax(taxCalculator.calculateTax(transaction));

return transaction;
}

So, we write a test case like this (note the Arrange, Act, Assert sequence).

//Arrange
OnlineStore onlineStore = new OnlineStore();

//Act
Transaction transaction = onlineStore.createTransactionWithTax("Book", 50, 2);

//Assert
assertEquals(15, transaction.getTax());

Effectively, we just want to know that if we purchase two items at price of $50 each and apply a 15% tax rate, total tax is correctly applied and set on the transaction. To be clear — this test is not testing the tax calculation, but it is testing that tax is correctly set on a transaction. This is a very simple piece of code… but because of a dependency on a DAO and a database, to run this test, you need to spin up an entire database instance to return one value. And if anything fails with all of the unrelated complexity arising from a database, your test fails. That’s really not great, but the fix is simple. Instead of using a concrete class for our TaxCalculator, we just use an interface.

public interface TaxCalculator {
double calculateTax(Transaction transaction);
}

For production code, we still use a concrete implementation of this calculator. You can call it whatever you want.

public class FixedTaxCalculator implements TaxCalculator {
...
}

In your unit test, you can now inject a mocked version of the calculator. Something like Spring makes this easy to do, but for the sake of simplicity, I’m just setting it explicitly here. The mock allows us to return a particular value.

//Arrange
TaxCalculator mockTaxCalculator = mock(TaxCalculator.class);
when(mockTaxCalculator.calculateTax(any())).thenReturn(15.00);

OnlineStore onlineStore = new OnlineStore();
onlineStore.setTaxCalculator(mockTaxCalculator);

//Act
Transaction transaction = onlineStore.createTransactionWithTax("Book", 50, 2);

//Assert
assertEquals(15, transaction.getTax());

Instead of focussing on getting a database instance up and running, the test can now focus on what actually matters — testing a calculation. This is made possible by coding to an interface.

Photo by Riku Lu on Unsplash

Step 3: Make it Easy to Debug

Now for the bad news – things will still go wrong, even if you write simple, maintainable, testable code. The third step we’re going to discuss entails how you can make it easier to identify and unpack issues when they do inevitably happen. I’ve discussed debugging in detail in another article, but I’m going to run through a few practices here as well.

Use proper logging practices

When code is running in an environment other than your local machine, you are probably not going to be staring at it in real-time. If something goes wrong, you’ll only know about it after the fact (even if you have excellent alerting and you find out within seconds or minutes – it’s already happened). When you find out about it, you need to have enough detail to track down the problem. That’s where logging comes in.

One of the most common issues I’ve seen with logging is a lack of context. If you are running a single transaction on your local environment, this is fine.

LOGGER.info("Created transaction");

However, what happens in production when you are processing high volumes of transactions? These logs become useless.

Created transaction
Created transaction
Created transaction
Created transaction
Created transaction
Created transaction
...

Thus, when you write logs, include the right context. The context is subjective and it will be up to you to decide what to use, but in our example, the log entry below will make it much easier to track things down in log files.

LOGGER.info(String.format("Created transaction.  Reference: %s, Timestamp: %d, Item: %s", transaction.getReference(), transaction.getTimestamp(), transaction.getItem()));

In addition – when you’re logging in an environment with multiple transactions being processed simultaneously, you can’t assume that log entries will be in particular sequence. This code is dangerous.

LOGGER.info("Created transaction.");
LOGGER.info(String.format("Reference: %s", transaction.getReference()));
LOGGER.info(String.format("Timestamp: %d", transaction.getTimestamp()));
LOGGER.info(String.format("Item: %s", transaction.getItem()));

In practice, this recreates the context issue we discussed earlier. If multiple transactions are being processed simultaneously, we lose context. Which entry here belongs to which transaction?

Created transaction.
Created transaction.
Reference: a1b229a0-3225-4192-9fe8-d765245a38d9
Timestamp: 1728052035157
Reference: d66ee081-8c05-4a9c-8eb1-163f16e7ae47
Timestamp: 1728052035146
Item: Book
Created transaction.
Item: Chair
Reference: c541fd9a-cab1-4906-b3c3-69a621359b91
Timestamp: 1728052035157
Item: Table
Created transaction.
Reference: d8d2e67c-a8b7-4a86-b346-7d3390d3cd38
Timestamp: 1728052035157

To keep the related logs together, remember to make a single log call. Combining those two pieces of advice gets us sensible, useful log entries.

Created transaction.  Reference: 67f48b11-96c5-4951-8c49-69db40a89b49, Timestamp: 1728055454682, Item: Book
Created transaction. Reference: 0bbdcf17-c381-482e-b6c1-bc4bd438cb99, Timestamp: 1728055454694, Item: Chair
Created transaction. Reference: 6fa4f53f-08d9-44ed-8cdc-0111a4cd9d89, Timestamp: 1728055454694, Item: Table

Use exceptions appropriately

Log entries allow you to trace how a piece of code executed. Exceptions tell you when something actually went wrong.

Similar to what we discussed around log entries, make sure that your exception messages are detailed and provide enough context to tell you want went wrong.

This is a bad example of using an exception. It doesn’t tell me anything about what went wrong.

throw new IllegalStateException();

This is better.

throw new IllegalStateException("TaxCalculator has not been initialized yet.");

Part of the context of an exception is the trail of exceptions that lead to it. Don’t swallow exceptions and don’t throw new exceptions without passing along the original exception, unless you provide enough detail to determine the root cause.

Here’s another bad example — this doesn’t give me context on the cause of the issue, nor does it pass along the details of where it happened.

try {
//Some code
} catch (NullPointerException ex) {
throw new ApplicationException("Something went wrong.");
}

This is better.

try {
//Some code
} catch (NullPointerException ex) {
throw new ApplicationException("Application failed to invoke TaxCalculator.", ex); //Pass the exception that caused the problem.
}
Photo by Temple Cerulean on Unsplash

Summary

In summary, the key message that I’d like you to take from this article is that simple code is generally better than complex code.

Whether it relates to code structure, testability, or debugging issues, make sure that you keep your things as simple as they can possibly be. It’s very likely that you going to have to read and debug your code at some point — so make it easier for your future self!

Recommended Reading

Here are two books that I’ve found to be good guides to writing better code. Again, there are no silver bullets — so put these in your toolbox and use them when appropriate, but remember that they are not a set immutable rules that always apply.

Clean Code: A Handbook of Agile Software Craftsmanship by Robert C. Martin

The Pragmatic Programmer: From Journeyman to Master by Andrew Hunt and David Thomas

Disclaimer: As always, the views expressed in this post are my own.

--

--

Riaan Nel
Riaan Nel

Written by Riaan Nel

Professional caffeine consumer and husband. I also write code, write things that are not code, read books and dabble in business. Views are my own.

Responses (1)