Dependency Injection

How to register and resolve services

The Beamable SDK uses a dependency injection approach to organize code layers and services. Every BeamContext is assigned an isolated dependency scope. A dependency scope is a logical group of services. If there are multiple BeamContext instances, then each has a unique dependency scope, and there are no inter-dependencies between the two contexts.

Beamable dependency injection is broken up into two main phases, registration, and resolution. The registration phase happens once as the BeamContext is initializing, and the resolution phase happens continously as the game plays.

Service Registration

All services that will exist must be explicitly registered before the service scope may be used to create any service instances. The IDependencyBuilder interface is the root type used for service registration in the Beamable SDK.

A service registration has 4 key elements,

  1. The public type of the service (often, an interface type)
  2. The private type of the service (often, an implementation class of the interface). The private type must be assignable to the public type.
  3. A service type of Singleton, Scoped, or Transient
  4. A factory function that can map an instance of IDependencyProvider to an instance of the registration's private type.

The IDependencyBuilder has methods, AddSingleton, AddScoped, and AddTransientthat add service registrations. There are overloads of these methods that have various levels of syntactic sugar for providing the 4 key elements of a service registration. The following code snippet is the most verbose way to register a singleton service.

// the public type is 'TInterface'
// the private type is 'TImpl'
// the service type is a Singleton
// the factory function is provider => new TImpl()
builder.AddSingleton<TInterface, TImpl>( provider => new TImpl() );

Beamable's service registration happens in the Beamable.Beam.cs class. You can modify the service registration records before any BeamContext is created by using custom services.

Factory Function

The factory function is often unnecessary to provide. The dependency injection system can automatically create factory functions for services that either,

  1. have no constructor or have parameterless constructors, or
  2. have constructor parameter types that are all registered in the IDependencyBuilderas public types.

For example, the previous example can be written without specifying the factory function, like so,

builder.AddSingleton<TInterface, TImpl>();

The system can also create factory functions for services that have constructors that only have parameter types represented as public types in the IDependencyBuilder. For example, consider the following class, TunaService...

public class TunaService : ITunaService {
  private readonly TInterface _dep;
  
  // the constructor requires a `TInterface` instance
  public TunaService(TInterface dep) {
    _dep = dep;
  }
}

The constructor of TunaService requires a TInterface, which is already a registered service. TInterface and TunaService can be registered like so,

builder.AddSingleton<TInterface, TImpl>();
builder.AddSingleton<ITunaService, TunaService>();

In practice, it is uncommon that the factory function must be specified. However, there are absolutely cases where it is required or preferred. For example, imagine a third class to add to the service scope, FishSticks, that requires a simple bool configuration,

public class FishSticks : IFishSticks {
  private readonly bool _frozen;
  private readonly ITunaService _tuna;
  public FishSticks(ITunaService tuna, bool frozen) {
    _frozen = frozen;
    _tuna = tuna;
  ]
}

The bool type is not registered in the IDependencyBuilder, and cannot be, because it is a primitive type. In this scenario, the factory function must be specified. The ITunaService parameter can be resolved using the factory function's IDependencyProvider argument,

builder.AddSingleton<TInterface, TImpl>();
builder.AddSingleton<ITunaService, TunaService>();
builder.AddSingleton<IFishSticks, FishSticks>(provider => 
        new FishSticks(provider.GetService<ITunaService>(), true));

A second common reason to specify a factory function is to register a single instance as the implementation service for multiple interface types. For example, imagine we have a class TunaFishSticks that implements both ITunaService, and IFishSticks,

public class TunaFishSticks : ITunaService, IFishSticks {
  public TunaFishSticks() { }
}

If the registrations were written as below, then there would be 2 instances of TunaFishSticks, the instance that backs the ITunaService interface, and the instance that backs the IFishSticks service.

builder.AddSingleton<TInterface, TImpl>();
builder.AddSingleton<ITunaService, TunaFishSticks>();
builder.AddSingleton<IFishSticks, TunaFishSticks>();

However, the following configuration will allow a single instance of TunaFishSticks to back both types.

