I think the real main issue is lack of discipline when dividing the application into modules.
Spaghetti is basically defined as an application where real modularisation does not exist and everything talks to everything.
It is much easier to work with an application when you can abstract parts of it when you are solving your problem. You effectively work on much smaller part of the application.
Spaghetti == you effectively have to take into account possibility of the piece of code you look at interacting with any other piece of code in the application.
Well modularised application == you only need to take into account the contents of your current module and the interface of the other modules you are using.
One reason why microservices sort of work (when done well) is because they force people to think about APIs and how those services talk to each other.
In most cases you could just put these microservices as modules in a monolithic application and expend the effort on ensuring APIs and application structure.
I have successfully rolled back couple microservice initiatives by integrating services into monoliths. This usually results in the team getting back a lot of their time because their environment suddenly became much simpler. Less applications to manage, less network communication, less possible ways for things to break, less frameworks, less resources needed to run the application, less processes (like processes around deployment, release management, etc.), less boilerplate code, and so on. The list is very long.
Of course, when you work on a large monolith vs a lot of small microservices, it is now important to be able to structure your applications. But there is also an opportunity for improvement.
Sure, if you were ready to make an entire separate application for it it will not be worse when you turn it into one package of a monolith?
Also you can build nested structures with modules (like a larger module consisting of smaller submodules).
Divide and conquer. This is how abstraction works in a nutshell. You start with a large problem, divide it into smaller parts (modules), each part you treat as a separate problem dividing it into smaller modules, and so on.
There exists no need to have a flat application consisting of hundreds of sibling modules.
An application might consist of larger modules, then some of those might have smaller submodules (packages), then those might have even smaller ones (classes), then you have methods, then you have statements, then you have function calls, etc.
A large monolith does not have to succumb to spaghetti or ravioli-type structuring. A good developer should know how to structure an application of any size.
Then instead of having a IService with 15 methods with a construction with 20 dependencies listed (multiply it with say 50 IServices in your project - mmmm spagheti project), you end up with a IHandler with a single method and a constructor that only has say 3 injected dependencies, only what it needs. The trade off is, you now have 100's of small Handler files, some people don't like that BUT you now have a pleasant git commit history per file and most of the code of a Handler file fits on one screen and it is easy to digest. You can also let a handler trigger other handlers if it needs to (via the same mediator). It also fits with SRP.
Auto-wiring 95% of your dependencies still stays intact as the above plumbing will need it to work.
You can run a single copy of the resulting binary (eg. for testing) that spins up all sub-components, or copy it to multiple machines and start individual parts. The ones which happen to run together can use in-process communication as well, others will have to dial in via remoting.
Have worked at multiple companies that started out as a monolith and are still running the monolith in some form or the other while breaking it down into micro services.
I like to analogize it to assembly vs structured programming languages, or C vs Java- you can write the same programs in either, you can write bugs in either, but there are whole classes of pitfalls that get erased by moving from one language to another.
I have experienced this pain so many times and in so many different varieties. Often, you cannot meaningfully subdivide the problem space without ruining the logical semantics per the business (i.e. bowl of spaghetti).
An alternative is to embrace the reality that circular dependencies are actually inevitable and appropriate ways to model many things.
The example scenario I like to use is that of a typical banking customer. One customer likely has a checking and savings account. For each of those accounts, there are potentially multiple customers (joint ownership). Neither of these logical business types will ever "win" in the real world DI graph. Certainly you can start to invent bullshit like AccountCustomer and CustomerAccount, but that only gets you 1 layer deeper into an infinitely-recursive rabbit hole of pain and suffering. There also exists the relational path, but I have heavily advocated for that elsewhere and it is not always applicable when talking about code-time BL method implementation. Being able to model things just as they are in the real world is a big key to success in the more complicated problem domains.
Instead of trying to control what depends on what, I shifted my thinking to:
> What needs to start up and in what order?
Turns out, most things don't really care in practice. The only thing I have to explicitly initialize before everything else in my current code base is my domain model working set (recovery from snapshot/event logs) and settings. I decided to not use DI for any business services. Instead, all services become a static class with static members that can be invoked from anywhere. This also includes the domain model instance which is used as the in-memory working set. This type just contains an array of every subordinate domain type (Customers, Accounts, etc.). By having the working set available as a public static type, every service can directly access it without requiring method injection. If I was working with a different problem domain (or certain bounded context within this one), I might prefer method-level injection.
Yes - according to every book on programming style you ever read, this is an abominable practice. Unit testing this would be difficult/impossible. But you know what? It works. It's simple. I can teach a novice how to add a new service in an hour. A project manager stumbling into AccountService might actually walk away enlightened. You can circularly-reference things at runtime if you need to. I've got some call graphs that bounce back and forth between customer & account services 5+ times. And it totally makes sense to model it that way too as far as the business is concerned. Everyone is happy.
I used to get upset about this, but now I embrace it. If a business method requires certain things to operate, then modeling those things as arguments to the method is totally reasonable. Some business is complicated and messy so we would expect more arguments to be involved. Trying to sweep reality under the rug just makes things 10x harder elsewhere.
I think there is value in wiring a monolith together in such a way that each course grained subcomponent exposes a constrained interface into the rest of the system (payments, orders, shipping, customers etc) before needing to break it into distributed micro-services.
Note: I quite like Dependency Injection, I just think the 1 giant bag of dependencies can lead to complexity at scale.