Skip to content
One Last Click Docs

How Service Registration Works

OneLastClick.Services uses Roslyn source generators to remove most of the boilerplate normally required to register and inject services.

Instead of maintaining a central list of services, you annotate your classes with service attributes:

[ApplicationService]
public class PlayerService
{
}
[MonoBehaviourService(typeof(IPlayerView))]
public partial class PlayerView : MonoBehaviour
{
[InjectService]
private PlayerService _playerService;
}

At compile time, the source generators inspect the project and generate the registration and injection code automatically.

The overall flow looks like this:

COMPILE TIME
│
▼
┌─────────────────────┐
│ Roslyn Source │
│ Generators │
└──────────┬──────────┘
│
┌──────────┴──────────┐
│ │
▼ ▼
Application Services MonoBehaviour Services
│ │
│ ▼
│ RegisterServices(...)
│ │
▼ │
GeneratedApplicationServices │
│ │
└──────────┬──────────┘
│
│
RUNTIME
│
▼
ApplicationServices
│
▼
ServicesCollectionBuilder
│
▼
ServiceCollection
│
▼
StartServices()
│
▼
InjectServices(...)

There are three generators responsible for the system:

Generator Purpose
PerAssemblyApplicationServiceRegisterSourceGenerator Finds [ApplicationService] classes and generates their registrations
MonoBehaviourServiceSourceGenerator Generates registration code for [MonoBehaviourService] classes
InjectServicesSourceGenerator Generates InjectServices() implementations for [InjectService] members

The important distinction is that registration and injection are generated separately.


An application service is a service that exists independently of a particular MonoBehaviour.

For example:

[ApplicationService]
public class PlayerService
{
public void LoadPlayer()
{
}
}

The PerAssemblyApplicationServiceRegisterSourceGenerator searches the compilation for types decorated with the application’s service attribute:

If no application services are found, the generator has nothing to generate.

Otherwise, it sorts the services deterministically and generates a class similar to:

public static partial class GeneratedApplicationServices
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void RegisterApplicationServices()
{
ApplicationServices.AddApplicationServicesBuilderFunction(RegisterGeneratedApplicationServices);
}
public static void RegisterGeneratedApplicationServices(ServicesCollectionBuilder builder)
{
builder.Register<PlayerService>();
}
}

Each assembly can contain its own application services.

The generator runs against each compilation and discovers the services belonging to that compilation. The generated registration function is then added to the central ApplicationServices registry.

This means you don’t need to maintain something like:

services.Register<PlayerService>();
services.Register<InventoryService>();
services.Register<AudioService>();
services.Register<SaveGameService>();

in one manually maintained location.

Adding:

[ApplicationService]
public class InventoryService
{
}

is enough for the generator to discover it.


The generated application-service class uses Unity’s subsystem initialization:

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void RegisterApplicationServices()
{
ApplicationServices.AddApplicationServicesBuilderFunction(RegisterGeneratedApplicationServices);
}

This happens before the actual service collection is started.

The generated function is stored by ApplicationServices:

private static readonly List<Action<ServicesCollectionBuilder>> RegisteredBuilderFunctions = new();
public static void AddApplicationServicesBuilderFunction(Action<ServicesCollectionBuilder> registration)
{
Debug.Assert(registration != null, "provided function is null.");
RegisteredBuilderFunctions.Add(registration);
}

At this point, the services haven’t been constructed yet.

The system has only collected functions that know how to build the service collection.


The actual service collection is created when:

ApplicationServices.StartFromGameObject(gameObject);

is called.

The startup process is:

StartFromGameObject()
│
▼
new ServicesCollectionBuilder()
│
▼
Execute generated registration functions
│
▼
Register the GameObject
│
▼
builder.Build()
│
▼
ServiceCollection
│
▼
StartServices()

The important part of StartFromGameObject() is:

ServicesCollectionBuilder builder = new();
foreach (var generatedRegistrationBuilderFunctionin RegisteredBuilderFunctions)
{
try
{
generatedRegistrationBuilderFunction?.Invoke(builder);
}
catch (Exception e)
{
Debug.LogException(e);
}
}
builder.RegisterGameObject(gameObject);
_serviceCollection = builder.Build();
StartServices();

Every generated registration function gets the same builder.

For example, imagine two assemblies generated:

RegisterAssemblyA(builder);
RegisterAssemblyB(builder);

The resulting builder receives registrations from both assemblies.


MonoBehaviour services are slightly different.

