Produce high-quality, resilient software while retaining team-members and preserving morale.

Table of Contents

Summary

Software engineering at scale can be just as much a war of attrition against a seemingly insurmountable mountain of frustration as it is a test of raw, problem-solving skill. Producing performant, resilient, maintainable software that meets modern user, product and quality requirements is hard, and it’s only getting harder.

Because of this, we are frequently reminded of the consequences of this endeavor: extreme burnout, employee turnover, lack of fulfillment and chronic stress seen in the workforce.

This problem is well-known. A plethora of YouTube videos, blog posts, tweets … go into length about how pervasive this problem is within our community. This shouldn’t be a surprise to most, so if we’re aware of the problem, why does it still remain?

The common solutions often thrown at the problem are distracting at best or amplifying the problem at worst. Free lunches, ping-pong tables, beer kegs, untracked time-off … These are often marketed as solutions to help the team have fun, de-stress or blow-off steam, but it’s clear the novelty wears off, and we are back to square one. Trying to build a culture of “fun” does not work. It can often backfire, leading to a fraternity-like culture.

If we are to “raise the bar” for our collective engineering capability, we need to look at the most important part of technology: the humans that are behind its creation.

The technology we produce is inescapably linked to the human collective that created it. You want better software? You need a better culture.

So, how do we create a better culture? We need to explicitly agree upon what we believe attributes to a better community. Something like a Standards of Practice, but better. How about a Standards of Excellence?

Conduct standards

Your organization should conduct itself according to a simple set of standards that reinforce the desired culture. These standards act as living framework for not just producing quality products, but also for cultivating a quality culture.

First and foremost, anything worthy of making a difference is not made by a single person. As the saying goes, “we stand on the shoulders of giants”, but what that neglects to address is who you are standing beside. As much as society perceives software engineering as a “loner” type task, it couldn’t be further from the truth. What we do requires just as much people skills as it does technical skills to ensure success beyond just lines of code written.

Those that don’t recognize this importance create a culture that results in high rates of burnout and turnover.

Diversity & Inclusion

It is important not to forget the inherent diversity in organizations of any size. As we look into the future, this diversity is inevitably going to increase, and this is good. Study after study has shown that diverse organization deliver more value. Rather than fight against this, we should embrace it. But, know that diversity in-and-of-itself isn’t enough. It’s just one side of a coin. The other side? Inclusion.

Empowered, included individuals ensure a organization’s cohesion and resilience, and both are critical to any project that will last more than just one iteration. Organizations that have strong cohesion and resilience will consistently outperform the stereotypical “dream team” of 10X’ers, ninjas or rockstars.

To increase inclusion, we need to avoid speaking in ways that can inherently exclude people from our world. One example is how we often project the male gender as the default for both people and inanimate objects. Another is avoiding social activities that could exclude others. Planning a work lunch? Ensure everyone has an option to eat, whether vegetarian, vegan or otherwise.

Reduce unnecessary conflict

There are three subjects that should be avoided:

  1. Politics is inherently polarizing, especially in today’s climate
  2. Religion is of course very personal and emotional
  3. Sex (the act) or anything sexual is an inappropriate topic for work

Of course, there are others that can have a high probability of conflict, but these are the most well-known and easy to avoid. Rather than arguing about any of these, save that emotional energy to advocate for the user, and one another.

Do better than the “golden rule”

The needs, desires and wants of people are just as diverse as the cultures from which they come. You likely want to be treated in a way that others do not, and that’s okay. We can do better than just treat people as if they are like us, because many are not.

Don’t treat people how you would want to be treated. Treat people how they want to be treated.

Be helpful & share

Our priorities should be organization focused, not individually focused. Any knowledge that makes an individual successful should be shared and distributed to make everyone successful.

There is no single individual that knows more than a organization, especially a diverse organization. Knowledge sharing is one of the most empowering things you can do.

Want to be a 10X Developer? Good! Do it by empowering 10 other developers.

Celebrate teachable moments

