Mind the gap
If you've had an experience like this you know it's no fun place to be.
So how do we solve it?
Well, to do that we need to understand what causes small changes to spiral into massive, risky tasks.
In my experience it's all down to the gap.
Specifically, the gap between the requirement (which sounds simple enough) and the complexity of the implementation.
The bigger the gap, the harder it is to make your changes.
This gap can take many forms; It might appear as an unnecessarily large number of separate C# projects...
Or a class which takes in many, many dependencies.
It might be that all the business logic is stored in a multitude of stored procedures, with inconsistent and misleading names.
Or, and this is where we'll focus for the rest of this short book, it can be found in the way your app is architected, with features divided up into lots of small pieces and scattered throughout your application.
When this happens, you can end up spending a lot of time hunting those pieces down, desperately trying to hold everything your head so you can keep everything working when you make your changes.
Why your projects devolve into a hot mess
Like most things in life we can't very well solve a problem until we understand it, so what causes codebases to descend into a chaotic, sprawling mass of interconnected methods in the first place?
The thing is, your project wasn't always like this. If you could step back in time to when the project first emerged, blinking into the sunlight, you'd no doubt find a small, beautifully formed, gem, with code all neatly organised and separated.
You've probably seen some variation of this.
Business logic goes in the Business Logic layer (manipulation of data, combining data from various sources etc.).
Data access goes in the Data Access layer (typically where you connect to your database and execute queries and/or commands).
Finally you have your controllers (or pages if you're using Razor Pages) which are there to handle the ASP.NET side of things (requests, responses, HTTP and auth matters). Other than that they make calls to the business logic layer and handle the response.
Now this is all well and good at the start, when the project is small, features are few and you're just getting started.
However, nothing stays small for long, and as more feature requests come in you face endless choices...
- Should this feature use the existing service method or create a new one?
- Should you re-use that repository method for this feature?
- Should you spin up a new service or use the existing one?
- Should these two methods share the same model?
- Should the controller ever call a repository directly?
- Should the same service call multiple repository methods?
That's an awful lot of "should" questions!
It turns out, this architectural approach, far from being simple, clear and organised, forces you into near-constant decision taking, and it's not long before those nice clear boundaries devolve...
This is still a relatively small project, but already problems are emerging!
Conditional logic == harder to reason about the code
Every time you see a method with multiple calls (like
TicketRepository.Details in this example) it has multiple reasons to change.
The danger with multiple reasons to change is you end up writing conditional code to handle those different cases. Conditional code makes it much, much harder to quickly figure out what's going on with any given method, requiring you to keep track of which "branch" you're on for the scenario you're trying to understand.
One model, multiple uses
You also end up with the model problem.
TicketRepository.Search for example. If there are details which
AgentService.GetTickets needs but
TicketService.Search doesn't, you either end up returning them in all cases anyway or accepting nulls in your model.
If you've ever had to chase down null reference exceptions in your code you know this pain!
As projects evolve it's not unusual to find methods with many references.
When this happens, your "small change" to one part of the code suddenly has the potential to ripple back up through all those references and cause chaos in an area of the application you didn't even realise was connected!
Ever expanding classes
When you have any
class in your code you essentially have an infinite bucket in which to throw your code!
There are no constraints on how big your classes can be and, over time, they tend to get bigger.
The problem with controllers, and services, and repositories is the methods in there aren't really related in any meaningful way.
TicketService has 30 methods, in practice most (if not all) of them are unrelated to the others. When you come to work on any given method you have to skip past all that other code to find the bit you're interested in.
Worst, if multiple methods in the class share logic (via private methods) you end up jumping around when you try to figure out what's going on.
Oh, and remember that problem of one method having multiple references, it applies to private methods too!
Once a private method in your class is called by lots of other methods in the class you're back to the problem of one small change creating ripples in your application.
And the problem with ripples is they tend to turn into waves...
Entropy will have its way...
There's no compiler or IDE in the world which will enforce your layered architectural approach.
Sure, you might know exactly how you wanted this all to work, with each layer neatly organised, calls between classes/methods kept to a minimum, classes and methods with single responsibilities etc.
And, if you're the sole developer on the project and you really focus 100% of the time on keeping everything organised, with minimal coupling and conditional logic you might just manage to keep your project in check.
But then, when Sam, your friendly but assertive Product owner sticks her head in the door and asks if you can get your fix out in the next ten minutes because the marketing team need it for their demo, good luck keeping those neatly organised layers intact.
Maybe just this once you'll have to re-use that one tiny method, or throw in an
if statement to handle this urgent, last minute requirement.
Then there's the multiple developers issue.
As soon as you have multiple developers working on the code it's very likely that some (or all) of them won't fully understand how all the layers are supposed to work, nor how to keep coupling to a minimum and the other nuances of keeping this particular architectural approach in check.
Inevitably more and more "one offs" and "quick fixes" are piled on top of each other and, after a few months of fraying at the edges, it's no real surprise when your entire application unravels.
Given all these problems you may be wondering, what's the alternative?
Is there another way to set your project up for a greater chance of success, with less potential for entropy to weave it's tangled web?