Cartoon avatar of Charles Sprayberry
cspray.io

Autowiring and Annotated Container

Autowiring dependency injection containers is a feature I've been a big fan of for some time. If you aren't familiar with the term, it roughly means figuring out what concrete implementation should be used for an abstract type when a method is invoked. There's examples of this throughout the Annotated Container documentation and the README.

Autowiring is implicit and at times can feel "magical". I'm not generally a big fan of implicit or magical in my software and I designed autowiring in Annotated Container to expose as much of the process as possible. In the rest of this post I'll show:

  • How Annotated Container makes the decision on which concrete implementation to use for an abstract service.
  • Different ways you can influence, or directly control, that decision-making process. I'll show real use cases for how those different methods can be beneficial.
  • Thoughts on how autowiring in Annotated Container can be improved in the future.

Brief Annotated Container Overview

First, we all need to have a basic understanding of how Annotated Container works. When you bootstrap Annotated Container, various source code directories are scanned and all PHP files are statically analyzed. The result of this analysis is an object called a ContainerDefinition. This object defines how your container will be created. It includes various definition objects, for what we're looking into today the important pieces are:

  • The abstract and concrete services in your codebase and any scanned vendor packages. These are defined by the ServiceDefinition type.
  • Concrete services that could act as an implementation for an abstract service. These are defined by the AliasDefinition type.
  • Factory classes or methods that should be used for constructing a service. These are defined by the ServiceDelegateDefinition type.

With this information when your container is created we can perform logic to determine what concrete service should be used. This is handled by an implementation of AliasDefinitionResolver. Ultimately, this implementation is how autowiring is decided. If you don't like how Annotated Container determines autowiring you can always implement your own!

Now that we're all on a more-even playing field let's take a look at different ways autowiring is handled!

Single Concrete Service

This is a fairly common scenario, where there's only one implementation of an interface. Perhaps it is an implementation for a third-party library, maybe the interface was added for better testability, or perhaps it is expected that additional implementations will come in the future. Regardless of the reason, the following code is properly autowired by Annotated Container:

<?php declare(strict_types=1);

use Cspray\AnnotatedContainer\Attribute\Service;

#[Service]
interface BlobStorage {

    public function store(string $identifier, string $contents) : void;
    
    public function retrieve(string $identifier) : ?string;

}

#[Service]
final class FilesystemStorage implements BlobStorage {
    
    public function store(string $identifier, string $contents) : void {
        file_put_contents($identifier, $contents);
    }
    
    public function retrieve(string $identifier) : ?string {
        return file_get_contents($identifier) ?? null;
    }

}

During alias resolution we'll see that there's only 1 possible concrete implementation. Our AliasDefinitionResolution will include AliasResolutionReason::SingleConcreteService and the definition for FilesystemStorage

Multiple Concrete Services with Profiles

Once you start adding multiple concrete implementations you'll need to decide how to resolve them. A solution I've found helpful is the concept of "profiles". All services in Annotated Container have at least one profile; either explicitly when you set the attribute or implicitly with the default profile. When your container is created you define active profiles, only services with that profile will be considered.

Profiles can be a very powerful feature of Annotated Container. Common situations I've discovered include:

  • Replacing services that should be used based on what machine is running the app. For example, an app hosted on a physical server saves files to local disk and a cloud-native app saves to some cloud storage.
  • Providing properly-scoped containers for monorepo apps. For example, you might have a CLI tool, web app, and worker app in the same repo. Using profiles you can ensure that services meant to only be used in a CLI context aren't available to the web or worker apps.
  • Integration testing where you want to replace a service with a known, testable implementation. For example, you might have a Clock service that you want to replace with a frozen implementation.

In our example below we'll take a look at the first scenario; we need our example from above to allow storing files in S3 instead of local disk to support moving our app from a physical server to cloud-native.

<?php declare(strict_types=1);

use Cspray\AnnotatedContainer\Attribute\Service;

#[Service]
interface BlobStorage {

    public function store(string $identifier, string $contents) : void;
    
    public function retrieve(string $identifier) : ?string;

}

#[Service(profiles: ['local'])]
final class FilesystemStorage implements BlobStorage {
    
    public function store(string $identifier, string $contents) : void {
        file_put_contents($identifier, $contents);
    }
    
    public function retrieve(string $identifier) : ?string {
        return file_get_contents($identifier) ?? null;
    }

}

#[Service(profiles: ['cloud'])]
final class CloudStorage implements BlobStorage {

    public function store(string $identifier, string $contents) : void {
        // implement this method to store $contents on your cloud provider of choice
    }
    
    public function retrieve(string $identifier) : ?string {
        // implement this method to retrieve $contents from your cloud provider of choice
        return null;
    }

}

During alias resolution only 1 concrete service will be available, based on what profiles are active. For example, if the active profiles are default,cloud then CloudStorage will be used as the concrete implementation. If the active profiles don't include either local or cloud no valid concrete service will be found and an exception will be thrown informing you to provide a concrete implementation.