Too many times, we have observed individuals struggling with something for far too long without asking for help. This is too costly for both the business and the individual. It’s frustrating, depressing and does no one any good.

People are afraid to ask for help when a culture is created that judges or criticizes others for not knowing something. We need to remove the stigma of asking for, or needing, help. Asking for help is absolutely crucial as no one knows everything, even the most senior of engineers.

Celebrating teachable moments is one of the best ways to create a learning culture.

Have fun and ensure others do as well

Last by not least, take breaks, go out for lunch, laugh. Find inclusive ways to bring other aspects of enjoyment into our workplace. There’s nothing prescriptive here other than just try to find ways to break the frustration and stress.

Everything is a conversation

Both the micro and macro interactions we have with each other is a conversation. You can either capitalize on it, or waste it. That code review: a conversation. That “silly” question that was asked: a conversation. The grooming of a user story: a conversation.

Know how to say “no”

Operating within an environment that empowers individuals to healthily push back, say no, or to ask why is crucial to quality output. But, “with great power comes great responsibility”, so a no should always be followed up by a well articulated rationale and/or educational response.

A “No” is always customer focused. Never individually focused.

This is a start of a conversation. As engineers, we are the last remaining barrier from a bad user experience or a completely unethical outcome. People make mistakes, requirements fall through the cracks, and things get overlooked. If we don’t ask questions, critique requirements and inquire about intentions, no one else has a chance to stop it.

No matter what anyone says, the product that we produce is always a compromise between a dozen or so forces. Marketing, design, budget, legal, business, product, engineering, customers and others all have their priorities, and many times they are not all aligned. So, compromise is critical, and to do that, engineers have to speak up about their responsibilities.

Disagree and commit

Now that you’ve said “no” to something. The team’s only responsibility is to listen to your argument and discuss its merits. The team isn’t obligated to agree with you. Speak your truth, humbly argue your point. If the team makes a decision with which you disagree, get your objection noted, and move on. Live to fight another day.

Slow down and build great things

Whether it’s with product, design, QA or each other, we have to have a process that ensures the time for proper discussion. This is the point of Agile, Scrum and many other philosophies/processes/frameworks. Daily stand-ups, grooming, retrospectives are all designed around taking the time to have conversations.

As has been said many times, speed and quality are almost always at odds with each other. It’s not one or the other, it’s a compromise. Speed without quality produces unmaintainable products, and quality without speed produces products that are late to market or not innovative.

If speed is prioritized over quality, then an agreement has to be made between parties that quality will take precedence soon to ensure a balance. This will be, and should be, a constant tug and pull, but with a long-term symmetry.

Code is a conversation

Code reviews should be a daily process between us all and should be an engineer’s way to converse about the code. Critiques of code should never be given or interpreted as a critique of the author. They should not talk at the individual, but with them. Review the code, not the person. The resulting conversation should represent the community speaking about the product overall.

Code review comments should focus on enforcing patterns, maintainability and understandability. Don’t focus on syntax or “nits”. That should be handled through static code tooling. Remember, code is almost never wrong, stupid or bad. Code is always a result of many factors and compromises, so no comment should communicate such an idea.

Write code for the future maintainers, knowing that it will almost always not be you.

Code has a greater impact on the humans that have to maintain it than on the computer that has to execute it. After a year or so, the large majority of code will be maintained by engineers that are not the original authors. This fact is crucial to understand for all.

Build with empathy and ethics

Everything we build needs to be built with a deep consideration for the user and each other. We build things that thousands or millions of people use, so we have to be cognizant that we have a responsibility to these users we invisibly affect.

Whether it’s keeping their data private or money secure, or that we ensure new features are usable and accessible to the widest of spectrum of capabilities, we have to have empathy for all that interact with our systems. Not doing so due to time constraints, bad days or conflicts, can have irreversible side-effects on real humans.

Engineering Standards

Business rules change, customers expectations change, requirements change, maintainers change, technologies change … write your applications with this at the center of your mind.

