Complexity can only be shuffled around. It can't entirely disappear. We can either gather our complexity in a few large components, or spread it out over many simple components. But those components now have more complex interactions.
The “trick” is to find a sweet spot between these too extremes. The components need to be sufficiently small that you can easily understand them. But not too small, because then most complexity stems from the interaction between these parts. I've illustrated that relationship in this totally scientific diagram:
Fig. 1: Finding a local minimum of complexity between too many components and too large components.
Refactoring techniques like extract-method or extract-class increase the number of moving parts. They are only helpful up to a point. If this is overdone the parts are so small that they provide no useful abstraction.
Sometimes, it's a good idea to reverse that process and remove abstractions. You often end up with a lot less code. This can make a project noticeably less complex.
So how can we decide which parts should be extracted, and which parts should be kept together?
Cohesion is a quality attribute that describes whether the things in a component actually belong together. Higher cohesion is better. The single responsibility principle (SRP) leads to higher cohesion.
Cohesion is often less of a code issue than a modelling issue. If the different parts of a model are roughly on the same level of abstraction, this is going to be all right. If parts of your model are addressing a largely unrelated concern, you might want to structure that as a separate model. See also the wonderful idea of a “bounded context” in DDD.
Coupling is a quality attribute that describes the interdependence between components. Less coupling is better.
For example, two components are coupled if a change of requirements will usually lead to changes in both of these components. Maybe they should be combined into a single cohesive component. Or maybe they should be decoupled.
Two components are also coupled if they somehow communicate with each other. That's not a problem by itself, because some communication is necessary. But it matters how that communication happens. Ideally, every component only depends on few other components and is very open about this dependency: by only talking to components that were provided as a parameter (dependency inversion) we enable dependency injection.
Cohesion and coupling are not the same thing, but they are closely related. E.g. it is better if a component depends on a single cohesive dependency than on multiple separate dependencies.
Abstractions have diminishing value the smaller they get. Each abstraction carries a bit of conceptual overhead because at the very least you have to understand what this abstraction is doing. If you also need to know how the abstraction is implemented, you've got a leaky abstraction.
Creating a non-leaky abstraction can be non-trivial: you need to find an evocative name, define a clear contract, write sufficient documentation, and test it independently.
In order to keep the abstraction clearly decoupled from your other code, it can help to develop it independently, e.g. creating a separate class, package/module, library , project, or microservice (depends on the scale of the abstraction).
Better abstractions fulfil some use case and don't just hide some implementation detail. Implementation details might change, but high-level goals last. E.g. a
notifyCustomer() abstraction is much better than
sendEmail() (unless this is an email program, of course).
For an abstraction to really pay off, it needs to be used multiple times. The conceptual overhead of an abstraction is per-definition, not per-use. So don't extract something just because it could stand on its own. However, extracting something from a component in order to make that component more cohesive is still a valid reason – just be aware that this move isn't free.
In most cases: YAGNI (you ain't gonna need it). It is easier to identify abstractions after they become necessary.
For complicated components there are two ways to split them: by feature (vertical) or by layer (horizontal). The question is: how do I get cohesive, decoupled pieces that provide useful abstractions to other code?
Splitting by feature is sensible when each vertical works independently from the others, and they do not share a data model. For example, an MVC application may use multiple controllers and multiple models, which leads to the idea of a model–view–presenter “axis”.
Splitting by layer is helpful when we can identify a set of operations that can be moved to a base layer, and all features can be implemented on top of those operations. So the base layer must provide a useful abstraction that can be consumer by the upper layer.
So there are many high-level approaches to structure your code into reasonable components. There usually isn't a right solution. Refactoring is a judgement call. But its worth keeping in mind that even well-intentioned refactoring can be too much and miss the point: making the code more simple.