Why I Recommend a Feature-Driven Approach to Software Design
Here are a few pressing questions:
- What is the single-most important concept you can tell me about software design?
- How do you structure a (frontend or backend) so that it's testable, flexible, and maintainable?
- What am I supposed to test against in my tests? Classes? Methods? React components? How can I write tests that actually give me confidence in my code?
At first glance, these questions might appear to be a little bit unrelated. General software design advice, project structure, testing best practices. Hard to see the link, right?
After having studied software design for a few years, the answer to each of these questions — and nearly all questions about how to write testable, flexible, and maintainable software, is to focus on the features.
That is, I'm telling you to be feature-driven.
In it, he discusses the ideas of essential complexity and accidental complexity.
Software, in its simplest form, is a way to tame complexity in the world. We do this by developing use cases (or features, you may call it) that cut through entropy and perform something useful for us.
If my goal is to "make friends in a new city", I can divide the application that helps me do that up into use cases/features. Three features/use-cases may be:
- Sign up
- Edit profile
- Get friend suggestions
The essential complexity involved in solving the problem of "making friends in a new city" is to first identify all of the features - then, to merely implement them.
Once all the features/use cases/vertical slices are implemented, we're done.
However, since we live in the real-world, we have to write code to accomplish this.
And in most mainstream programming languages, the concepts of state and sequence exist.
What's wrong with state and sequence?
Well, according to Ben Moseley, state and sequence are the two leading causes of accidental complexity.
- State — "Have you tried turning it off and on again?" Has anyone ever said that to you before? This is what happens when a system ends up in an illegal state. And maintaining state is hard. We have to keep track of variables, instances, etc.
- Sequence — "First we do this, and then we need to do this, but only if this happens". Most of us write in languages that support procedures and conditionals. When we have to ensure that things happen in a particular order, we're also introducing surface area for complexity.
Accidental complexity is when the complexity we're faced with is not actually related to the complexity of the problem (like the essential ones — the features). Instead, accidental complexity is related to the way we solve problems.
That means that languages or paradigms that contain state and sequence inadvertently introduce accidental complexity.
Okay, can we write code without state and sequence?
It turns out that we can.
This is where (purely) functional programming shines. Scott Wlaschin's Domain-Modeling Made Functional book depicts an approach to get as close to the essential complexity as possible by modelling features/use cases (he calls them workflows) using minimal-to-no state and sequence.
For example, here's some pseudo-code for a feature that can be modelled entirely using F#'s algebraic type system.
This is a really thorough approach. I think it's amazing. But real pure-ish functional programming isn't for the faint of heart. This is some seriously disciplined programming. By modeling the entire domain from scratch, similar to the way we build up math equations from first principles, this is truly the way that we make illegal states unrepresentable.
However, most of us want a little bit of state and sequence. Modelling purely functional features like this can be challenging. For many, composing software this way is an entirely different way to think about programming. Even RxJs can be challenging.
Like most things in design, there's a push-pull between priorities.
In software design, I call it the balance between Structure and Developer Experience.
You want a structured approach to doing things, but you don't want it to hinder your productivity.
Well, that's certainly some accidental complexity that we're taking on isn't it? Yes... and it's fine.
But that doesn't mean we don't need to keep tabs on it.
Luckily for us, OOP folks from the 90s discovered a great feature-first way to consistently, reliably, and confidently write code containing state and sequence.
It's called TDD.
There's more to Test-Driven Development than just unit testing React components and classes.
The best ROI comes through figuring out a way to continue to add and change code, and ensure that the features of our application still work. This is where the majority of our efforts should lie — because the features are the essential complexity, after all.
The "feature-first way" to do TDD is to do Double Loop TDD.
- Outer loop: Write the acceptance test (which can be written as an integration or end-to-end test); this is the essential complexity written in English.
- Inner loop : Red-Green-Refactor your components, classes, etc until the outer loop passes.
This isn't a new thing, I just think that we're all struggling to agree on the terminology and recognize that we're all talking about the same things (see this blog post from the Dodds).
Some of the best techniques I've found that help us align with the essence of the problem at hand and cut out the noise (tech stack, language, paradigm, etc) are as follows:
- Event Storming
- Event Modeling
- Use-Case Driven Design
- Acceptance Test-Driven Development (also called Double Loop TDD)
- ... way more, to be continued
We get a lot of benefits when we apply a "feature-first" approach.
At the folder-level:
- More cohesive feature folders
- More discoverable folder structures
- Less "cognitive load" flipping around files/folders
- Screaming architecture (easy to remember what the system does)
At the system level:
- Consistent testing strategy with a high ROI (acceptance test for each feature)
- Simpler architecture
- Easy to keep tabs on coupling between features/vertical slices (SRP)
At the professional level
- Decouple large projects into sets of features
- Provide better estimates
- Understand where new technologies fit into a vertical slice (feature)
The backend follows a feature-first project structure, organized by use cases.
The frontend application is currently being refactored to follow a feature-first approach for the next iterations of the Client-Side Architecture Basics guide. In the next iterations, you'll learn:
- How to craft a feature-first project architecture
- How to consistently evolve code using TDD & Unit, Integration, and E2E tests regardless of your stack
Part II — Humans & code from solidbook.io
I'll be writing more content about this phenomenon on the blog, but if you're looking for an in-depth discussion on how to design human-friendly codebases, the Humans & Code part of solidbook.io just went out last week. We learn how to structure repos, organize things, name them, handle errors (and more) — all in a feature-first way.
Check it out here.
Liked this? Sing it loud and proud 👨🎤.
Stay in touch!
View more in Software Design
You may also enjoy...
A few more related articles
Software Design and Architecture is pretty much its own field of study within the realm of computing, like DevOps or UX Design. Here's a map describing the breadth of software design and architecture, from clean code to microkernels.
Learn how to use DDD and object-oriented programming concepts to model complex Node.js backends.
Want to be notified when new content comes out?
Join 10000+ other developers learning about Domain-Driven Design and Enterprise Node.js.
I won't spam ya. 🖖 Unsubscribe anytime.