To do that, we have compiled a list of philosophies and approaches to higher level programmatic thinking to lend itself to better long-term code quality. You will find all these ideas in standard computer science theory or popular technical books written by prolific leaders in our industry.

Write code for humans, not computers

Computers can understand and execute the ugliest of code as long as it’s syntactically correct. This does not apply to humans. Because of this, the largest reason for standards in code authoring is for the humans that own/read/maintain the code you write.

Know that the original author of code is almost never the final maintainer of the code, so don’t write code for yourself. Write code for the future engineer that has to adopt it after inheriting it from you.

Writing code is not an expression of individuality

Code is community owned, represents the organization and is explicitly for the customer and company. This means that all code should look like a single person wrote it (though, don’t take this to a logical extreme).

Conformity has a much higher value than uniqueness when it comes to scaling software. If you want to propose a change or a new pattern, raise it to the team and come to a conclusion, and then document that change.

Cleverness has little, long-term value

Let us not confuse clever with intelligent. Cleverness can easily lead to hard to maintain, difficult to understand and frustrating to debug code as it focuses on the wrong goals: micro-optimizations or ultra succinctness. If there’s a more mundane/boring/verbose way to write code, that’s the preferred, “intelligent” way as it keeps in mind the long-term goal: code is written for the future maintainers.

Design patterns

Most people are familiar with design patterns from the famous book written by the “Gang of Four”. The below are intended to guide the overall design of your programmatic solutions. They document a preferences towards a set of choices when looking at writing code from a holistic perspective.

When faced with a choice, use the below to help influence your decision making.

Simplicity over complexity

Reduce complexity into simple units of responsibility. Each unit should do one thing well and be designed for composition through simple interfaces, input and output.

Interfaces over implementations

There are language specific interpretations of this design principle, but I use this a bit more generically. The short of it is focus on how the code is going to be used, not how the code needs to accomplish it.

Whether it’s a module, widget, REST API, a model … write code that expresses an interface without the exposure of the implementation. Embrace the idea of public versus private, adhering to conventions and having a small, easy to understand footprint.

Don’t forget about the principle of least privilege.

Composition over inheritance

Classical inheritance has many consequences. Composition is much easier to reason-about, debug and maintain due to a more shallow, transparent structure. Try to avoid tall, vertical, inheritance structures as they can be fragile and hard to maintain (“fragile base class” problem). Focus more on shallow inheritance models or avoid inheritance all-together.

Functional over object-oriented

Many programming languages promote OOP with classes. But, these programming models can have side-effects: implicit state, vertical inheritance, exposure of implementation details, accidental mutation, etc. Of course, there are times where OOP is necessary; in those times, use it.

Writing code in a more functional style, focusing on purity, statelessness and referential transparency, leads to more predictable, testable, maintainable code.

Decoupled over coupled

Avoid coupling data or state to the pieces of the system that process it. Data should flow independently through a system.

Also, avoid coupling solutions to external systems, environment or technologies. Know that the future brings change, and the future is inevitable. Code should accommodate for a changing environment as much as possible.

Declarative over imperative

Nothing is harder to grok, maintain and debug than complex, imperative code. Rather than thinking in terms of writing procedural statements (how something works), write code that describes what it should do. That way, each function describes its purpose and encapsulates and abstracts away the imperative details.

Explicit over implicit

Write code in a way that avoids, or at least limits, its reliance on implicit state (state that’s provided through the environment or inheritance), especially when that state is mutable. Explicitness, operating only on what has been supplied, is always preferred as it’s predictable and side-effect free. Don’t fall into the banana-gorilla problem.

Verbosity over terseness

This is where “self-documenting code” comes from. Code rarely requires no documentation, but writing in a verbose way reduces the reliance on documentation. Nothing’s worse than having to run code just to see what it does.

Quality assurance standards

Automation removes the need to manually review your application’s functionality or syntactical correctness after a change. Spend the time to build the necessary tools and utilities upfront. In the long term, you want to be focused on writing what’s unique about your task, not what’s common or repeated.

Humans are bad at repetition; don’t make them do it.