builder.AddSingleton<TInterface, TImpl>();
builder.AddSingleton<ITunaService, TunaFishSticks>(p => p.GetService<TunaFishSticks>());
builder.AddSingleton<IFishSticks, TunaFishSticks>(p => p.GetService<TunaFishSticks>());
builder.AddSingleton<TunaFishSticks, TunaFishSticks>();

Inferred Generics

The service registration must have a public interface type, and a private implementation type that is assignable to the public interface type. However, one or the other is often inferable given the context.

If the interface type and the implementation type are the same, then it only needs to be specified once. For example, imagine the class, WidgetService

public class WidgetService 
{ 
}

The type has no interface, and is going to be referenced directly as WidgetSource.

builder.AddSingleton<WidgetService>();

Service Resolution

The IDependencyBuilder can be built into a IDependencyProvider instance, which represents the actual service scope.

IDependencyProvider provider = builder.Build();

The IDependencyProvider is given all the service registrations from the IDependencyBuilder. Instead of allowing more services to. be registered, the IDependencyProvider allows services to be instantiated. For example, imagine that a type ITunaServicewas registered, then an instance could be acquired like this,

var tunaService = provider.GetService<ITunaService>();

The GetService<T>() function does not allow for any configuration to be given. The exact mechanism of the instantiation is hidden and cannot be altered. If there is configuration that needs to be given, it should have been configured in the registration phase.

Services are created lazily. If a service is registered in the dependency scope, but no code ever requires an instance of the service, then the service is never instantiated. Depending on the type of service, the GetService<T>() invocation will either create an instance, or use a previously cached instance of the requested service.

The IDependencyProvider has two other main functions, CanBuildService<T>, and Fork. The CanBuildService<T> function will return true of false depending on if the given scope is able to construct the requested type. The Fork function will fork the current scope and create a child scope.

In the example above, tunaService is actually an IDependencyProviderScope, which is the type that implements IDependencyProvider. The IDependencyProviderScope provides access to the service registration records, and parent/child scope information. Usually, the full control of IDependencyProviderScope is not required.

The Beamable SDK creates a IDependencyProviderScope for every BeamContext instance. The IDependencyProvider is available from the ServiceProvider property on the BeamContext. The code below shows how to access the ITunaService from a BeamContext.

var tunaService = BeamContext.Default.ServiceProvider.GetService<ITunaService>();

You can add services or modify the existing Beamable services by using .Custom Services.

Circular Dependencies

It is possible to create circular dependency chains that will cause stack overflow exceptions. Imagine two classes, Tom, and Jerry. They depend on each other to exist.

public class Tom {
 Jerry purpose;
 public Tom(Jerry mouse) {
  purpose = mouse;
 }
}

public class Jerry {
  Tom purpose;
  public Jerry(Tom cat) {
   purpose = cat;
  }
}

If the services are registered naively, as such,

builder.AddSingleton<Tom>();
builder.AddSingleton<Jerry>();
var provider = builder.Build();

And then either Tom or Jerry were attempted to be instantiated,

var tom = provider.GetService<Tom>();

Then a stack overflow exception will occur. The exception happens because when the dependency scope resolves Tom, it will identify that the it needs an instance of Jerry, and it will use the service scope to resolve the instance of Jerry. However, when the instance of Jerry is resolving, it will see that it needs an instance of Tom. No instance of Tom has finished being created yet, and therefor, it will attempt to spawn a new one. The cycle will repeat until the computer hits the stack limit.

Generally speaking, designing circular dependencies should be avoided. However, sometimes they are either unavoidable, or the cost of avoiding them is too great. In those scenarios, a combination of custom factory functions and class redesign are required. Imagine that the Tom class is written slightly differently...

public class Tom {
  Jerry purpose => _provider.GetService<Jerry>();
  IDependencyProvider _provider;
  public Tom(IDependencyProvider provider) {
    _provider = provider;
  }
}

📘

IDependencyProvider is always available!

Even though the IDependencyProvider interface is not explicitly registered, it is always implicitly registered during the construction of the dependency scope.

Then, when Tom is instantiated, it does not require an instance of Jerry immediately. The instance of Jerry is deferred to when it will be used later in the system. This comes with major design trade offs.