His concern was that the large number of classes would translate to a
maintenance nightmare. My view was that it would have the exact
opposite effect.
I am absolutely on the side of your friend, but it could be a matter of our domains and the types of problems and designs we tackle and especially what types of things are likely to require changes in the future. Different problems, different solutions. I don't believe in right or wrong, just programmers trying to find the best way they can to best solve their particular design problems. I work in VFX which is not too unlike game engines.
But the problem to me that I struggled with in what might at least be called somewhat more of a SOLID-conforming architecture (it was COM-based), might crudely be boiled down to "too many classes" or "too many functions" as your friend might describe. I would specifically say, "too many interactions, too many places that could possibly misbehave, too many places that could possibly cause side effects, too many places that might need to change, and too many places that might not do what we think they do."
We had a handful of abstract (and pure) interfaces implemented by a boatload of subtypes, like so (made this diagram in the context of talking about ECS benefits, disregard the bottom-left comment):
Where a motion interface or a scene node interface might be implemented by hundreds of subtypes: lights, cameras, meshes, physics solvers, shaders, textures, bones, primitive shapes, curves, etc. etc. (and there were often multiple types of each). And the ultimate problem was really that those designs weren't so stable. We had changing requirements and sometimes the interfaces themselves had to change, and when you want to change an abstract interface implemented by 200 subtypes, that's an extremely costly change. We started mitigating that by using abstract base classes in between which reduced the costs of such design changes, but they were still expensive.
So alternatively I started exploring the entity-component system architecture used rather commonly in the gaming industry. That changed everything to be like this:
And wow! That was such a difference in terms of maintainability. The dependencies no longer flowed towards abstractions, but towards data (components). And in my case at least, the data was far more stable and easier to get right in terms of design upfront in spite of the changing requirements (though what we can do with the same data is constantly changing with changing requirements).
Also because the entities in an ECS use composition instead of inheritance, they don't actually need to contain functionality. They are just the analogical "container of components". That made it so the analogical 200 subtypes that implemented a motion interface turn into 200 entity instances (not separate types with separate code) which simply store a motion component (which is nothing but data associated with motion). A PointLight
is no longer a separate class/subtype. It's not a class at all. It's an instance of an entity which just combines some components (data) related to where it is in space (motion) and the specific properties of point lights. The only functionality associated with them is inside the systems, like the RenderSystem
, which looks for light components in the scene to determine how to render the scene.
With changing requirements under the ECS approach, often there was only a need to change one or two systems operating on that data or just introduce a new system on the side, or introduce a new component if new data was needed.
So for my domain at least, and I'm almost certain it is not for everyone, this made things so much easier because the dependencies were flowing towards stability (things that did not need to change often at all). That wasn't the case in the COM architecture when the dependencies were uniformly flowing towards abstractions. In my case it's so much easier to figure out what data is required for motion upfront rather than all the possible things you could do with it, which often changes a bit over the months or years as new requirements come in.
Are there cases in OOP where some or all of the SOLID principles do
not lend themselves to clean code?
Well, clean code I can't say since some people equate clean code with SOLID, but definitely there are some cases where separating data from functionality as the ECS does, and redirecting dependencies away from abstractions towards data can definitely make things a lot easier to change, for obvious coupling reasons, if the data is going to be a lot more stable than the abstractions. Of course dependencies to data can make maintaining invariants difficult, but ECS tends to mitigate that to the minimum with the system organization which minimizes the number of systems that access any given type of component.
It's not necessarily that dependencies should flow towards abstractions as DIP would suggest; dependencies should flow towards things that are very unlikely to need future changes. That may or may not be abstractions in all cases (it certainly wasn't in mine).
- Yes, there exist OOP design principles which partially conflict with
SOLID
- Yes, there exist OOP design principles which completely conflict with
SOLID.
I'm not sure if ECS is truly a flavor of OOP. Some people define it that way, but I see it as very different inherently with the coupling characteristics and the separation of data (components) from functionality (systems) and the lack of data encapsulation. If it to be considered a form of OOP, I would think it is very much in conflict with SOLID (at least the strictest ideas of SRP, open/closed, liskov substitution, and DIP). But I hope this is a reasonable example of one case and domain where the most fundamental aspects of SOLID, at least as people would generally interpret them in a more recognizable OOP context, might not be so applicable.
Teeny Classes
I was explaining the architecture of one of my games which, to my
friend's surprise, contained many small classes and several
abstraction layers. I argued that this was the result of me focusing
on giving everything a Single Responsibility and also to loosen the
coupling between components.
The ECS has challenged and changed my views a lot. Like you, I used to think the very idea of maintainability is having the simplest implementation for things possible, which implies many things, and furthermore, many interdependent things (even if the interdependencies are between abstractions). It makes the most sense if you are zooming in on just one class or function to want to see the most straightforward, simple implementation, and if we don't see one, refactor it and maybe even decompose it further. But it can be easy to miss what's going on with the outside world as a result, because any time you split anything relatively complex into 2 or more things, those 2 or more things must inevitably interact* (see below) with each other in some way, or something outside has to interact with all of them.
These days I find there's a balancing act between the simplicity of something and how many things there are and how much interaction is required. The systems in an ECS tend to be rather hefty with non-trivial implementations to operate on the data, like PhysicsSystem
or RenderSystem
or GuiLayoutSystem
. However, the fact that a complex product needs so few of them tends to make it easy to step back and reason about the overall behavior of the entire codebase. There's something to it there which might suggest that it might not be a bad idea to lean on the side of fewer, bulkier classes (still performing an arguably singular responsibility), if that means fewer classes to maintain and reason about, and fewer interactions throughout the system.
Interactions
I say "interactions" rather than "coupling" (though reducing interactions implies reducing both), since you can use abstractions to decouple two concrete objects, but they still talk to each other. They could still cause side effects in the process of this indirect communication. And often I find my ability to reason about a system's correctness is related to these "interactions" more than "coupling". Minimizing interactions tends to make things a lot easier for me to reason about everything from a bird's eye view. That means things not talking to each other at all, and from that sense, ECS also tends to really minimize "interactions", and not just coupling, to the barest of minimums (at least I haven't found any other popular architectural design which reduces it any further in my domain), if only because it can capture the design of a complex product with so few systems, and because the systems don't directly talk to each other (just the central "ECS database").
That said, this might be at least partially me and my personal weaknesses. I've found the biggest impediment for me to create systems of enormous scale, and still confidently reason about them, navigate through them, and feel like I can make any potential desired changes anywhere in a predictable fashion, is state and resource management along with side effects. It's the biggest obstacle that starts to arise as I go from tens of thousands of LOC to hundreds of thousands of LOC to millions of LOC, even for code that I authored completely on my own. If something is going to slow me down to a crawl above all else, it's this sense that I can no longer understand what's going on in terms of application state, data, side effects. It's not the robotic time that it requires to make a change that slows me down so much as the inability to understand the full impacts of the change if the system grows beyond my mind's ability to reason about it. And reducing the interactions has been, for me, the most effective way to allow the product to grow much larger with much more features without me personally getting overwhelmed by these things, since reducing the interactions to a minimum likewise reduces the number of places that can even possibly change application state and cause side effects substantially.
It can turn something like this (where everything in the diagram has functionality, and obviously a real-world scenario would have many, many times the number of objects, and this is an "interaction" diagram, not a coupling one, as a coupling one would have abstractions in between):
... to this where only the systems have functionality (the blue components are now just data, and now this is a coupling diagram):
And there are thoughts emerging on all of this and maybe a way to frame some of these benefits in a more conforming OOP context which is more compatible with SOLID, but I haven't quite found the designs and words just yet, and I find it difficult since the terminology I was used to throwing around all related directly to OOP. I keep trying to figure it out reading through people's answers on here and also trying my best to formulate my own, but there are some things very interesting about the nature of an ECS that I haven't been able to perfectly place my finger on that might be more broadly applicable even to architectures that don't use it. I also hope this answer doesn't come off as an ECS promotion! I just find it very interesting since designing an ECS has really changed my thoughts drastically, since it started out by challenging lots of things I thought were essential for maintainability.