I like the architecture you've described. I think we arrived at where did because it's a natural for when using a DI container, which manages state and transaction groups for you.
This is nice because it prevents leaky abstractions into the controller layer. Actions across services that need to be atomic can be grouped in an XA transaction:
// Xa start databaseService.reset password(user); emailService.notifyPasswordChange(user); // Xa commit
Last bad thing I saw was - quite recently actually - some business code in a request handler mutating state on a controller field (which are singletons.) Lots of fun once we started load testing with concurrent sessions...
In most apps I've seen "services" like Redis/Rabbit/Memcache/Postgres/External APIs/Storage are cross-cutting concerns and you make your life a nightmare by having to pass
myfunc(actual, params, db, redis, memcache, rabbit,...)
because if you realize deep in your call stack you actually need data from Postgres now you have change all the callers recursively to pass the handle down. If you have this global "service catalog" it's almost always to eliminate the need for passing the same effectively global connection pools to hundreds of call-sites.It is annoying that it makes every test require a bunch of ambient service mocks but you only really have to set it up once.
I think just about everyone would scoff at the idea that every single method in your codebase should take an explicit `logger` parameter rather than just having a global logger object.
So now we're looking as a case where you have a bunch of services: dbs, caches, apis, storage that are used all over the app ("in every file") and you have to make a judgement call.
* Have hundreds (honestly thousands at current $dayjob) of methods that do a lot of work just to pass around the same connection handle.
* Declare a single object that manages all the connection pools.
I think it's really hard to escape the fact that connection pools are actually global and you either have to admit this or have your runtime hide it from you.
Another example of a cross-cutting concern that runtimes usually hide is event loops. Can you imagine if every function that wanted to use async had to be passed an event_loop variable?
We should strive generally to have tests that only change if the business requirements change. But if I want to refactor my unit (whatever that might be) then the test should not change, or at least should not change __much__