A MonoBehaviour is already a Unity object, so the service system needs to register the existing instance, rather than create a new instance using the normal service registration mechanism.

For example:

[MonoBehaviourService]
public partial class PlayerController : MonoBehaviour
{
}

The MonoBehaviourServiceSourceGenerator discovers this type and generates a partial implementation.

Conceptually, it generates:

public partial class PlayerController : IServiceRegisterer
{
public void RegisterServices(ServicesCollectionBuilder servicesCollectionBuilder)
{
servicesCollectionBuilder.Register<PlayerController>(this);
}
}

The important detail is:

Register<PlayerController>(this);

The this reference is the actual MonoBehaviour instance.


A MonoBehaviourService can specify types that it should be registered as.

For example:

[MonoBehaviourService(typeof(IPlayerView))]
public partial class PlayerView : MonoBehaviour
{
}

The generator reads the attribute arguments and then generates:

public void RegisterServices(ServicesCollectionBuilder servicesCollectionBuilder)
{
servicesCollectionBuilder.Register<IPlayerView, PlayerView>();
}

This allows consumers to depend on the abstraction:

[InjectService]
private IPlayerView _playerView;

rather than the concrete PlayerView.

Multiple registration types are also possible:

[MonoBehaviourService(typeof(IPlayerView), typeof(ISelectable))]
public partial class PlayerView : MonoBehaviour
{
}

which results conceptually in:

servicesCollectionBuilder.Register<IPlayerView, PlayerView>();
servicesCollectionBuilder.Register<ISelectable, PlayerView>();

Once services can be registered, the final piece is injection.

A class can mark fields or properties with [InjectService]:

public partial class PlayerController : MonoBehaviour
{
[InjectService]
private PlayerService _playerService;
[InjectService]
public IPlayerView PlayerView { get; set; }
}

The InjectServicesSourceGenerator finds types containing injectable members. For each type, it generates an InjectServices() method.

The generated implementation is conceptually:

public partial class PlayerController : IServiceInjectable
{
public virtual void InjectServices(IServiceResolver resolver)
{
_playerService = resolver.Get<PlayerService>();
PlayerView = resolver.Get<IPlayerView>();
}
}

There is no reflection involved in the generated assignments.

The generated code directly calls:

resolver.Get<T>();

for each injectable member.


The generators don’t replace your class.

Instead, they add another part of it.

Your source:

public partial class PlayerController : MonoBehaviour
{
[InjectService]
private PlayerService _playerService;
}

and generated source:

public partial class PlayerController
{
public void InjectServices(IServiceResolver resolver)
{
_playerService = resolver.Get<PlayerService>();
}
}

become one class after compilation.

This keeps generated implementation details out of the handwritten source.


Injection also takes inheritance into account.

Suppose a base class contains an injectable member:

public partial class BaseController : MonoBehaviour
{
[InjectService]
protected PlayerService PlayerService;
}

and a derived class contains another:

public partial class PlayerController : BaseController
{
[InjectService]
private IPlayerView _playerView;
}

The generator detects that the base type is also injectable.

Instead of replacing the base implementation, it generates an override:

public override void InjectServices( IServiceResolver resolver)
{
base.InjectServices(resolver);
_playerView = resolver.Get<IPlayerView>();
}

This creates an injection chain through the inheritance hierarchy.

Conceptually:

PlayerController.InjectServices()
│
▼
BaseController.InjectServices()
│
├── PlayerService
│
▼
PlayerController
│
└── IPlayerView

This is why the generator checks:

HasInjectableBaseType(...)

before deciding whether to implement or override InjectServices().


Putting everything together, the lifecycle looks like this:

C# source
│
├── [ApplicationService]
│
├── [MonoBehaviourService]
│
└── [InjectService]
│
▼
Roslyn generators
│
├──────────────────────────┐
│ │
▼ ▼
GeneratedApplicationServices Generated partial classes
│
├── RegisterServices()
│
└── InjectServices()

For application services, Unity invokes:

RegisterApplicationServices();

The generated method registers its builder function:

ApplicationServices.AddApplicationServicesBuilderFunction(RegisterGeneratedApplicationServices);

At this stage, the system knows how to register the services.


Eventually:

ApplicationServices.StartFromGameObject(gameObject);

creates the builder.

Every generated registration function is invoked:

foreach (var registration in RegisteredBuilderFunctions)
{
registration(builder);
}

