The UX of clean code

If you ask the twitterverse what even is clean code, you'll probably hear a million thoughts around the following tactics:

  • short methods and classes
  • good naming conventions
  • comments vs self-documenting code
  • reducing cyclomatic complexity / logical branching
  • style linting / style guides
  • programming paradigms: functional vs objected-oriented, imperative vs declarative
  • usage of language idioms
  • immutability
  • using the right abstractions
  • using story-telling
  • loose coupling / high cohesion
  • reducing the half-life of code / evolutionary architecture

Many of these tactics do have merit in terms of reducing complexity, yet we're no closer to defining what "clean" really means. It's not even useful to try to define universally. What makes for clean and readable code is context-dependent: it's about what works for the team and the problem space. Every team has different needs, so the tactics might need to shift to achieve this goal.

Sarah Mei's metaphor of Livable Code really resonated with me: in her talk she illustrates how interior design, not construction/architecture, is what we're really doing when we build software. It's never quite done, but it's sustainable and can be changed much more easily than, say, redoing the plumbing or electricity of your house. Interior design is a lot more personal, because it depends on who is living in the space, how the space is being used and shared, and what group habits (i.e. chores) are needed to maintain the space.

The idea of "livable code" implies that there exists a group tolerance-level of mess; if you've ever lived with other people in the same household you know just how subjective "neatness" is, and how dynamics change once the level of mess has exceeded an arbitrary threshold.

So how do you figure out what is workable for your team as a collective, when what feels "livable" is highly personal? Well, that's what user research is for! This got me thinking about how we might apply various UX techniques to produce livable code.

Don't Make Me Think

I personally subscribe to the "don't make me think" usability model as applied to reading code. One of the main points outlined in Steve Krug's Don't Make Me Think is the idea that every question adds to our cognitive load. Every time we have to stop and figure out what a bit of syntax is trying to achieve is a distraction from the actual problem we're trying to solve. I don't want to spend my brain power on the act of reading individual lines of code, I want to spend it on understanding, analyzing, and synthesizing ideas, in order to add new code.

Krug's advice is that interfaces should be learnable, effective, and efficient: "a person of average ability and experience can figure out how to use the thing (learnable) to accomplish something (effective) without it being more trouble than it's worth (efficient)". And what is code but another kind of interface between humans and computers, between readers and writers?

The Interview

A great way to learn what the team needs is simply to ask them. Team members can ask each other questions like:

  • Tell me about your professional journey.
  • What are your strengths?
  • Where are you trying to grow?
  • What excites or interests you the most?
  • How do you learn best?
  • How can I support you in your goals?

These are questions that can come up in a job interview or in a 1:1 with a manager, so it might feel a little weird to ask these things of your co-workers. But this information is really valuable, especially when you are first starting to work together, so that you can tailor your communication style to others' needs and goals.

This technique can work well in a variety of working environments. I happen to work in an environment where I'm pair programming nearly all of the time, so whenever I pair with a new person for the first time I try to get a sense of where they're coming from and what experiences they bring to the table. This helps me understand things like how familiar they are with the technologies we're using, the domain we're working in, and what will or won't work when writing and explaining technical topics. It also helps me understand what things I may be able to learn from them, so I can seek opportunities to pair with them on those subjects further in the future.

On one team, I learned that a new teammate was completely new to Angular, and generally was much more interested in and comfortable with C#. They wanted to learn enough to be able to contribute and do a good job, but otherwise didn't care that much about learning everything there is to know about Angular because they would rather spend time deepening their knowledge of .NET. This eventually led to a team practice of limiting usage of Angular and Typescript idioms to lower the barrier of entry for casual contributors.

User Testing

In UX research, task-based user testing is a technique for observing how users interact with your product. You give the user a few goals to achieve, and take note of the steps they took, giving very few hints along the way. "User testing" is a bit of a misnomer, because as UX researchers are always careful to point out to participants, it's not about testing the user or their knowledge, it's about testing the product itself and whether it is learnable.

In Don't Make Me Think, Krug defines learnable interfaces as either self-evident or self-explanatory: self-evident means "obvious just by looking at it", whereas self-explanatory refers to the least possible amount of effort required to piece strong context clues together.

As applied to code, "self-evident" implies that it can be nearly universally understood with zero upfront context. That's pretty hard to achieve, and usually not necessary; it's reasonable to expect that in most cases engineers will be onboarded with some amount of business context and not just dropped into a codebase cold turkey. Aiming for self-explanatory code is a much more realistic goal.