Favor immediate feedback for automation. Integrating automation into the developer’s own tooling is best. Catch errors, inconsistencies or unwanted patterns as early in the process as possible.

Static code analysis

Nothing’s worse than having to argue over syntactic variations (tabs v. spaces). Leverage static code tooling, like syntax “linters” or type checkers, to ensure the correctness of your program while writing, compiling, committing, pushing … and let the tools enforce the law of the land.

Nothing’s worse that a PR that has two dozen comments that address semicolons and indentation.

Unit tests

Unit tests address the atomic pieces of code. They should focus on functional input and output. A unit test should not require any environment mocking, global state or external functionality. It should be entirely self contained and quick to run. If you have to bootstrap your app or start up a server to run your unit tests, they’re not unit tests.

Referential transparency plays an important role in unit tests: A function should be replaceable with its value.

Integration tests

Integration tests focus on unit composition. It should answer the question, “Do these units compose as expected?” They will often require mocking, and the amount of mocking should correlate with the number of units involved. These tests should not require a running server or real environment.

UI component tests (optional)

These are tests that share similarity with integration tests, but are a bit more specialized. If the UI library or framework allows for it, mount the UI component in isolation of the surrounding app to test the “view logic” written within the component. Don’t test static attributes of the UI component! Only test results of logic.

Inputs to the component should be mocked and the resulting output from the component, in this case the output is UI, should be tested against expectations. Again, only test view logic, don’t test units of code that would be better tested with the far-simpler, unit testing.

End-to-end tests

These tests are intended to mimic how users would interact with the real-world software. If the software has a UI, the tests should run against a functionally complete, running piece of software by clicking and typing against its interface. Though these tests are the most expensive and complex, they often can catch crucial system errors that cannot be caught in any other type of test.

Avoid testing portions of code in end-to-end tests that can be more easily tested in simpler, lower-overhead tests. It’s also important not to test external code (libraries, frameworks or language features); test only the application.

Code Coverage

Code coverage attempts to provide insight into how many paths in the code are tested. Code coverage tools, and metrics around them, can help identify untested code paths, preventing too much untested code from being introduced into the system.

But, there is a dark-side to code coverage, and that’s writing tests just to inflate or preserve the coverage numbers. Encouraging the enforcement of an arbitrary number as a gating mechanism, more times than not, causes developers to just write tests that have very little value to meet the numbers.

Versioning and maintenance

It’s important to version and maintain your code to ensure proper deployment predictability, tracing of changes, assistance in debugging efforts, transparency of code authors, and version “rollbacks”, if necessary. The assumption of the following is that Git and Github are used as your code versioning and maintenance tooling.

Unidirectional Flow

To reduce complication when collaborating with a team of developers, the best practice is to have a unidirectional flow of code modifications. Pull changes from upstream, push changes to origin, PR changes from origin to upstream, rinse and repeat.

The main branches are immutable

Treat the main branches (master, main, develop, etc) as immutable. Changes can only be introduced into the codebase by way of “feature branches”. Keeping these main branches immutable allows for easier code maintenance and flow. This allows for git pull on main branches to be done as “fast forward” only, no rebasing necessary.

Communicate through commits messages

A verbose and informative commit message is recommended. Use an unqualified commit, git commit, to be allowed to create a commit title and commit body.

Establish a formulae for constructing this commit message and enforce it. Commits are like code, keep them consistent and meaningful across the team.

Reduce the amount of commits and noise

A clean git log is incredibly beneficial to investigative research for issues. With this in mind, use the commands Git provides to amend previous commits or squash past commits. A commit represents a logical chuck of functionality.

A git log should never show commit messages of “Oops, typo O_o” or “git is hard!!!!!” littered throughout the project’s history.

NEVER rewrite public or another’s history

Rewriting Git history is a powerful tool for keeping a well maintained git log. But, “with great power comes great responsibility”, so never, ever rewrite public history or another’s history.

Once code is in the dedicated, main branch, or shared with another developer, it cannot be rewritten. This is law!