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,
- The public type of the service (often, an interface type)
- The private type of the service (often, an implementation class of the interface). The private type must be assignable to the public type.
- A service type of Singleton, Scoped, or Transient
- 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 AddTransient
that 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,
- have no constructor or have parameterless constructors, or
- have constructor parameter types that are all registered in the
IDependencyBuilder
as 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 ITunaService
was 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.
Updated about 1 year ago