The builder now contains registrations for:

  • application services
  • MonoBehaviour services
  • other registrations associated with the startup GameObject

The builder is then converted into the runtime collection:

_serviceCollection = builder.Build();

After building:

StartServices();

is called.

This performs the actual service startup:

_serviceCollection.StartServices();

The service collection is now considered started.

private static bool IsStarted =>_serviceCollection != null &&_serviceCollection.IsStarted;

Once the collection has started, services can be retrieved through:

ApplicationServices.Get<PlayerService>();

or safely:

if (ApplicationServices.TryGet<PlayerService>(out PlayerService playerService))
{
// Use playerService
}

The static API delegates to the current ServiceCollection:

public static TI Get<TI>()
{
return _serviceCollection.Get<TI>();
}

and:

public static bool TryGet<TI>(out TI service)
{
if (HasStarted() == false)
{
service = default;
return false;
}
return _serviceCollection.TryGet<TI>(out service);
}

The generated injection code uses the same underlying resolver mechanism.


After startup, ApplicationServices.TryUpdate() can be called.

If the service collection has not started, nothing happens:

if (IsStarted == false)
{
return false;
}

Otherwise:

UpdateServices();
return true;

updates both application services and scene services:

private static void UpdateServices()
{
Debug.Assert(IsStarted == true, "Services not started");
_serviceCollection.UpdateServices();
SceneServices.UpdateServices();
}

The runtime lifecycle is therefore:

Build
│
▼
StartServices()
│
▼
UpdateServices()
│
▼
UpdateServices()
│
▼
UpdateServices()
│
▼
Application quitting
│
▼
StopServices()

ApplicationServices subscribes to Unity’s quitting event when services start:

Application.quitting += Stop;

When Unity quits, Stop() is called.

It stops the services:

StopServices();

and clears the service collection:

_serviceCollection = null;

StopServices() also clears the generated registration functions:

RegisteredBuilderFunctions.Clear();

The complete shutdown sequence is:

Application.quitting
│
▼
Stop()
│
▼
StopServices()
│
├── SceneServices.StopSceneServices()
│
└── ServiceCollection.StopServices()
│
▼
_serviceCollection = null

The service system could theoretically perform discovery using reflection at runtime.

Instead, discovery happens at compile time.

For example, rather than runtime code searching for:

[ApplicationService]

the generator turns:

[ApplicationService]
public class PlayerService
{
}

into an explicit registration:

builder.Register<PlayerService>();

Likewise:

[InjectService]
private PlayerService _playerService;

becomes:

_playerService =
resolver.Get<PlayerService>();

This has several useful properties:

  • No runtime type scanning is required for these operations.
  • No reflection is required to perform the generated injection assignments.
  • The compiler knows the concrete types involved.
  • Adding a service does not require editing a central registration file.
  • Generated registrations are deterministic.
  • The generated code can be inspected when debugging compilation.

Application services are explicitly sorted before their registrations are generated:

DeterministicallySortServiceTypes(applicationServices);

The sort key is:

type.ContainingAssembly.Name
+ "."
+ type.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat);

This means the generated registration order does not depend on arbitrary discovery order.

Conceptually:

AssemblyA.PlayerService
AssemblyA.SaveService
AssemblyB.AudioService
AssemblyB.InventoryService

will always be emitted in the same order for the same set of source types.

This makes generated output more predictable and avoids unnecessary changes caused purely by discovery ordering.


For most services, the handwritten code is intentionally small.

[ApplicationService]
public class InventoryService
{
}
[MonoBehaviourService]
public partial class PlayerController : MonoBehaviour
{
}
[MonoBehaviourService(typeof(IPlayerView))]
public partial class PlayerView : MonoBehaviour
{
}
public partial class PlayerController : MonoBehaviour
{
[InjectService]
private InventoryService _inventoryService;
}

The generators take care of the rest.


The easiest way to think about the system is to separate it into three stages:

1. DISCOVER
│
│ Source generators inspect attributes
▼
2. GENERATE
│
│ Registration + injection code is compiled
▼
3. RUN
│
│ ApplicationServices builds and starts the collection
▼
SERVICES

Or, more concretely:

[ApplicationService]
│
▼
builder.Register<T>()
│
▼
ServiceCollection
│
▼
StartServices()
│
▼
resolver.Get<T>()
│
▼
[InjectService] member

The important idea is that attributes describe intent, source generators turn that intent into normal C# code, and ApplicationServices coordinates that generated code at runtime.