I don't understand what is the problem of having a lot of small classes?
They are there when you need them, and they get out of the way you don't, and I think that's the most important thing.
Indirection. Every time you add a new class to do something and call it from somewhere else you've added an extra level of indirection. This can be good when dealing with complex logic but for many cases it just hinders code readability.
When the actual logic resides deep in a call stack, it's more difficult to debug code, much less bring on new developers. Each additional abstraction layer is, unsurprisingly, another layer that you're going to have to trace through to get at the root of the issue.
Similarly, when your logic doesn't reside at the root level, it's easy to accidentally bypass. Perhaps this is intentional, but I'd argue that intentionally bypassing certain logic under certain conditions indicates your logic is poorly factored. My most common example to this is an admin panel: admins can, as an example, change any user's name, not just their own. However, this only holds true when in the admin panel. So in your user controller, you have some sort of permission check ensuring that the user being operated on is the one authenticated[1], but intentionally don't place that check in your admin controller. One day in the name of a better UX you add an AJAX component to this, and forget to copy across that check into the ajax endpoint. Suddenly I can change anyone's username, despite not being an admin.
To solve this, we've created a couple of libraries: PermissionCheck and AuthContext; the former (typically) depends on the latter. AuthContext answers the questions of who are you (authentication) and where are you (context), and the permission check wraps up the authorization component: in the (web frontend | api | admin panel | cronjob | upgrade script) does the currently authenticated user have permission to do X on object Y? By putting these checks in the models, it's easy to ensure they're always enforced (a user's updateName method may call checkUpdateName()->enforce() ); if you want to side-step (or ramp up) authorization under a certain context, you edit the model's permission check for that action, rather than one or more controllers.
Could you put that into a different helper library? Probably, if you were so inclined. However, that leaves you with at least two files to check when you want to examine behavior, and an inability to use any protected or private methods from these permission checks.
Having an object that does a lot of stuff is not necessarily the much-feared "God object", although it certainly can become that. I've learned to become more fearful of the "God methods" instead; having 300 10-line methods doesn't concern me nearly as much as 10 300-line methods. Breaking code apart simply because you want a smaller file means you have a crappy text editor. Break code apart when it's logically doing more than it should be doing, or is poorly encapsulated.
This is, to a limited degree, an issue I have with mocking in unit tests: while great in theory (test components individually and trust that your interfaces/contracts/whatever ensure that A and B independently working mean that in production they will work together), in practice the two components do have a dependency on each other, and it's exceedingly difficult to reproduce production issues because of that fake isolation. How many ways have you seen an external API fail? Have you reproduced those failure modes in your mock, and have you written tests against each one of those different modes? Maybe it's the industry I'm in (payments), but in a world made of edge cases, effectively squaring the number of scenarios I have to test is not a pleasant experience.
/edit - forgot my footnote :)
[1] In practice, a user controller will hopefully tend to always operate on the currently-authenticated user, rather than passing around a userId by whatever means. However I've seen the latter done, and this exact kind of problem is what ensued.
> One day in the name of a better UX you add an AJAX component to
> this, and forget to copy across that check into the ajax endpoint.
> Suddenly I can change anyone's username, despite not being an admin.
Your AuthContext library sounds like a very unusual solution to this problem. Yes, there should be a single, unduplicated piece of code that restricts users' ability to edit each others' usernames. But why does that code need to know where the user is in the software? Surely admin users can always edit other users' names regardless of whether they're in the admin panel, and the question of whether or not to show the UI for editing usernames is a front-end concern. It sounds like an unusual and unnecessary tight coupling between the front-end and the back-end.
> Having an object that does a lot of stuff is not necessarily the
> much-feared "God object", although it certainly can become that.
> I've learned to become more fearful of the "God methods" instead
I'd argue that both are more or less equally harmful symptoms of the same problem: code that isn't clean. There are a couple of paragraphs about this in Clean Code that I like:
> At the same time, many developers fear that a large number of small,
> single-purpose classes makes it more difficult to understand the
> bigger picture. They are concerned that they must navigate from class
> to class in order to figure out how a larger piece of work gets
> accomplished.
> However, a system with many small classes has no more moving parts
> than a system with a few large classes. There is just as much to
> learn in the system with a few large classes. So the question is: Do
> you want your tools organized into toolboxes with many small drawers
> each containing well-defined and well--labeled components? Or do you
> want a few drawers that you just toss everything into?
I don't think I agree with the fact that every time you isolate a piece of logic you add a layer. But I don't think I have a solid argument for that yet :)
You do make a good point though that in having many small objects is easy to mess with external dependencies. But in my case, the testing always suggested if I had done something like that, and I improved. If an object becomes hard to test in isolation, then something has been done wrong.
Oh, I don't think isolating logic necessarily adds a layer, unless you go overboard with isolating it. I think we're probably caught up on the subtle differences between encapsulation and abstraction.
There seems to be a point where "I can't figure out what's happening here without an IDE" starts to happen, which is a) relatively subjective and b) sometimes a necessary side-effect. I only care when it's hard to test something in isolation when it's actually used in isolation. A private method, for example, exists only to be used by another component; it often makes little sense to test independently, and when it does it's easy to wrap in a public call that asserts that it's being run by a unit test.
But I think a lot of this is theoretical code purity vs prioritizing business goals. All else being equal I'd love a perfectly-factored codebase, but at the end of the day I have to target high-friction, high-confusion areas of the codebase to refactor because those are the ones that get in the way of building stuff for our customers, or cause the most production bugs.