On Separation of Concerns vs Locality of Behavior
Separation of concerns is always good. Or is it? A more critical look at software engineer's most abused virtue.
Many software developers consider separation of concerns (SoC for short) as a universally good thing. If you’re not practicing SoC in all things, you are doomed to repetitive, unmaintanable code. And anything that attempts to unify traditionally separate code in one place, like Tailwind or server components, is inherently bad.
But is that really the case? Of course nobody likes verbose code where you need to change 10 repetitive files to make one practical change. However, excessive separation of concerns also leads to the same problem: repetitive scattered code. Separation of concerns works best when balanced by its counterpart: Locality of Behavior.
How Much Separation of Concerns Is Too Much
My personal separation of concerns epiphany came when I started working with Redux and ngrx in 2016-2017. Their neat diagrams for state management looked very nice, and the idea of separating concerns between “I’m taking some action” and “I’m updating the app state” sounded good in theory.
However, I quickly found that these frameworks had taken SoC to its logical extreme. The result was facetious levels of SoC that blurred the line between real software dev and parodies like Enterprise Grade Fizzbuzz.
Suppose you wanted to add a new button that made a new HTTP request to a component. You needed to:
Add a new entry MY_ACTION to the `user.actions.ts` `ActionTypes` enum
Add another new entry MY_ACTION_SUCCESS
Export a new class MyAction for your action that implements `Action` from `user.actions.ts`
Update the `user.actions.ts` `Actions` type export to include the new MyAction class
Add a new effect to `user.effects.ts` that matches MY_ACTION
Add a new effect to `user.effects.ts` that matches MY_ACTION_SUCCESS
Add a new case statement for MY_ACTION to `user.reducer.ts` that updates the state (usually just a repetitive Object.assign()`
Add another case statement for MY_ACTION_SUCCESS in `user.reducer.ts`
Finally add the button to the component and have the button dispatch MY_ACTION
Add handling for the state change from MY_ACTION_SUCCESS
This multi-step process was so tedious that the Angular team had to write a CLI to automate it.
This level of boilerplate wasn't just a mild inconvenience; it made adding a single button challenging, and made debugging a monumental struggle.
Another major issue was the misalignment between the code structure and the tasks developers needed to complete. Tickets were typically framed as "add a new feature to user checkout," not "improve reducers." Yet, every new feature or bug fix for a particular component required changes across the same 6-10 files. This fragmented approach resulted in frequent merge conflicts, slow code reviews, and overlapping work.
Bringing Back Sanity With Locality of Behavior
Locality of Behavior emphasizes keeping related logic together in one place. Specifically, grouping code together by function (“these functions handle the user checking out”) vs form (“these functions are reducers”) means your code is more closely aligned with how requirements are structured. Some of the benefits are:
Easier Code Reviews
Tracing through dozens of files to follow a single workflow doesn’t just slow down debugging. The cognitive load is also passed on to code reviewers.
Fewer Merge Conflicts
Even if developers are working on unrelated features in the same component, they still need to modify numerous repetitive boilerplate files. And that meant multiple merge conflicts if multiple developers were working on the same component.
Easier Troubleshooting
If all relevant code fits on your screen, debugging is easier than if you have to context switch through 8 different files.
Easier Testing
Testing an `add(a, b)` function is easy. Locality of behavior means your business logic is structured in easily testable functions like `add(a, b)`, not scattered across dozens of files glued together by a sophisticated framework.
Easier Development
Something has gone horribly wrong if you need a CLI tool just to add a new button to a web app.
For example, the following is a checkout function from one of our clients’ apps. This function is easy to review and test: all logic fits on one screen, and you can test by stubbing some dependencies with Sinon. Imagine if, instead, you had the HTTP request, toasts, and analytics logic in separate effects, with action creators for creating the HTTP request and a separate reducer.
One good point that I’ve heard people make is that SoC-heavy patterns like ngrx and Redux make the codebase more globally understandable, at the expense of making the codebase less locally understandable.
I believe that is an unacceptable tradeoff. As a software architect, your job is to actively make the codebase more locally understandable, so developers can more effectively contribute without understanding the whole system. If nothing else, your system eventually gets too complex for any one person to hold it all in their head, at which point local understanding is all you have.
Separation of Concerns Isn’t Always Good
It isn’t always bad either. Software is all about tradeoffs. For example, I’m not necessarily suggesting you ship your app as one monolithic PHP file, even though that works for some people. Locality of behavior is more of a balancing, moderating force than a replacement for separation of concerns.
When taken to the extreme, without regard for the practical realities of software development, separation of concerns turns your codebase into scattered unmaintainable spaghetti. As a software architect, it is your job to determine how much separation of concerns is enough, versus how much is too much.