User testing is done in real time in order to directly observe pain points, so it's useful to try this technique while pair programming or during live code reviews. However, it takes some finesse to do this well while accounting for power imbalances, so that it doesn't come across as quizzing or gate-keeping. It's not about evaluating individual developers, their skill level or knowledge of Important Books Some People Wrote, it's about evaluating whether the code itself provides enough context clues. Here are some potential goal-oriented questions you can pose to yourselves:

  • What's the problem we're trying to solve?
  • (While using TDD) What's the simplest change we can make to make this test pass?
  • What do you think this function does based only on its signature? What's the responsibility or intent of this function/object?
  • How long does it take to understand what the function is doing? (Time it! Don't, like, set a stopwatch, but keep a general sense of how long it takes.)
  • Make the app run for the first time, or make the tests run for the first time. (Was the README self-explanatory?)
  • (While using driver/navigator pairing) As a navigator, what do you notice? Where does the driver get stuck, and are they able to unblock themselves? What do they say aloud? Catalogue your observations mentally.
  • Wonder aloud: "I wonder what would happen if we deleted all the 'act' steps of the tests."

Contextual inquiries

A contextual inquiry is similar to user testing. It also involves observation and questioning in the context of use, in order to understand current processes, behaviors, and attitudes. The difference is that user testing is used to interrogate the solution space, and contextual inquiries are typically used to deepen the understanding of the problem space. They are less about performing specific tasks in a lab environment (which is more convenient for the researcher), and more about observing typical scenarios in the time and place that makes sense for the user.

Code is part of a sociotechnical system, so it's not sufficient to only do user testing with only other developers. User testing tells us if the code is learnable, but it doesn't necessarily tell us if it is effective and efficient, and usable code requires all three. For that, we need to have established communication pathways and a shared mental model of the problem space with teammates of other roles, to ensure that we are building the right thing. Interviewing product strategists, designers, quality analysts, customer support staff, site reliability engineers, et. al., can provide helpful inputs into how our code ought to be structured.

Empathy mapping

An empathy map is a tool used to create a shared understanding of users, the situation they're in, the tasks they're trying to accomplish, and how they are thinking and feeling about their context.

Empathy mapping can be particularly useful when dealing with legacy code. It's understandable to get frustrated with code that is inefficient and takes a long time to unravel, but we have to be careful of shifting our frustrations towards the people (often total strangers) who wrote the code.

  • What constraints or pain points might have led someone to write code this way? What might they have been feeling or thinking?
  • How would a new team member respond to this code? What would they say, think, feel, or do?
  • How would a non-developer person on our team describe this behavior? What language would they use?

Thinking through these questions builds our empathy for the people who wrote the code and the situations they may have found themselves in. The people who wrote the code are not stupid, lazy, or incompetent; it's mean and unproductive to think this. The constraints live on in the code, and with a little perspective and empathy, you can choose to make the space a little better than how you found it. You can choose to break down some of the underlying communication dysfunction.

I once worked on a team that regularly had to touch a giant, legacy, monolithic application; there were also a couple of brand-new developers for whom this was their very first project. This was a really difficult environment to learn in, as every day felt like walking through a quagmire, making minimal progress. There was no one to turn to who could answer our questions. I thought of my new teammates, and whoever would come after us, and imagined them sitting in my shoes looking at this same code.

We were so far removed from the circumstances that had produced this code, the temptation to delete everything was strong – and yet, we knew we couldn't, no matter how much we wanted to, because we didn't have the appropriate context. Each time we had to touch that part of the code, we added a few more tests around the existing behavior, making it progressively easier to introduce new seams. As much as we learned to empathize with those who came before us, we also learned to empathize with those would come after us, and that influenced our approach to cleaning up the code so we could live with it.

Code you can live with

The idea of "livable code" ought not to be interpreted as apathy towards improvement; in fact, quite the opposite. It's not saying, 'because we tolerate mess, we therefore have no discipline for tidying messes' – it's saying that we are incentivized to keep the mess manageable, because the extremes (both zero mess and wall-to-wall mess) are costly and actively undesirable.

Each team member can only speak for themselves about what makes code usable:

  • Is the code self-explanatory for them? Can they piece together context clues to figure out how it works?
  • Is the code effective? Does it accomplish the right thing at the right time?
  • Is the code efficient? (This is not in terms of compute performance!) Is it concise, or is it too much trouble to understand? Does it satisfice their needs as a reader?

You can ask these questions directly, in addition to using some of the above UX research techniques, in order to make adequate compromises on the set of engineering practices that work best for your team as a collective.