For me it's a coupling problem and related to granularity of design. Even the loosest form of coupling introduces dependencies from one thing to another. If that is done for hundreds to thousands of objects, even if they are all relatively simple, adhere to SRP, and even if all the dependencies flow towards stable abstractions, that yields a codebase which is very difficult to reason about as an interrelated whole.
There are practical things that help you gauge the complexity of a codebase, not frequently discussed in theoretical SE, like just how deep into the call stack you can get before you reach the end, and how deep you need to go before you can, with a great deal of confidence, understand all possible side effects that could occur at that level of the call stack including in the event of an exception.
And I've found, just in my experience, that flatter systems with shallower call stacks tend to be so much easier to reason about. An extreme example would be an entity-component system where components are just raw data. Only systems have functionaltiy, and in the process of implementing and using an ECS, I found it the easiest system ever, by far, to reason about when complex codebases that span hundreds of thousands of lines of code basically boil to a few dozen systems that contain all the functionality.
Too Many Things Provide Functionality
The alternative before when I worked in previous codebases was a system with hundreds to thousands of mostly-tiny objects as opposed to a few dozen bulky systems with some objects used just to pass messages from one object to another (
Message object, e.g., which had its own public interface). That's basically what you get analogically when you revert the ECS back to a point where components have functionality and each unique combination of components in an entity yields its own object type. And that will tend to yield smaller, simpler functions inherited and provided by endless combinations of objects that model teeny ideas (
Particle object vs.
Physics System, e.g.). However, it also tends to yield a complex graph of inter-dependencies that makes it difficult to reason about what happens from the broad level, simply because there are so many things in the codebase that can actually do something and therefore can do something wrong -- types that are not "data" types, but "object" types with associated functionality. Types that serve as pure data with no associated functionality cannot possibly go wrong since they can't do anything on their own.
Pure interfaces don't help this comprehensibility problem that much because even if that makes "compile-time dependencies" less complicated and provides more breathing room for change and expansion, it doesn't make the "runtime dependencies" and interactions any less complicated. The client object still ends up invoking functions on a concrete account object even if they're being called through
IAccount. Polymorphism and abstract interfaces have their uses but they don't decouple things in the way that really helps you reason about all the side effects that could go on at any given point. To achieve this type of effective decoupling, you need a codebase that has much fewer things that contain functionality.
More Data, Less Functionality
So I've found the ECS approach, even if you don't apply it completely, to be extremely helpful, since it turns what would have been hundreds of objects into just raw data with bulky systems, more coarsely designed, that provide all the functionality. It maximizes the number of "data" types and minimizes the number of "object" types, and therefore absolutely minimizes the number of places in your system that can actually go wrong. The end result is a very "flat" system with no complex graph of dependencies, just systems to components, never vice versa, and never components to other components. It's basically much more raw data and much fewer abstractions which has the effect of centralizing and flattening the functionality of the codebase to key areas, key abstractions. And while that produces denser code in those centralized areas, I find it much easier to reason about a physics system that isolates and centralizes its complexity than 30 different interrelated object types that could make up a physics system if they were provided functionality.
30 simpler things are not necessarily simpler to reason about than 1 more complex thing, if those 30 simpler things are interrelated while the complex thing stands on its own. So my suggestion is actually to transfer complexity away from the interactions between objects and more towards bulkier objects that don't have to interact with anything else to achieve mass decoupling, to whole "systems" (not monoliths and god objects, mind you, and not classes with 200 methods, but something considerably higher level than a
Message or a
Particle in spite of having a minimalist interface). And favor more plain old data types. The more you depend on those, the less coupling you'll get. Even if that contradicts some SE ideas, I've found it really helps a lot.