The other day I saw the question on Reddit about Symfony's controller action dependency injection. More people around me are hyped about this new feature in Symfony 3.3 that allows to autowire services via action argument typehints. It's new, it's cool and no one has a bad experience with it. The ideal candidate for any code you write today.
Since Nette and Laravel introduced a similar feature in 2014, there are empirical data that we learn from.
Today I'll share the experience I have from consulting few Nette applications with dangerous overuse of this pattern and how this one thing turned the code to complete mess.
Disclaimer: this post is not about Symfony, nor critics of its feature. It's rather about teaching, thinking about knowledge embodied in the code, an aware approach of critical thinking to information from authorities.
What is wrong with this code?
class SomeService extends SomeAbstractParentService
{
public function someMethod(SomeArgument $someArgument, SomeOtherService $someOtherService)
{
return $someOtherService->process($someArgument);
}
}
It's not unreal that this code will appear in your project in next 2 years, if you start using action injection. But we'll get to that later, let's start from the beginning.
Since Symfony 3.3 there is a new feature that allows injecting services to controller actions. It's important to this post, that Symfony documentation includes a warning: "This is only possible in a controller, and your controller service must be tagged with controller.service_arguments
to make it happen."
Oh, sorry. In case you don't know what I'm talking about, here is a little example. If you do, skip right to the pitfall of such approach below.
If not, let's look at this example. This is the most simple and clear way to register controller as services:
<?php
# app/Controller/SomeController.php
namespace App\Controller;
use App\Model\SomeService;
final class SomeController
{
/**
* @var SomeService
*/
private $someService;
public function __construct(SomeService $someService)
{
$this->someService = $someService;
}
public function someAction()
{
$someData = $this->someService->getSomeData();
// ...
}
}
with basic PSR-4 autodiscovery registration:
# app/config/services.yml
services:
App\:
resource: '../'
# include all controllers and model services
The argument autowire (also called method injection or action injection) will save us some writing.
As the name suggests, dependencies won't be passed by a constructor, as it's common in every service, but via method - the action method!
# app/Controller/SomeController.php
namespace App\Controller
use App\Model\SomeService;
final class SomeController
{
- /**
- * @var SomeService
- */
- private $someService;
-
- public function __construct(SomeService $someService)
- {
- $this->someService = $someService;
- }
- public function someAction()
+ public function someAction(SomeService $someService)
{
- $someData = $this->someService->getSomeData();
+ $someData = $someService->getSomeData();
// ...
}
}
On the other hand, service registration is now 3x more complex:
# app/config/services.yml
services:
- App\:
+ App\Controller\:
- resource: '../'
+ resource: '../Controller'
+ tags: ['controller.service_arguments']
+
+ App\Model\:
+ resource: '../Model'
Paul M. Jones wrote that “Action Injection” As A Code Smell. Why?
"The fact that your controller has so many dependencies, used only in some cases and not in others, should be an indicator that the class is doing too much. Indeed, it’s doing so much that you cannot call its action methods directly; you have to use the dependency injection container not only to build the controller object but also to invoke its action methods."
And I agree. It's the same code smell as adding 10th action method to the ProductController
that now has 300 lines. Maybe you should split it into 2 classes and add sniff to make sure this won't happen in production code ever again (because no-one else will do it better than continuous integration).
But that's just words and ideas, no legacy (yet).
What might really happen with autowired arguments approach?
Nette "inject" feature released in 2014 in Nette 2.1 started very similarly. It has 2 ways to inject dependencies:
@inject
Annotatoinnamespace App\Controller;
use App\Model\SomeService;
final class SomeController
{
/**
* @var SomeService
* @inject
*/
public $someService;
}
inject*()
Methodnamespace App\Controller;
use App\Model\SomeService;
final class SomeController
{
/**
* @var SomeService
*/
private $someService;
public function injectSomeService(SomeService $someService)
{
$this->someService = $someService;
}
}
It also have to be activated in config manually with specific tags: ['inject']
tag, as in Symfony:
# app/config/services.neon
services:
App\Controller\SomeController:
tags: ['inject']
- App\Model\SomeService
Can you see the difference to Symfony? Well, almost none. But so far so good.
Note to Nette programmers: @inject
is often a code smell and you should do it cleaner
If you prepare some "dirty-hack-that-none-should-use" or even better "don't-ever-use-this-unless-you-know-why" and make it public, you can be sure people will ignore it and use it in a very creative way. Unless there is new ForbiddenUseException
thrown.
This effect appeared in Nette many months before 2.1 even became stable and method injection was born (many months before Nette 2.1 even became stable):
final class SomeController
{
public function someAction(SomeService $someService)
{
$someData = $someService->getSomeData();
// ...
}
}
So far so good, right?
Do you have children? If so, you know that "be careful with that fire" repeated 10 times in 60 seconds will mostly lead to the exact opposite. Human brain works on "Neurons that Fire Together Wire Together" principle - so the final version can sound like "fire".
Programmers use the feature you provided. They don't know what you wrote in that single post 2 years ago, nor explore documentation for any reference they found. Sorry jako.
Back to our story - it didn't take long to new idea appeared on Nette forum (Czech only): "I have 6 methods in SomeService
, why should I inject all dependencies every time one public method is called? I want to use inject there as well, it's shorter and faster" This is the same argument to use action injection in controllers, remember?
"Where is no exception, there is a way."
It was super easy to turn it on:
# app/config/services.neon
services:
App\Controller\SomeController:
tags: ['inject']
App\Model\SomeService:
+ tags: ['inject']
I confess I liked this idea too. But it's too much writing... how could we add to every service?
A Extension
(~= CompilerPass
) solved it:
foreach ($this->getContainerBuilder() as $definition) {
$definition->addTag('inject');
}
Now we can remove these annoying long constructors and use property/method injection everywhere. Be careful, this visual debt is different from cognitive overload.
Now our code looks like this:
class SomeService
{
/**
* @inject
*/
public $someOtherService;
}
or in Symfony
namespace App\Model;
final class SomeService
{
public function someMethod(SomeArgument $someArgument, SomeOtherService $someOtherService)
{
return $someOtherService->process($someArgument);
}
}
3 lines and documentation is ignored. Great job!
To achieve similar functionality in Symfony, you'd probably have to override this CompilerPass
. I won't show you nor try on purpose, so I don't spread too much black magic here.
Let's say you're right and I live in micro-open-source cosmos, where people are too lazy not to do it. Also, it's possible, that framework checks the service against interface (IPresenter
) or a class (Controller
), that it really is a controller.
But it's still possible. How? Take 3 breaths to think about it, you'll find a way.
Yes, our favorite composition pattern.
namespace App\Model;
final class SomeService extends SomeAbstractService
{
public function someMethod(SomeArgument $someArgument, SomeOtherService $someOtherService)
{
return $someOtherService->process($someArgument);
}
}
Do you know where this goes?
use Symfony\..\Controller;
abstract SomeAbstractService extends Controller
{
}
Kaboom! Method injection works in SomeService
and we bypassed the frameworks internals :).
I must add, I'm not writing this post because I'm bored and need a topic to babble about. This is true story, such code exists and it was so much WTF to me, that I don't want my deepest enemy to have to work on project like that. And I see that Symfony framework is slowly heading a way that opened these doors.
It was (maybe still is) famous Czech project (can't tell you which one, but you know who you are ;)).
Everything which is not forbidden is allowed.
Now you know how to take advantage of framework architecture backdoor and save you lot of writing. At least in the present moment. And also how not to fall into this. It's up to you, what you like and what code you love to write.
There are 2 ways ho to avoid this completely and still use your framework:
Paul M. Jones has written many posts about Action-Domain-Responder
I really recommend checking the Reddit thread, there are few experiences worth reading that will save your time and energy of personal research:
I've abandoned this [action inject] approach because it makes it harder to differentiate request parameters from services. It also makes this method definition in most cases spread to multiple lines. And it makes it harder to inject non-autowirable services
What inject approach do you prefer? What happy or WTF inject stories do you have? Let me know in the comments.
Happy Injecting!
Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors.
I'd really appreciate it!