A Case for Thinking Before Programming
The content of this post will probably be a bit different than others you have read. While this post is targeted towards developers, my goal isn’t to teach you about a new framework, language, design pattern, etc. Frankly, there are other resources that can do that better than I can.
Furthermore, I feel like us engineers often get caught up in telling ourselves that the trick to becoming better at our job lies in learning more technical skills. We look back at a recent launch, or a failure in production that caused one of our teammates to get paged and tell ourselves “if I had just known a little more about X, Y, and Z, I that wouldn’t have happened”.
While this is true in some cases, it is rarely the whole story. In fact, this narrative we create often allows us to hide behind the idea that we are one more tutorial away from not making critical mistakes anymore when the reality is, we rarely do enough to make the best use of what we know currently.
Therein lies the call to action for this post. If nothing else, I want to convince you that you probably know enough to succeed on whatever feature or product launch you are working on, and that the key to doing so is to formally think through each problem before you actually begin writing any code.
To help with this, I want to talk to you about lessons my team and I have learned and give you a three-part framework to facilitate thinking before programming. I don’t intend for this to be a formal framework. Really, my goal is that for those of you that don’t have any sort of framework you use to think through problems before you begin implementing solutions for them, you at least walk away with something to begin using.
To make a case for this, I would like to reference a quote from Flash Boys by Michael Lewis, which is a book about high-frequency Wall Street Trading (which I discovered from this blog post).
Russians had a reputation for being the best programmers on Wall Street, and Serge thought he knew why: They had been forced to learn to program computers without the luxury of endless computer time. Many years later, when he had plenty of computer time, Serge still wrote out new programs on paper before typing them into the machine. "In Russia, time on the computer was measured in minutes," he said. "When you write a program, you are given a tiny time slot to make it work. Consequently we learned to write the code in ways that minimized the amount of debugging. And so you had to think about it a lot before you committed it to paper.... The ready availability of computer time creates this mode of working where you just have an idea and type it and maybe erase it ten times. Good Russian programmers, they tend to have had that one experience at some time in the past - the experience of limited access to computer time.
Clearly, there is great value in thinking through problems before you begin brainstorming or working through solutions by actually writing code. Thinking before you write code allows you to avoid the numerous complexities brought on by interacting with computers, and having to work through details of implementation while brainstorming a solution. With that, let’s get started with this 3-part framework
Step 1: Fully Define your Problem Domain
Most of the work we do as engineers is oriented around technical problems. While it is temping to jump straight into looking for solutions, doing so is unwise. Often time, people on your team will have different assumptions about the problem you are trying to solve, the current state of the code base, the particular constraints the solution must fall within, etc. People may come up with different solutions, because they are trying to solve different problems.
To avoid this, make sure you and your team fully define and agree upon the context in which this particular problem exists.
Feedback Context
Let me introduce you to our Feedback component, the design for which is above. This component is fairly simple. It allows the user to select and deselect a positive or negative feedback rating. However, the development process for this component was not simple at all. While there were numerous points of failure, the one relevant to this talk lies in the assumptions we made while developing this component.
List Your Assumptions
At a base level, this is a component that takes in user input. Consequently, my team agreed that we should probably develop it using some base-level input element. When I first saw the design for this component, I saw two exclusive values for a single input. With this, my mind went to radio buttons. This would allow the user to select one, but not both, of the values. So, I suggested this to the developer from an external team contributing this component.
By suggesting this, I made the assumption that deselection was not a priority. As it turns out, that assumption was completely false. Deselection was actually something the team consuming this component really wanted to support.
This brings me to my first point for this step: when working on a feature, or advising someone on how to work on a feature, write down, or at the very least explicit state each assumption you are making when you suggest a certain path forward. Then, verify these assumptions with the rest of your team and any stakeholders.
In this case, had I explicitly noted that my suggestion to use a radio button relied on the assumption that deselection was not necessary, the team consuming this component would have been able to assert that this assumption was false.
Run Mini-Experiments
By the time I learned this assumption was false, I had already suggested the contributor use a group of radio buttons. To avoid re-work, I suggested that the contributor add a third, hidden radio button that would automatically be selected when the user clicked on the button that was currently selected. By suggesting this, I made the key assumption that having this third hidden radio button would be accessible, and wouldn’t completely break the screen reader and keyboard navigation we wished to support (more on that here).
As you might expect, I was wrong. Without going into too many technical details, the core problem with this pattern has to do with keeping this button invisible when a user is looking at the screen and navigating with the mouse or keyboard, while keeping it visible to screen readers.
By the time this component had been fully developed by our contributor, I realized addressing these accessibility concerns would take more trouble than re-writing the component, so we starting over and built this component with buttons as the base input.
This brings me to my second point for this step: when you are making a technical assumption that a feature relies on, run mini-experiments to verify this assumption is correct. Had I created a code sandbox with a demo of this pattern, we would have quickly seen that this was not feasible, and saved days of work.
The point of this story is to underscore the importance of enumerating all of your assumptions, verifying these with your team to make sure you are all on the same page, understanding which technical assumptions a feature relies on, and running mini-experiments to verify these before actually developing a feature.
Step 2: Seek to Understand the Range of Potential Solutions
After following the first step we covered above, everyone should agree on the context in which this particular problem exists. This brings us to step number two: list out all potential solutions, seek to understand the trade-offs between them, and decide on one based on your team’s core values.
Modal Context
Let me introduce you to our Modal component, the design of which can be seen above. Before I go any further, I want to point out that most of the information I cover about the Modal is covered in much more detail in this blog post by Kaeden Wile, a former member of Asurion UI who was the feature owner for this component.
The term modal generally refers to a box of content that displays “on top” of the rest of the content on the page, and blocks interaction with the rest of the page until dismissed. The design for ours can be seen above. To understand the multiple variants this Modal can take on, we can take a closer look at its design in Figma.
As you can see, the similarities between the variants of our Modal have to do with the consistent container, dismiss button, and optional call-to-action buttons in the footer. On the other hand, the header can take the form of a hero image, or of text that represents a header (and optionally, a sub-header).
As Kaeden puts it in his blog post, when we are dealing with something simple like our Button component, it is quite easy to expose all of this component’s options and functionality via props. This is not as easy to do with the Modal. This has to do with the variable layout of the Modal, and the fact that it contains nested components.
If we took this standard approach, we would need to have two sets of props that are passed down to each of the call-to-action buttons in the footer, as well as additional props to manage the configuration of these buttons (i.e. if they are present or not).
This leads us to the key problem we faced when building this component: how do we build this modal to be flexible enough to accommodate all of the design variants, while providing sensible default styles and a clean API?
Look for Trade-Offs Inherent to the Problem
This brings me to my first point for this step. When facing a problem like this, start by listing all or some of the possible solutions as a mechanism to understand the trade-offs present in this feature. If you are already aware of some trade-off, start is on either end of the spectrum, then consider the solutions in between.
This is what we did with the Modal. Going into this refactor, we were aware we needed to balance flexibility and adherence to our design system standards. As in, we want to make sure people adhere strictly to our design best practices but also understand that there are edge cases that we will not be aware of until after we build a component, so we need to have some flexibility baked in. We also wanted a clean, easy to use component API.
On one end of this spectrum of adherence and flexibility, we discussed the possibility of exposing options entirely through props, the way our original modal does (as you can see in Storybook here).
On the other end of the spectrum, I suggested that we consider not creating a Modal component at all, but instead creating a series of templates built with Asurion UI components that looked and behaved like a modal. The biggest problem with this solution is that it would not allow us to push updates to this component, and instead would rely on users pulling in updates by copy and pasting the updated template in their existing code.
While not feasible, this suggestion was useful because it made us think about how we could structure our Modal to allow for customization. The consideration of these two solutions lead to the suggestion of a hybrid solution: our Modal component would act as a container, and we would also export various sub-components that were made to be used in the Modal.
This allowed us to make sure everything matched and defaulted to our design out of the box, but also allowed for full customization of both the structure and styles of the component and each sub-component.
Decide Based on Your Team's Core Values
When building the modal, neither of the solutions were inherently better. At this point we had to ask ourselves, what is most important to us? If all solutions are technically feasible, then the decision between them should be guided by your teams core tenets.
In our case, we know some of the things we value most include building components that:
- Work out of the box
- Adhere to our global design standards
- Are flexible enough to support edge cases that were not designed around, but do have design approval
- Are easy to use, and enhance our consumers developer experience
- Require little overhead/maintenance (i.e. that we can update in the case of an accessibility bug)
As you can see on this table, the Hybrid Composed Components approach is the best at addressing all of these concerns. Consequently, we chose to take this approach, so that we could provide consumers with an easy-to-use component API (based on having to pass less props and configuration options in) with sensible defaults (by exporting sub-components), while allowing them to step out of this completely by styling our sub-components or putting other components in the modal if necessary.
Look for Existing Solutions
Sometimes, the hardest part of a new feature is coming up with a single solution. If this is the case, look to others for inspiration.
Here at Asurion UI, we often refer to other component libraries when we are looking to build out a new component (The Component Gallery is a comprehensive aggregation of Component Libraries). The soluto-private
GitHub Organization is also a great place to start.
When you find one, or ideally multiple solutions, explore what each solution chooses to address and ignore. The range of solutions you find may help you discover some inherent trade-off that you were unaware of.
If two examples are implemented in different ways, seek to discover what each solution prioritizes and de-prioritizes. If every solution you find has a relatively similar implementation, this might clue you in on the fact that this is probably the best path forward for you team as well.
While building out the Feedback component, we did not look to see how other teams had built similar components until we had already started implementing the component. When we looked, we only found a few examples that used radio buttons as a base element, while nearly every other solutions used a regular button. Of the solutions that supported deselection, every single one used a button.
Step 3: Write it all Down!
If you have followed this framework up to this point, then you and your team fully understand the problem space you are working in, you have used this shared understanding to enumerate multiple solutions, and you have decided on one of these solutions in accordance with your team’s core values.
The final step to take before actually writing code is to define your starting state, desired ending state, and write down your entire plan of execution.
Icon Context
Let me introduce you to our Icon component (the post-refactor version of the Icon can be found here). At a glance, this component is simple: it accepts a src
prop that corresponds to one of the icons available in our “Icon Catalog” and displays the appropriate svg
.
However, if you look closer, you’ll see that src can be of type string
or of type JSX.element
. This is because all of the icons available in our Design Library are not available in our “Icon Catalog”. Basically, we have an "Icon Catalog" that represents a subset of the Icons available in our Design System Library, which is bundled with our package.
The Icons that are not present in this "Icon Catalog" are kept in our CDN. For this reason, if someone wants to use one of these icons outside of the catalog, they need to download the svg
from our CDN, and pass the source code for this icon into the src
prop.
Some pain points of this setup include:
- Passing in a component as a prop is not intuitive, and not a pattern we use anywhere else in our library. Also, it’s hard to document this with Storybook
- This is a Leaky Abstraction. If all the icons exist in the same place in our design library, then a consumer should be able to use them in the same way in our component library. An icon is an icon, and they should be able to use all of them in the same way
- This Icon Catalog contributes to our bundle size, and adding all the icons from the design library would cause it to grow to an unmanageable size
Clearly, this current setup was not optimum for long term usage.
Define Start and Finished State.
I was the feature owner for this refactor. I am a visual learner, so I often create diagrams when working through a problem. As soon as I started working on this refactor, I created a few diagrams with this tool called Excalidraw in order to explain the current state of this component to my team.
What you are looking at now is a cleaned-up version of those diagrams. The first point for this step is that, when beginning work on a solution, define exactly what the starting state looks like, and exactly what the final state should look like. That is exactly what I did here. The “current” section refers to the starting state, and then “desired” section defines what the ending section could look like, with references to existing solutions.
By doing this, I was able to ensure that everyone agreed on what this refactor was starting with, and exactly how this component should look when we were finished with it. This forces everyone can think declaratively before thinking imperatively.
Write Down a Detailed Plan of Execution
With the start and final states defined, the last step before you can begin coding is to write down your execution plan. The medium doesn’t matter, the important part is that you write down enough info so that if you suddenly had to hand this task over to another engineer on your team, you would be able to do so with minimal transition time.
What this forces you to do is take a final pass over everything before you start implementing the solution, and serves as a final safety check to make sure you have all of your bases covered. Furthermore, writing down this execution plan allows you to decrease the cognitive load with which you enter a programming task, which in turn gives you more space to think about what you are building.
As a final note, I am sure most of us are familiar with rubber ducky debugging. The screenshots above are from an April Fool's joke by Stack Overflow in 2018 that created a virtual rubber duck (more on that here) If not, the general procedure is that you debug code line-by-line by explaining what your code should be doing, and what it is actually doing to an inanimate object, like a rubber duck.
The premise here is that teaching something to someone else requires a deeper understanding than just learning it yourself, and requires you to look at the problem from perspectives that you may have not yet considered.
You can almost think of writing down this plan of execution as “preemptive rubber ducky debugging”, where you explain all of the steps you will take to a degree that someone else could follow these steps, thereby forcing you do take one more deep look at your plan before getting started.
Your Turn
I would like to end this post the way we started, with a quote. This is a quote from Edsger W. Dijkstra, who you might remember as the computer scientist who came up with the algorithm to find the shortest path between two nodes on a weighted graph. When asked in an interview about how he came up with this algorithm, he had the following to say:
What is the shortest way to travel from Rotterdam to Groningen, in general: from given city to given city. It is the algorithm for the shortest path, which I designed in about twenty minutes. One morning I was shopping in Amsterdam with my young fiancée, and tired, we sat down on the café terrace to drink a cup of coffee and I was just thinking about whether I could do this, and I then designed the algorithm for the shortest path. As I said, it was a twenty-minute invention. In fact, it was published in ’59, three years late. The publication is still readable, it is, in fact, quite nice. One of the reasons that it is so nice was that I designed it without pencil and paper. I learned later that one of the advantages of designing without pencil and paper is that you are almost forced to avoid all avoidable complexities. Eventually that algorithm became, to my great amazement, one of the cornerstones of my fame.
Dijkstra (2001), in an interview with Philip L. Frana. (OH 330; Communications of the ACM 53(8):41–47)"
Through this post, I hope I have convinced you of the value of avoiding “all avoidable complexities” by thinking through a problem, with or without pen and paper, before you even think about spinning up your development environment of choice. Who knows, maybe whatever you create will become one of the cornerstones of your fame.