Ray.Di Mental Model

Learn about Key, Provider and how Ray.Di is just a map

When you are reading about “Dependency Injection”, you often see many buzzwords (“Inversion of control”, “Hollywood principle”) that make it sound confusing. But underneath the jargon of dependency injection, the concepts aren’t very complicated. In fact, you might have written something very similar already! This page walks through a simplified model of Ray.Di implementation, which should make it easier to think about how it works.

Ray.Di is a map

Fundamentally, Ray.Di helps you create and retrieve objects for your application to use. These objects that your application needs are called dependencies.

You can think of Ray.Di as being a map[^Ray.Di-map]. Your application code declares the dependencies it needs, and Ray.Di fetches them for you from its map. Each entry in the “Ray.Di map” has two parts:

  • Ray.Di key: a key in the map which is used to fetch a particular value from the map.
  • Provider: a value in the map which is used to create objects for your application.

Ray.Di keys and Providers are explained below.

map is a reasonable approximation for how Ray.Di behaves.

Ray.Di keys

Ray.Di uses Key to identify a dependency that can be resolved using the “Ray.Di map”.

The Greeter class used in the Getting Started declares two dependencies in its constructor and those dependencies are represented as Key in Ray.Di:

  • #[Message] string –> $map[$messageKey]
  • #[Count] int –> $map[$countKey]

The simplest form of a Key represents a type in php:

// Identifies a dependency that is an instance of string.
/** @var string $databaseKey */
$databaseKey = $map[$key];

However, applications often have dependencies that are of the same type:

class Message
{
    public function __construct(
    	  public readonly string $text
    ){}
}

class MultilingualGreeter
{
    public function __construct(
      private readonly Message $englishGreeting,
      private readonly Message $spanishGreeting
    ) {}
}

Ray.Di uses binding attributes to distinguish dependencies that are of the same type, that is to make the type more specific:

class MultilingualGreeter
{
    public function __construct(
      #[English] private readonly Message $englishGreeting,
      #[Spanish] private readonly Message $spanishGreeting
    ) {}
}

Key with binding attribute can be created as:

$englishGreetingKey = $map[Message::class . English::class];
$spanishGreetingKey = $map[Message::class . Spanish::class];

When an application calls $injector->getInstance(MultilingualGreeter::class) to create an instance of MultilingualGreeter. This is the equivalent of doing:

// Ray.Di internally does this for you so you don't have to wire up those
// dependencies manually.
$english = $injector->getInstance(Message::class, English::class));
$spanish = $injector->getInstance(Message::class, Spanish::class));
$greeter = new MultilingualGreeter($english, $spanish);

To summarize: Ray.Di Key is a type combined with an optional binding attribute used to identify dependencies.

Ray.Di Providers

Ray.Di uses Provider to represent factories in the “Ray.Di map” that are capable of creating objects to satisfy dependencies.

Provider is an interface with a single method:

interface Provider
{
  /** Provides an instance/
  public function get();
}

Each class that implements Provider is a bit of code that knows how to give you an instance of T. It could call new T(), it could construct T in some other way, or it could return you a precomputed instance from a cache.

Most applications do not implement Provider interface directly, they use Module to configure Ray.Di injector and Ray.Di injector internally creates Providers for all the object it knows how to create.

For example, the following Ray.Di module creates two Providers:

class countProvicer implements ProviderInterface
{
    public function get(): int
    {
        return 3;
    }
}

class messageProvider implements ProviderInterface
{
    public function get(): Message
    {
        return new Message('hello world');
    }
}

class DemoModule extends AbstractModule
{
   protected function configure(): void
   {
       $this->bind()->annotatedWith(Count::class)->toProvider(CountProvicer::class);
       $this->bind()->annotatedWith(Message::class)->toProvider(MessageProvicer::class);
   }
}
  • MessageProvicer that calls the get() method and returns “hello world”
  • CountProvicer that calls the get() method and returns 3

Using Ray.Di

There are two parts to using Ray.Di:

  1. Configuration: your application adds things into the “Ray.Di map”.
  2. Injection: your application asks Ray.Di to create and retrieve objects from the map.

Configuration and injection are explained below.

Configuration

Ray.Di maps are configured using Ray.Di modules. A Ray.Di module is a unit of configuration logic that adds things into the Ray.Di map. There are two ways to do this:

  • Using the Ray.Di Domain Specific Language (DSL).

Conceptually, these APIs simply provide ways to manipulate the Ray.Di map. The manipulations they do are pretty straightforward. Here are some example translations, shown using PHP syntax for brevity and clarity:

Ray.Di DSL syntax Mental model
bind($key)->toInstance($value) $map[$key] = $value;
(instance binding)
bind($key)->toProvider($provider) $map[$key] = fn => $value;
(provider binding)
bind(key)->to(anotherKey) $map[$key] = $map[$anotherKey];
(linked binding)

DemoModule adds two entries into the Ray.Di map:

  • #[Message] string –> fn() => (new MessageProvicer)->get()
  • #[Count] int –> fn() => (new CountProvicer)->get()

Injection

You don’t pull things out of a map, you declare that you need them. This is the essence of dependency injection. If you need something, you don’t go out and get it from somewhere, or even ask a class to return you something. Instead, you simply declare that you can’t do your work without it, and rely on Ray.Di to give you what you need.

This model is backwards from how most people think about code: it’s a more declarative model rather than an imperative one. This is why dependency injection is often described as a kind of inversion of control (IoC).

Some ways of declaring that you need something:

  1. An argument to a constructor:

     class Foo
     {
       // We need a database, from somewhere
       public function __construct(
             private Database $database
        ) {}
     }
    
  2. An argument to a DatabaseProvider::get() method:

     class DatabaseProvider implements ProviderInterface
     {
         public function __construct(
             #[Dsn] private string $dsn
         ){}
          
         public function get(): Database
         {
             return new Database($this->dsn);
         }
     }
    

This example is intentionally the same as the example Foo class from Getting Started Guide. Unlike Guice, Ray.Di does not require the Inject attribute to be added to the constructor.

Dependencies form a graph

When injecting a thing that has dependencies of its own, Ray.Di recursively injects the dependencies. You can imagine that in order to inject an instance of Foo as shown above, Ray.Di creates Provider implementations that look like these:

class FooProvider implements Provider
{
    public function get(): Foo
    {
        global $map;
        
        $databaseProvider = $map[Database::class]);
        $database = $databaseProvider->get();
        
        return new Foo($database);
    }
}

class DatabaseProvider implements Provider
{
    public function get(): Database
    {
        global $map;
        
        $dsnProvider = $map[Dsn::class];
        $dsn = $dsnProvider->get();
        
        return new Database($dsn);
    }
}  

class DsnProvider implements Provider
{
    public function get(): string
    {
        return getenv(DB_DSN);
    }
}  

Dependencies form a directed graph, and injection works by doing a depth-first traversal of the graph from the object you want up through all its dependencies.

A Ray.Di Injector object represents the entire dependency graph. To create an Injector, Ray.Di needs to validate that the entire graph works. There can’t be any “dangling” nodes where a dependency is needed but not provided.1 If the bound is incomplete somewhere in the graph, Ray.Di will throw an Unbound exception.

nothing ever uses it—it’s just dead code in that case. That said, just like any dead code, it’s best to delete providers if nobody uses them anymore.

What’s next?

Learn how to use Scopes to manage the lifecycle of objects created by Ray.Di and the many different ways to add entries into the Ray.Di map.

  1. The reverse case is not an error: it’s fine to provide something even if