Marking a Service as Primary

There's situations where adding a new profile isn't the right solution. At times, I've found that marking a service as the primary implementation has been useful. Though I've only found 1 good scenario so far, it is commonly encountered:

  • Overriding a default implementation or one provided by a third-party library.

In our example, we're going to take a look at the ubiquitous database configuration. We'll have an interface defining the parameters we need, a default implementation for standard values, and an override implementation. Imagine the interface and default implementation are provided by a library we install through Composer. As an author of that library, I can define a type-safe config and a reasonable default value that will work in my demo setup and initial dev environments. Later on as the app matures and needs a real configuration the app developer can do so by marking their own implementation as primary.

<?php declare(strict_types=1);

use Cspray\AnnotatedContainer\Attribute\Service;

#[Service]
interface DatabaseConfiguration {

    public function getHost() : string;
    
    public function getPort() : int;
    
    public function getDatabase() : string;
    
    public function getUser() : string;
    
    public function getPassword() : string;

}

#[Service]
final class DefaultPostgresDatabaseConfiguration implements DatabaseConfiguration {

    public function getHost() : string {
        return 'localhost';
    }
    
    public function getPort() : int {
        return 5432;
    }
    
    public function getDatabase() : string {
        return 'postgres';
    }
    
    public function getUser() : string {
        return 'user';
    }
    
    public function getPassword() : string;

}
<?php declare(strict_types=1);

use Cspray\AnnotatedContainer\Attribute\Inject;
use Cspray\AnnotatedContainer\Attribute\Service;

#[Service(primary: true)]
final readonly class AppDatabaseConfiguration implements DatabaseConfiguration {

    // We can't have an example that exposes your production password!
    // Check out the docs for more information about the #[Inject] Attribute
    public function __construct(
        #[Inject('DATABASE_HOST', from: 'env')]
        private string $host,
        
        #[Inject('DATABASE_PORT', from: 'env')]
        private string $port,
        
        #[Inject('DATABASE_NAME', from: 'env')]
        private string $database, 
        
        #[Inject('DATABASE_USER', from: 'env')]
        private string $user,
        
        #[Inject('DATABASE_PASSWORD', from: 'env')]
        private string $password
    ) {}
    
    public function getHost() : string {
        return $this->host;
    }
    
    public function getPort() : int {
        return (int) $this->port;
    }
    
    public function getDatabase() : string {
        return $this->database;
    }
    
    public function getUser() : string {
        return $this->user;
    }
    
    public function getPassword() : string {
        return $this->password;
    }

}

During alias resolution multiple concrete services will be found. Only 1 is marked as primary so it will be used. The AliasResolutionReason will be ConcreteServiceIsPrimary and AppDatabaseConfiguration will be used.

You may notice that we introduced a new attribute, #[Inject], in this example. A real configuration shouldn't hardcode sensitive information that could be committed to a repo, even in an example! Digging into this functionality is out of scope for this article, if you'd like to learn more checkout the docs for injecting scalar values.

Service Factories

Sometimes it isn't possible for Annotated Container to determine the precise implementation to use. Or, maybe you don't wanna rely on autowiring and need complete control over the creation of your services. Either way, no worries! In Annotated Container we refer to this as a service delegate; the container is delegating construction of the service to some factory. The #[ServiceDelegate] Attribute can be put onto a class method, that method will be invoked to create the service. If an abstract service is delegated all autowiring is skipped and the factory is always used.

You can define this Attribute on a static method or an instance method. If a static method is attributed it is called directly. If an instance method is used as the factory, the container will create an instance of the method's class and invoke the method on that instance. Either way, you can declare other services the factory might need and they'll get passed in.

In our example, we're going to decorate a FilesytemStorage with a logging decorator.

<?php declare(strict_types=1);

use Cspray\AnnotatedContainer\Attribute\Service;
use Psr\Log\LoggerInterface;

final class BlobStorageFactory {

    private function __construct() {}

    // We know you're creating a service of type BlobStorage based on the return type
    // If your method doesn't specify a return type you can explicitly pass the type to ServiceDelegate attribute
    #[ServiceDelegate]
    public static function createStorage(LoggerInterface $logger) : BlobStorage {
        // Assuming that your Container is configured to have a Logger service. 
        // How to do this is covered in docs! 
        $storage = new FilesystemStorage();
        return new class($logger, $storage) implements BlobStorage {
        
            public function __construct(
                private readonly LoggerInterface $logger,
                private readonly BlobStorage $storage
            ) {}
        
            public function store(string $identifier, string $contents) : void {
                $this->logger->info('Storing info in ' . $identifier);
                $this->storage->storage($identifier, $contents);     
            }
            
            public function retrieve(string $identifier) : ?string {
                $this->logger->info('Retrieving info for '. $identifier);
                return $this->storage->retrieve($identifier); 
            }
        };
    }

}

