editione1.0.1
Updated August 7, 2023While somewhat counterintuitive, a common path to adding value is to ship code that reduces cost for your customers or for your company. If a product solves a customer’s pain point better, faster, or cheaper than if they built it themselves, the customer benefits from a return on their investment. When a customer purchases your company’s product, they’re doing it in order to free up capital or other resources. This allows them to shift resources to help grow other parts of their business. It’s a win-win for both your customers and your company.
While saving your customers money is a great way to add value for your company, writing code that reduces cost for your employer is another opportunity for you to have a significant impact. Operational efficiencies are vital in highly competitive industries, as that can make the difference in whether the company makes or loses money in a given quarter or year.
Businesses are incentivized to control their costs and increase their efficiency as they work to produce more goods and services with less resources. This usually means building internal automation tools, scaling existing infrastructure, and streamlining processes to gain efficiency. While you may not get to work on projects like these right away, there are things you can focus on that help reduce the cost of delivering software, such as writing clean and modular code.
Clean, modular, and extendable code adds value by reducing the amount of time it takes to make future changes to the codebase. Business requirements always change over time, and that requires frequent modifications to the codebase to support the requirements. Let’s look at some ways in which you can add modularity and extensibility to your codebase so you can ship more code with less cost.
Different customers have different requirements, so ideally, we’d write software that can be configured to each user’s individual needs and preferences—all while using the same codebase. It’s generally considered a bad practice to hard-code configuration values because, at some point, you may need to change that value. If it’s hard-coded into the codebase, that means you’ll need to modify the code and deploy a new release in order to change the configuration, which is time-consuming and adds risk.
When we hard-code logic for specific configurations in our codebase, programs become rigid and inflexible, making it difficult to modify the system as our customer’s requirements change. Therefore, it’s better to build our systems in a way that can easily handle changing requirements. Whether you need to add a new shipping option, toggle a feature on or off, or increase the threshold for some limit, try to build it in a way that lets you or someone else update those values without needing to deploy new code. When you separate configuration values from the application itself, you’re able to modify the software to fit customers’ needs with less time and effort.
Existing applications probably already have some sort of configuration system, so it’s important to understand how it works. Try to learn what its possibilities are and also where the functionality falls short. In almost all cases, it’s better to use the existing configuration system rather than build your own from scratch.
It’s often difficult for new programmers to think in abstract concepts when writing code. It often comes down to the fact that you don’t know what you don’t know, and that’s okay. Sometimes it’s hard to think about how to abstract code if you’ve never seen it done before. Luckily, an easy solution to build this skill is to study other people’s code.
The single best way to learn abstractions is to study code written by experienced engineers. An experienced engineer will write code abstractions in order to be able to adapt that code to many different use cases. By writing code that is abstract, they can easily extend or reuse the logic as requirements change because the code is designed to be used in multiple scenarios.
A well-abstracted part of a codebase allows teams to make changes quickly when they need to support new requirements for the business. This allows them to deliver value more efficiently, which saves the company money and allows the programmers to move on to the next task.
Unfortunately, there’s no silver bullet to learning this, as it is both an art and a science. Over time, you’ll learn how to identify opportunities where code can and should be abstracted to handle different use cases.
The more experience you gain during your career, the more natural it’ll be to design your programs with abstractions in mind. You’ll see how other developers separate their logic, which will influence how you write your own code. Writing modular code is a skill that takes years to develop, but finding time to study code written by experienced engineers or in popular open-source projects will help you learn and identify patterns you can reuse. You’ll pick up techniques and start to incorporate them into the code you write, and soon enough, you’ll be able to write abstracted and modular code.
Sometimes, small abstractions can have a big impact on how quickly you’re able to extend the logic, saving you time and resources, so it’s worth taking the time to build that skill. But be careful, though. As you learn to think about abstractions and build them into your codebase, keep in mind that every new level of abstraction added will increase complexity and the cognitive load you’ll need to fully understand how your program works. Additional levels of abstraction also often come with a performance cost, so it’s important to consider the trade-offs involved with introducing new abstractions in your code.
For most engineers, writing automated tests feels like a chore at first, but over time, you’ll understand why automated testing is critical in the software development lifecycle. There are many benefits to building out good code coverage with a test suite, but the most important reasons we’ll look into are:
To catch bugs earlier in the process before they hit production.
To reduce the burden on QA engineers from having to manually test each part of the code to ensure the next changes don’t introduce regressions.
That’s it right there. Those two bullet points address issues that are incredibly costly to an engineering organization, so if you can ensure all of the changes you make have corresponding test coverage, you’ll be able to ship code that helps reduce costs for your company. You’ll know your code works according to your test cases, so the QA engineers can focus on testing other parts of the application or building test automation.
A bug is cheap and easy to fix in your local environment, but it becomes exponentially more costly to fix a defect that’s moved through staging environments and been deployed to production. The cost is not only in the time and resources needed to identify, fix, test the fix, and deploy the updated code to the affected environments, but also in the opportunity cost of not being able to work on another task that could build value, not to mention any cost of mitigating damage done by the bug. If you can reduce the amount of bugs introduced into staging and production environments, you can reduce the cost associated with shipping code.
Building value with an automated test suite is similar to forming a new habit—it’s going to require some real effort, and it’s not going to happen overnight. But if you chip away at it with every code commit, you’ll eventually have good code coverage on all new code written, and you’ll hopefully build a healthy culture around testing within your team.
Having an automated test suite with good code coverage improves the efficiency with which you can confidently make changes to the codebase. You’ll be able to quickly refactor and make modifications to your business logic while knowing that you’re not breaking existing functionality. Software is complex, and there are often unintended consequences from our code changes. A test suite with sufficient code coverage will help you catch those side effects before they make it to staging or production, leading to higher-quality software and more time to work on tasks that build value.
An important point to understand, however, is that your tests are only as good as the assertions they make. Your tests can only catch behavior that you’re explicitly testing for, so don’t expect your test suite to catch every bug for you. Tests can be incomplete, and they can have bugs, just like all code.
caution Just because your tests are passing does not mean your code is error-free.
Now that we’ve covered how tests help reduce the cost of shipping software, let’s look at how and when to write tests.
Any time you’re fixing a bug, include at least one unit or functional test with your code changes if you can. The benefits are twofold—first, you’re adding a test to the suite that ensures that the bug is in fact fixed, because you can set up a test case with steps to reproduce the bug and to prove with assertions that the bug no longer exists. Second, you now have tests written that will catch that bug in the future in case you or another engineer make changes that accidentally reintroduces it.
The complexity of a codebase increases over time. As new developers join the team, experienced developers move on to new roles. Business requirements change and parts of the codebase will need to be rewritten. The more lines of code you’re rewriting, the riskier the refactor becomes. So, how do you replace old code in a production environment without breaking anything?
Automated tests are not the silver bullet, but they’re a crucial tool that can be used to ensure you’re not breaking existing functionality. When refactoring code, the goal is to make sure you have tests in place before you begin making changes to the structure and logic of the code.
This helps define the existing functionality: how the system should work. As you refactor your code, run your test suite periodically to ensure that any new changes you make aren’t breaking the expected behavior. If your tests are still passing, you’ll be confident the system is still working as intended with the modified code.
It’s always fun to write something new rather than fix or extend code that someone else has written. You get to choose the names for your methods and variables, design the APIs, and come up with a clean solution. As you’re building out new features in your codebase, think about how you’re going to test the code that you’re writing.
Ideally, all new features you add should include automated tests. This isn’t always possible, but try to limit the situations in which you commit code that doesn’t have associated test coverage. If for some reason the code is difficult to test, try asking yourself how you can modify it to make it easier to test in the future.
A proper automated testing pipeline and a test suite with good test coverage adds a significant amount of value and can reduce the amount of cost associated with writing software. But automated testing does not ensure you’re producing quality software. There are trade-offs you should be aware of when writing tests.
It’s entirely possible to write bad tests. Some of the features we build and bugs we fix are complicated. You may find yourself writing dozens of lines of code to set up the data for a test, which introduces opportunities for bugs within your tests.
exampleHere are some examples of how tests can be bad:
Incorrect tests. These are tests that test the wrong behavior of a piece of code, or tests that pass when they should actually fail. This can be misleading since you think your code is well tested when in fact it isn’t.
Poor code coverage. The tests do not cover all of the code paths or edge cases needed in order to be confident a piece of code works the way it should. If there are parts of a system (whether a few lines in a function or entire files in a module) that are not covered by tests, you may be missing critical bugs.
Poor maintainability. If you have tests that frequently fail or fail inconsistently when other parts of the system change, your tests will be difficult to maintain. Additionally, tests need to be updated as the underlying code they’re testing is modified and refactored. Tests are code, after all, and will need to be maintained along with the core business logic.
Nondeterministic tests. These are tests whose behavior is not consistently reproducible. For example, if a test generates random test data, then your tests will not run under the same conditions every time. Some data may cause a test to fail, but it will be hard to reproduce because the test will generate different data the next time it runs.
Designing good tests takes some skill, but with a little practice, you can learn good testing habits in no time. The main idea is to build towards correctness and determinism, then increase coverage and maintainability as much as possible.
Here are things you can do to ensure tests are isolated and consistent:
Make sure your test data is the same every time. It’s okay to generate fake data dynamically, but be aware that test data that changes for each run may introduce unreliable results, causing your tests to fail intermittently.
Make sure your tests do not rely on shared state, such as data from a database or cache that other tests are also modifying. Ideally, each test sets up its own data and cleans up the data after itself.
Each test should have one responsibility and should be testing one thing. Tests that make multiple assertions are more prone to being unreliable.
It’s important to remember that faulty or incomplete tests can actually have a negative impact on productivity for you and your team. All code you write needs to be maintained, even the tests. When a test breaks, it’ll need to be fixed, which means taking time out of your day to track down the failing test, reproduce the failure, and fix it. And any time you spend fixing a broken test is not time you’re spending adding value for your company and your customers.
It takes a fair amount of work to build and maintain a test suite with good code coverage, but don’t let the amount of work deter you from doing it. Anyone with a little discipline can build a valuable test suite, but sometimes you’ll need to hold your teammates accountable when they try to commit code without test coverage. As with all good habits, it will take time at first to build a foundation, but once it’s there it’ll help reduce costs over time.
You may be fortunate enough to work for a company or a team that has a dedicated team of test engineers that maintain some or all of the automated test suites. While having a team responsible for testing code quality is helpful, it does not mean you are free to add features and make changes without writing tests for your own code. All engineers on a team share the burden of making sure the code works correctly, so it’s important to work together with other developers and test engineers to ensure good code quality. You will most likely still be responsible for writing unit or functional tests, and the test engineers will be focused on writing integration and end-to-end tests for the system as a whole.
While some people may embrace a well-defined process that provides structure, it may bring a negative connotation to others who value autonomy and independence. At some point, all programmers feel the frustration of dealing with “red tape,” or the lengthy process to gather the necessary approvals to move forward with a decision. But processes aren’t always a bad thing, as they provide a tremendous amount of value.
Whether you work for a large Fortune 500 enterprise, a development agency, a small scrappy startup, or even freelance for a living, you will follow processes every day and probably even develop new processes. Building a process provides value because it allows you to standardize a set of steps to complete specific tasks. By doing so, you are able to increase efficiency and scale your throughput by training others to follow the process.
When you take a set of steps, which may need to be done in a specific order, and formalize them into a process that can be followed by anyone anytime that task needs to be completed, you add value by creating consistency. You’ve defined a standard way of doing something that can be completed the same way every time, regardless of who is performing the task.