Introducing Annotated Container - Part 1

June 1st, 2022

Over the last year I've been working off and on with a new project that I'm really excited about. It's a dependency injection framework to create a feature rich, autowire-able, PSR-11 Container using PHP 8 Attributes. As the title suggests, I call it Annotated Container. This is the first in a three-part series introducing the framework. This article discusses some motivations behind the project and basic functionality. Part 2 talks about some of the more advanced, noteworthy features. Part 3 talks about some things I have learned while working on the project and its future.

Motivations

Beyond learning new things, developing this project has some discrete motivations; I talk about some important pieces below. Perhaps the problems I'm hoping to address can give you some ideas on how this project might benefit your own codebase.

Simplifying Container Configuration

Wiring up or configuring a Container can be, if not challenging, tedious. Existing libraries have done a pretty good job of providing APIs for creating feature-rich Containers. However, I believe that configuring them could be less tedious, perhaps even intuitive. Annotating code constructs can be a good way to accomplish this, in my opinion.

Bring Configuration Closer to the Code

Configuration doesn't always have to be stored physically close to the code that uses it; in most cases there's good reason not to store it in the code. But, in the case of configuring a Container the "thing" you're configuring is the code. Need to figure out how a particularly complicated Service is determining the values it injects? No more digging through configuration files. No more staring at YAML. Look at the code!

Reducing Vendor Lock-in

In theory there's a lot of interop when using a Container, thanks to PSR-11. However, in practice it can be very difficult to change Container implementations. Even if there's feature parity between Container packages they often have drastically different ways of doing things. The cost of that wiring often means you're not gonna change your Container library once the app grows to a certain size. Annotated Container aims to provide a solution for this problem.

Java and Spring Boot

Professionally I've been doing a lot of work with a Java Spring Boot application. Spring offers a very powerful dependency injection container using Java annotations. I wondered to myself, with Attributes could PHP 8 support such functionality? For the most part, it can!

Quick Start

Enough chattering, let's look at some code! This example demonstrates what I consider the "bread & butter" of Annotated Container. It's not meant to be production-ready, but is meant to resemble a task you might see in real life. We're going to create an interface and implementation for storing some arbitrary data against a key.

<?php declare(strict_types=1);

namespace Acme\AnnotatedContainerDemo;

use Cspray\AnnotatedContainer\Attribute\Service;

#[Service]
interface BlobStorage {

    public function store(string $key, string $contents) : void;

    public function retrieve(string $key) : ?string;

}

#[Service]
class FilesystemBlobStorage implements BlobStorage {

    public function store(string $key, string $contents) : void {
        file_put_contents($key, $contents);
    }

    public function retrieve(string $key) : ?string {
        return @file_get_contents($key) ?? null;
    }

}

#[Service]
class BlobStorageConsumer {

    public function __construct(public readonly BlobStorage $storage) {}

    // Some API methods that would interact with the BlobStorage instance

}

In this example there's an interface, BlobStorage, that we refer to as an abstract service. There's an implementation, FilesystemBlobStorage, that we refer to as a concrete service and is an alias for BlobStorage. Where we've type-hinted BlobStorage in the BlobStorageConsumer the concrete service will be injected. We've identified these services to the framework by annotating them with the #[Service] Attribute.

Now, we have to statically analyze the source code and create the Container! In your bootstrap, or someplace that might be appropriate for your architecture, you'd include something that resembles the following.

<?php declare(strict_types=1);

namespace Acme\AnnotatedContainerDemo;

use Cspray\AnnotatedContainer\ContainerDefinitionCompileOptionsBuilder as CompileOptionsBuilder;
use function Cspray\AnnotatedContainer\compiler;
use function Cspray\AnnotatedContainer\containerFactory;

// The compiler() will statically analyze the given directories for annotated code
// $containerDef is a ContainerDefinition that details how to make your Container
$containerDef = compiler()->compile(
    CompileOptionsBuilder::scanDirectories(__DIR__ . '/src')->build()
);

// Will create PSR-11 Container based off of analyzed source code
$container = containerFactory()->createContainer($containerDef);

// At this point you're ready to use the $container however appropriate for your app!
// The code below demonstrates some important concepts when using Annotated Container

$storage = $container->get(BlobStorage::class);
$storage instanceof FilesystemBlobStorage;                          // true
$storage === $container->get(BlobStorage::class);                   // true
$storage === $container->get(FilesystemBlobStorage::class);         // true
$storage === $container->get(BlobStorageConsumer::class)->storage;  // true

While you'd want to replace the calls to $container with something more useful, this example demonstrates a couple important concepts. First, we're able to get an abstract service. The service returned is the concrete alias that was discovered. Since there's just one alias for the abstract service we know which one to use. Second, the concrete implementation that is created is shared. This means that everytime you get a shared service the same instance is returned. Third, we can inject a concrete service into the constructor of other services. Constructor dependencies are recursively resolved; services can depend on other services and, if the Container is aware of it, will be automatically injected.

Choosing the Backing Container

An important aspect of Annotated Container is that the library doesn't provide any Container implementation itself. There are plenty of feature-rich, capable packages out there, and we aren't aiming to compete with them. If anything, we're looking to take advantage of the functionality they offer. You can think of AnnotatedContainer as a consistent way to define the configuration for a Container!

In theory, any Container implementation that supports autowire functionality should be able to be used with this package. There's a standardized test case for ContainerFactory implementations to make sure they're all capable of creating Containers with the described functionality. Out-of-the-box Annotated Container comes with support for the following implementations:

When using Annotated Container you should install one of these backing libraries. If you don't, when you invoke containerFactory() an exception will be thrown prompting you to take a look at composer suggest to choose a backing library. A future blog post, and in-repo documentation, will be created for adding new libraries to the list of supported Container implementations.

Noteworthy Features - Part 2

This is the tip of the iceberg when it comes to Annotated Container. If you're familiar with dependency injection there's a lot of potential features you might have questions about. What happens if there's more than 1 service? What do you do if you wanna construct the service with a factory? How do you handle depending on values that aren't objects? Annotated Container gives you ways to deal with all of these problems and more! There's enough describing these features to warrant its own article. Part 2 is nearly done, and I hope to have it published soon. In the meantime, all the features discussed in Part 2 are already documented in the repo's /docs folder!

Project Resources