"Love our codebase! <3" — this PR comment is the pinnacle of engineering success. ❤️
Over the past few months, we've built a React codebase engineers love to work with. Replacing the old jQuery code best described with every new engineer's favorite words: "Wow! When can we rewrite?" A codebase grows over time, gets poked and prodded by many, and adapts to changing demands from the business. There are no rules.
Or GOCODE for short.
Grow it like a garden
Engineering is programming over time. ~ Titus Winters
Programming is easy: you write code, run it a few times, get the result, and move on.
Engineering is hard: you write code, consider its impact, write a test, get half the result, add features next sprint, touch it again 2 months from now, break something you wrote yesterday, and who knows what might change when you weren't looking.
Your code grows weeds. Little fuzzy things around an edge case. Exceptions to every rule.
A quick fix here, a rush job there, a new feature from that engineer who wasn't around to hear your grand vision. Software grows like a garden and without gardening, it grows into a mess. Add a loving touch when you can. Pluck a weed, bind the branch, create beauty. 😍Move code, clarify names, turn patterns into tools.
Open for tinkering
Try it, see what happens:
But you need a welcoming environment that supports tinkering and exploration.
- fast feedback cycles
- searchable code
- welcome the mess
You get fast feedback cycles with auto reloads, hot module replacement, and automatic builds. Standard React tools give you that – make a change, see a change.
Searchable code is easier to navigate. All text on the screen should be findable with git grep text. That's how you find components to change. Use unique enough names to find files with your IDE's fuzzy search.
Do not rely on folder structure and name every file index.js. 🤮 Make sure you welcome the mess. Young code, early ideas, little experiments, they're fragile. Linter errors, 1-component-per-file, 1-language-per-file, and other restrictive nonsense kill creativity. Yes I know it's not perfect damn it, I'm figuring it out!
Engineers on linting errors
Move all quality validations to git commit or even the merge action. Code doesn't need to be perfect until it's done. Trust your engineers to make it right.
Tinkering requires confidence and confidence comes from your ego. That's the bad kind. 😛The good kind of confidence comes from your tools and coding environment.
There are many ways to get there. 100% integration test coverage, a killer QA team, expecting your team to know the full codebase like the back of their hand, ...
None of those are realistic and they're all slow. Yes, all that time writing tests and fiddling with mocks is time you aren't making a better codebase.
Here's what worked for us:
Using TypeScript in strict mode and no type covers 80% of your unit test needs. You know you'll never call a function with bad arguments, mistyped names, or forget an edge case.
Big changes spread virally through the codebase and your IDE tells you what you touched. ❤️
We use a storybook for visual UI testing and documentation. A sprinkle of unit tests covers gnarly business logic that TypeScript can't handle.
And we design our code for spelunkability. Makes sure you can figure out how something works. Find the UI, follow the function calls, and you always find the answer.
No guessing. No spooky action at a distance. No disjointed events. No reactivity. Just good old function calls.
Understandable code that tells you where it hurts 👌
Obvious is correct
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody. ~ Hyrum's Law
Your engineers are smart and they will find a way to solve every problem. That's a threat 😉
Weeds grow when the solution they find doesn't fit your system or codebase design. Gardening helps.
Tactics abound, the two that helped us are:
Code that works together, lives together. In the same file if possible. Same directory at least. The closer your code lives to where it's used, the less mental overhead it takes to understand. And it helps keep changes small.
Clear public interfaces ensure you always know how to use a piece of code. If it's not for you, it doesn't exist.
You exported that helper function to write a unit test and nobody's meant to use it. Guess what – everyone's using it now. import helper from X is enticing 😉
Read pull requests and ask "Are folks finding the right solutions?" If not, go gardening.
Build is when you create behaviors, add business logic, make your code work. Delivers value.
Design is when you polish. Create space, add consistency, fix copy, make it pretty. Delivers delight, trust, and confidence.
You want engineers to spend time making big progress instead of fiddling with details. The more time they can spend on building, the better.
A custom design system works like a set of building blocks. It encapsulates your design language, core behaviors, UX interactions, and hides fiddly details behind a common API.
You get a system where
- Designers iterate fast with rough mockups
- Engineers build fast with magically-good-looking UI
- UI Polish happens at the end or isn't necessary
Add novel patterns, useful components, and commonly used style combos to your design system to create a growing set of components. Patterns become tools and engineers are free to focus on the unsolved bits. 👌 Every time your engineers have to worry about a 2px offset is time that could be spent building UX.
You can't fit a large codebase in your brain. Abstraction is crucial. The dream is that engineers can focus on their tasks, treat the system as a black box, rely on public interfaces, and never look under the hood. The reality is that abstractions are leaky 😅
All non-trivial abstractions, to some degree, are leaky. ~ Joel Spolsky
You can mitigate that with a fractal top-down approach to writing code. Make it read like a book or an essay that starts with 1 core idea then fractally expands to support each part of the idea.
At the top, you have The Fronted.
It splits into User Flows and Shared Components.
A component contains all of its UI, UX logic, hooks to grab data... everything you need to add a complete piece of functionality to your page. A user flow splits into pages, shared components, and shared hooks. Each page further splits into components. This fractal structure creates good ergonomics:
- Each layer contains all it needs to work
- Well-defined lines between areas of responsibility
- Easy naming thanks to well-defined responsibilities
- Reduced cognitive overhead
- Easy to dig into details thanks to collocation
Abstraction names say what's going on, a TypeScript interface says how to hold it, and if something's leaking, the relevant code lives close together. 😍
Reducing side-effects and encapsulating complexity makes abstractions easier to use, the rule of 3 makes abstractions you'll find useful. Gardening helps you make improvements. When you see code that's gnarly 👉 move it to a function or component. When you see code repeated 3x 👉 abstract away.
A good abstraction hides complexity, reduces cognitive load, and is easy to dig into when it leaks.
In short: GOCODE.