I thought I’d kick off this new blog by talking about how I approach product development. I’ve helped build products for over 10 years and slowly formed a way to think about building products based on what I’ve seen work and what hasn’t.
I’ve been thinking about this because I recently took two weeks to completely rewrite a major internal piece of Actual. I was supposed to focus hard on features during that time to get a beta out soon, but I think I made the right call. The result is that I solved several major problems at once and the beta will have features that were otherwise impossible. This is how I evaluate those kinds of decisions.
It begins with the classic partnership: product and technology. Product is more than the thing that your users use. Product is the vision, and technology is the implementation.
Unfortunately, product and technology are often in conflict. The implementation is not delivering enough of the vision. You spent too much time on a feature that few people use. The implementation was rushed to get the product into people’s hands, but now it’s unmaintainable and needs to be rebuilt. It gets worse when multiple people are involved, opinions differ, and feelings get hurt.
We can align goals better by understanding the importance of both sides. As a developer, I’m naturally biased towards technological solutions, but this means it’s very easy to overestimate the real needs of the product and overcomplicate the solution. I’m getting better at taking a step back and trying to listen to the things that actually need to get done. At the same time, sometimes a thoroughly researched implementation changes everything.
Ultimately, the product is first. Ideas are great, technical achievements are impressive, but in the context of a company shipping a product, they ultimately mean nothing if users never see it and use it.
However, it’s very hard to quantify the impact of technology on product. Fruitful efforts do not always result in an immediate, concrete feature. What if you spent a lot of time improving general performance? What if several failed iterations of a feature led you to a really good idea? It’s not nearly as simple as “Let’s only work on things that immediately gives users more features.” There’s a lot of judgement calls here and it takes experience to develop a good sense of where to invest your time.
Still, it’s helpful to try to frame technology and product in a way that gives you a basis for making decisions. The way I like to think about it is two distinct pieces of the product: core and features.
Core is the part that is critical to your product’s vision. It’s the stuff that seriously should always just work. It’s the internal code that is thoughtfully-written with long-term goals in mind; code that is robust and allows you to rapidly research new ideas. It’s the part of the product that doesn’t change often.
Core is not just important internal code — it’s also the critical workflows in your product. I worked on Firefox’s developer tools, and even though they have hundreds of features, I’d probably only list ~20 of them as actual core workflows. For Actual, a budgeting system, I’d only list a few. Things like “entering a transaction, categorizing it, and seeing it appear on the budget page.” It’s an aggressively small subset of the entire app.
Features make up the rest of the app that make it actually useful to people. They are important too, but are much more fluid, isolated, and prone to breaking. Bugs in features are annoying but usually not a big deal. Features can be implemented quickly and change often, so uglier code is more acceptable. One of two things will happen: either the code will be changing a lot or it’s a rarely used feature. Either way it’s not worth investing so much time in each individual revision of code.
In short, features are disposable. You should be able quickly add features, heavily restructure them, and remove them without messing with the rest of the app. Over time you will be doing this a lot as you learn how users use your app (and even that changes over time).
Embrace the disposability of features. I find teams that do this tend to be healthy for two reasons: they can admit when they were wrong, and/or they are willing to scale back when they’ve taken on too much. I’ve worked on teams that couldn’t agree to remove barely-used features even though the team already had far too much on their plate, and it only added more burden. Some users may get angry, but the majority of your users will thank you for focusing on the more visible features.
The line between core and features isn’t actually a hard line, as depicted in the first image. It’s more ambiguous; some things aren’t clear whether they exist as core or features. These are things that are important to the product, but isolated enough to not be central, at least not yet.
Products change over time. Features that don’t get removed usually move into core. If a feature exists for years, it’s probably a core part of your product and deserves more attention. You have more time to focus on features and bring them into core anyway since core stabilizes over time. Eventually core grows bigger, and you start experimenting with new features.
While core is rock solid, it’s not immutable. Every so often features will require significant changes in core to work. This is a minor refactoring, and you should expect to do it every 2–3 years on average.
At some point, after many features have gradually moved to core the whole thing becomes too big, and a major refactoring is necessary. Due to how projects evolve over time and the industry shifts, it’s impossible to avoid this, and if handled correctly can be very healthy. The system might be reduced to a simpler implementation without losing features, unused features can be forgotten, and developers are happier. The extent of the refactoring depends on the project; it could just be an overall simplification. Expect this to happen every 5–7 years.
There are no hard rules for how big core and features should be. Core doesn’t have to be 10% of your product, it could be 90%. You have to know your product and decide the best way to build it. Context matters. Some products will have a small core and lots of features, others will have a big core and few features. It’ll also be different the first 6 months than a few years in.
Although it’s quite subjective, at a high-level the distinction is usually pretty clear, and it’s important to be aware of it because it influences how you build products. Everything from big decisions to small implementation details changes if you know how much to care about it. You make tons of little implementation decisions every day, and if you’re not careful you’ll end up with a mess.
For example, every day you decide how to structure your app and set up dependencies. If you’re building a feature and continuously pulling in pieces of other features, carelessly assuming they’ll always exist, you’ll end up with a highly coupled implementation that is brittle and very difficult to change. Imagine roots growing throughout your entire app freely — you lose agility and the ability to quickly experiment with large changes.
It’s better for features to exist in isolation as much as possible, and centralize only the core pieces. It’s worth it even if it causes duplication in feature implementation. I’d say in general it’s acceptable for feature code to be ugly, verbose, and under-abstracted. Keep it simple and straight-forward until it’s proven its merit and ready to be moved into core, where the cost of abstraction is worth it.
You’d be surprised how often you’ll throw away the ugly code and it never even makes it into core. This happens especially in the early stages of a product. You’ll save a ton of time and focus on what matters.
Once you’ve determined what the core part of your product is, invest heavily in it from the beginning. In the early stages even core code will be changing quite a bit, but work on it with the long-term vision in mind. Write abstractions that allow the vision to be fulfilled later, and write tests to make sure it’s robust.
Sometimes it’s better to write tools that make it easy to manually test instead of investing in complex testing setups, especially early on. Our brains are pattern matching machines and it’s remarkable how much we can verify in a small amount of time. Of course you should automate as much as possible, but sometimes a quick manual workflow wins out over a complicated, flaky setup.
For example, in Actual I have a testing mode that exposes a panel with buttons. These buttons each trigger a specific workflow: a runner will run code (manually written) that automatically performs actions at a slow pace, and I can visually watch it happen without any effort. I can see it add a transaction, categorize it, and look at the budget automatically. I can quickly make sure it all works. The added benefit is I get to feel these critical workflows first-hand and make sure they are solid. (Obviously this won’t scale, but it’s all I need right now to make sure the critical paths work.)
It’s important to have an idea of how important each part of your product is. You don’t have unlimited time so you need a way of prioritizing work. I’ve found it helpful to start with two tiers: core and features. Invest heavily in core and get it right, and embrace the disposability of features.