During alias resolution Annotated Container will see that you've defined a delegate for the BlobStorage and the AliasResolutionReason will be ServiceIsDelegated. All autowiring is skipped in this scenario and your factory will always be used. You can depend on other services in your container and they'll be injected. Defining a third-party service is out of scope for this article, but it is covered in Annotated Container docs!

Autowiring Many Implementations

So far our examples have showed when you needed to work with a single service. However, there are plenty of situations where there will always be multiple implementations. Annotated Container provides a way to accomplish this directly out-of-the-box with an observer mechanism when your container is created. I've found this scenario so common that it has been used in many of the projects I've been using to dogfood Annotated Container.

In our example, I'm going to show how this website uses this functionality. Checkout the home page, on the right side column (or at the bottom on mobile), there's a list of personal projects I've open sourced. This list is maintained by implementing an interface multiple times.

<?php declare(strict_types=1);

use Cspray\AnnotatedContainer\Attribute\Service;
use League\Uri\Http;
use Psr\Http\Message\UriInterface;

interface Project {

    public function getName() : string;

    public function getDescription() : string;

    public function getRepoUrl() : UriInterface;

    public function getSortOrder() : int;

    public function getAssociatedTag() : ?string;

}

#[Service]
final class AnnotatedContainerProject implements Project {

    public function getName() : string {
        return 'cspray/annotated-container';
    }

    public function getDescription() : string {
        return <<<TEXT
Annotated Container is a PHP 8 dependency injection framework, configured with Attributes, for creating an autowired, 
feature-rich, PSR-11 compliant container.
TEXT;
    }

    public function getRepoUrl() : UriInterface {
        return Http::createFromString('https://github.com/cspray/annotated-container');
    }

    public function getSortOrder() : int {
        return ProjectListSortOrder::AnnotatedContainer->value;
    }

    public function getAssociatedTag() : ?string {
        return 'annotated-container';
    }
}

// More project implementations are provided... one for each project listed!

Now, we need to hook into the container creation process and add the appropriate Project implementations to a collection object that can be used in the site generator's templating. This scenario was planned for ahead of time and Annotated Container provides an abstract ContainerCreated observer to facilitate this process.

<?php declare(strict_types=1);


use Cspray\AnnotatedContainer\AnnotatedContainer;
use Cspray\AnnotatedContainer\Bootstrap\ServiceWiringObserver;
use Cspray\AnnotatedContainer\Bootstrap\ServiceGatherer;

final class ProjectWiringObservere extends ServiceWiringObserver {

    protected function wireServices(AnnotatedContainer $container, ServiceGatherer $gatherer) : void {
        $collection = $container->get(ProjectCollection::class); // this is provided by the site code to store Project instances
        foreach ($gatherer->getServicesForType(Project::class) as $projectServiceAndDefinition) {
            // You can also get access to the ServiceDefinition to get more information, including the Attribute
            // used to configure the service!
            $collection->addProject($projectServiceAndDefinition->getService());
        }
    }

}

All of this happens as part of the container bootstrapping, before your app's code is ever executed. Our ProjectCollection object has implementations added when the home page is finally written to disk. I can add new projects by implementing the Project interface and adding a #[Service] attribute. I can remove it from the list by removing the attribute. If I need to make changes to the UI that requires more data all of the projects listed HAVE to provide that data.

This example also demonstrates the only place I believe you should be depending on a container interface. By restricting your Container dependency to only ContainerCreated observers you gain the following:

  • Autowiring or service setup happens in container bootstrapping, before your app code should ever run.
  • Container interactions by your app are confined to a known place, easily searchable by finding implementations of the appropriate interface.
  • Less likely for your container to turn into a service locator within your app.

Exposing the Magic

Everything I've discussed in this post has been designed to expose the magic behind autowiring in Annotated Container. When you use Annotated Container I don't want the decisions made on autowiring to be magical. I want them to be because of your own deliberate, knowledgeable decision making. I want you to be able to completely opt out of autowiring or implement your own logic. I feel what Annotated Container provides so far goes a long way toward achieving those efforts. In addition, if you provide a logger to the bootstrapping process a wealth of information is provided, including every decision made regarding autowiring.

However, I don't think that's enough.

I believe the next step in exposing the magic behind autowiring is better tooling. One of the projects I'm looking forward to is implementing a PHPStorm plugin that will provide the following features, among others:

  • List all the services in your container
  • Show what concrete services are autowired for a given abstract service and the reasoning why
  • When a factory is used to create a service

Annotated Container already provides some of the foundation for this. When your ContainerDefinition is cached it is converted into an XML document with a known schema. While only caching uses this XML document right now, I think it is one of the most important things to come out of Annotated Container. With it I expect Annotated Container could provide:

  • Static analysis to find errors in your container configuration
  • IDE tooling to show this information in your favorite editor
  • A web UI to provide a feature-rich, visually-pleasing view into your container

Autowiring can be a controversial subject. The implicitness and "magic" involved with this type of functionality has a lot of valid arguments against it. In this article I hope I've unveiled some of that magic and shown that autowiring can be a valuable tool in your toolbelt and not just framework magic.