Leveling up as a Software Engineer
This post contains some of the lessons I’ve learned in the past decade of growing my career. I hope they resonate with engineers of all levels, and help early to mid-level software engineers advance their careers. Some of the lessons may seem to be stating the obvious (eg “be organized”, “communicate effectively”), but spelling out exactly what they mean can provide the focus needed to make meaningful advances. Others are not as intuitive, but are instrumental in growing your skills and impact. You don’t have to be great at every one of these areas, but every little bit helps.
Don’t let code intimidate you
Documentation and collective understanding of how pieces of software work tend to be incomplete and atrophy over time. Source code, however, is always the complete and most accurate representation of a single piece of the system. Get comfortable reading code that you or anyone in your organization didn’t write — often that’s the easiest way to gain a full understanding of the module you’re working on, and it helps you understand its capabilities and constraints. A strong software engineer doesn’t shy away from digging deeper to reason about expected and observed behavior.
When reading code, don’t skip over areas where you don’t understand the need for complex business logic — often they are the result of quick fixes to solve very specific use cases, and are of vital importance to the business. It’s very easy to skip over those historical patches, and that misunderstanding can result in a fire drill if that code is moved around or lost. Another common complaint is code readability — perhaps it was written in a hurry, using ineffective abstractions, or was twisted into a pretzel to satisfy changing requirements. In those cases too it takes a bit more effort to understand the flow and what the code is trying to do, but at the end of the day it boils down to reading and understanding, and provides more value than documentation that may be out of date.
With time it becomes clear that code is actually the easiest part of understanding a system, it’s just text that you can read and step through if necessary. It’s usually the interaction model of different parts of a system and a mismatch in how a system is used versus how it expects to be used that gets you.
Maintain a healthy level of skepticism
It’s natural for people to project their own assumptions when presenting something they built or when debugging an obtuse error. A strong engineer questions assumptions until there is conclusive proof of the validity of the assumptions. Start with what you know to be invariant and build your way out from there, using theories backed up by evidence every step of the way. This is a common practice when constructing formal proofs — you start with a fully validated fact and build on top of it using logic and reasoning. Beginning with first principles and validating assumptions every step of the way makes weaknesses apparent; if debugging an issue, the problem presents itself as soon as your expected outcome doesn’t match your actual outcome. Don’t just consider the happy path where everything is bright and rosy — also consider what happens when the system is under stress or receives unexpected input. For example, when looking into a failing function call that goes through multiple layers, trace the path through each layer to see where the computation goes awry. Battle-tested third party libraries aren’t exempt from this — they’re written by other humans, and humans make mistakes.
There’s a relevant quote from Harry Potter and the Chamber of Secrets: “Never trust anything that can think for itself if you can’t see where it keeps its brain”. A lesson we can draw from that is to be wary of a system’s advertised capabilities — test out use cases you depend on to ensure that it actually does what it says it does.
One important caveat here: it isn’t always efficient to question everything, especially if there is low-hanging fruit you can go for first. When debugging, start with the highest-risk step before going through and validating every other step, especially if there’s a much higher chance of it being the one that fails. It can be counter-productive to start with areas that are more stable than areas that changed recently, for example.
Another case where being too thorough can hamstring you: when designing a system, you can get bogged down validating all of your assumptions, and so it is sometimes better to make a call to get moving and come back to it later when needed. As long as you don’t paint yourself into a corner with a bad choice of abstractions, you can make some progress and then revisit assumptions when needed.
Break things to understand their failure modes
No system is perfect — there are always trade-offs, inefficiencies and bugs. At some point you will run into them, whether it’s a system developed in-house or an industry standard. Instead of being hit by one at an inopportune time, make the effort to test the limits of your software and tools to observe how they fail and what the possible remediations are. Strong engineers take their time to use their tools in different ways, occasionally deviating from the paved path to observe the behavior and take notes on limitations. This way when you encounter one of these issues in the wild, it will be something you recognize and know how to deal with.
Avoid turning down learning opportunities
The world of software changes frequently, and business needs are always evolving. A savvy engineer takes time to pick up new kinds of projects and isn’t scared off by needing to dig into an area they haven’t had much exposure in previously. The challenging problems just outside your immediate domain provide the best return on investment where it comes to learning something new while not being completely out of your depth. Be the person to take the initiative on an obscure bug that others don’t necessarily want to pick up — the skills you gain in the process will more than compensate for the time spend banging your head on the wall trying to figure out what’s going on. This could involve digging into and making changes in code owned by another team or even in third-party software.
When you do find issues, don’t be afraid to go above and beyond your immediate responsibilities — put in the time to fix the issue and send a pull request to the owner. Apart from the skills you gaining tracking down and fixing the problem, this has the added benefit of building a rapport with the other individuals and earning you goodwill that may be useful later down the line.
Search for higher level points of leverage
An experienced engineer is always looking for inefficiencies and for opportunities to improve. In some cases this is as simple as a process tweak, but in other cases this can be an architecture change for an application to better fit a new use-case that doesn’t quite fit the existing architecture. Keep an eye out for common trends and complaints and spend some time noodling on a solution that can save time in the long run. For instance: invest time and effort in tooling to support services even if putting in the effort up-front is a daunting task, if doing so can greatly decrease operational burden.
Be proactive here rather than reactive — it’s easy to live with a particular pain point and for your pain threshold to move up with it as it gradually gets worse, but it takes initiative and discipline to take a step back and put effort into a multi-faceted improvement. Find the commonality of sets of disparate problems and then look for the higher-level abstraction — you’ll usually find that they are specific cases of a higher-level problem, and this will help you come up with a general solution.
Communicate at the right level
One of the more difficult aspects of the job of an engineer is communicating effectively. It can be difficult to divorce yourself from all the context you have gathered around a particular topic and break it down to a level where someone without that context can understand your point. Empathy doesn’t always come naturally — put yourself in the shoes of your target audience to figure out what level of explanation and vocabulary will allow you to transmit the necessary information.
Remember that although the topic may be clear in your head, your goal is implanting that clarity into somebody else’s without them having the same background and perspective. This goes for both verbal communication and written — always think about who you are gearing your communication to, and while it’s occasionally okay to assume a baseline level of context, assume they have less context than you think.
Organize your thoughts and task list
Self-organization is key for a software engineer. Your coworkers want to know that they can count on you to deliver and not let items fall through the cracks. This mean you need to always be on top of your near-term and long-term roadmaps. There are many ways to accomplish these, varying from lightweight mechanisms like keeping a running list in a text file to more sophisticated task management software such as Jira. The exact mechanism isn’t as relevant, but what’s important is that you capture work in progress and future tasks in a way that ensures you deliver on everything from quarterly goals to commitments made in hallway conversations. A dependable software engineer can be trusted to follow through on complex projects with a lot of moving pieces, and as you gain a reputation for being that person, you will get the opportunity to work on projects that will grow the depth of your skills and impact.
Solicit feedback continuously
Where it comes to self-evaluation, everyone has blind spots. The very nature of a blind spot means you aren’t aware of an area in which you may be lacking. Fortunately, a software engineering role is inherently collaborative, and the people you work with are the ones that are best positioned to provide feedback and help you improve. However, most people aren’t proactive at providing feedback, and it’s common to avoid conflict entirely, so the onus is on you to actively seek out feedback. A strong engineer works on extracting feedback on a regular basis, so that they can quickly be aware of areas they need to improve in and course-correct.
Feedback provided to you is often colored by the perspective of the individual giving you the feedback, so take it with a grain of salt. Even if you don’t necessarily agree with the specific feedback given, there’s usually a valuable higher level pointer you can take away. For example, it’s possible that you and the individual are misaligned on priorities, which results in them being upset about a situation where you ignored them in favor of what was a higher priority from your perspective but not theirs. A lesson to take away there is to do a better job at communicating priorities, and to align on them to avoid misunderstandings in the future.