Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 32 additions & 15 deletions docs/class-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ GameKit's DI container (`GameKit.DependencyInjection`) supports singleton and tr
- Transient services create a new instance for each resolution or injection site.
- `BuildServiceProvider` resolves singleton services immediately before returning and records transient factories for later resolution.
- Factory registrations may return `null` to contribute no service. Resolution skips null results.
- `ServiceProvider` supports a parent chain: resolution falls back to the parent provider when a type is not registered locally.
- `ServiceProvider.CreateServiceCollection()` creates child collections whose providers inherit from the parent.
- `ServiceProvider` itself is automatically registered and resolvable.
- Registration is done through `ServiceCollection`; the built `ServiceProvider` is immutable after `BuildServiceProvider` returns.

Expand Down Expand Up @@ -229,15 +229,28 @@ Use when:

---

### `AddRegistry<TService>()`
### `AddRegistry<TService>(Comparison<TService>? comparison = null)`

Registers a `ServiceRegistry<TService>` singleton that tracks activated services assignable to
`TService`. The registry does not create services by itself; it observes normal service activation.
Singletons appear during `BuildServiceProvider`, and transients appear when they are resolved.
Tracked services are removed from the registry when the owning provider disposes them.
The optional comparison is applied when pending services are published before an outermost iteration.
Removing services preserves the existing order, and changing comparison state alone does not reorder the registry.

The registry is enumerable but is not a list. Activated services remain pending until the next
outermost iteration begins. Services activated during iteration are therefore excluded from that
iteration and become visible to the next one. Services removed during iteration are skipped
immediately. Nested iterations use the same published service generation as their outer iteration.
Enumeration uses a struct enumerator without creating a snapshot.

```csharp
services.AddRegistry<IUpdatable>();
services.AddRegistry<IUpdatable>(static (left, right) =>
{
int leftOrder = left is IOrderable leftOrderable ? leftOrderable.Order : 0;
int rightOrder = right is IOrderable rightOrderable ? rightOrderable.Order : 0;
return leftOrder.CompareTo(rightOrder);
});
services.AddSingleton<PlayerController>();

ServiceRegistry<IUpdatable> registry =
Expand Down Expand Up @@ -309,9 +322,9 @@ services.OnActivated(static (instance, type) =>

---

### `IsRegistered(Type)` / `IsRegistered<T>()`
### `IsRegistered<T>()`

Returns `true` if the type has been registered at least once.
Returns `true` if the type has been registered in the collection or its parent provider hierarchy.

```csharp
if (!services.IsRegistered<DebugOverlay>())
Expand All @@ -322,20 +335,24 @@ if (!services.IsRegistered<DebugOverlay>())

---

### `BuildServiceProvider()` / `BuildServiceProvider(ServiceProvider? parent)`
### `BuildServiceProvider()`

Resolves all services, fires `OnStart` callbacks, freezes the provider, and returns it. The optional `parent` parameter sets up a fallback chain for resolution.
Resolves all services, fires `OnStart` callbacks, freezes the provider, and returns it. A collection
created by `ServiceProvider.CreateServiceCollection()` builds a child provider of that provider.

```csharp
ServiceProvider provider = services.BuildServiceProvider();

// Child provider with fallback to a parent
ServiceProvider child = childServices.BuildServiceProvider(parent: provider);
ServiceCollection childServices = provider.CreateServiceCollection();
ServiceProvider child = childServices.BuildServiceProvider();
```

## Parent/Child Providers

`BuildServiceProvider(parent)` creates a child provider that inherits from a parent. This is the mechanism behind scoped lifetimes such as stage management — a stage creates a child provider, and disposing the child cleanly tears down only the stage's services.
`ServiceProvider.CreateServiceCollection()` binds a new collection to its parent before registration
begins. `IsRegistered<T>()` can therefore see inherited registrations while the child is configured.
Building the collection creates a child provider, and disposing the child tears down only its services.

### Service resolution

Expand All @@ -346,9 +363,9 @@ ServiceCollection rootCollection = new();
rootCollection.AddSingleton(new AppConfig());
ServiceProvider root = rootCollection.BuildServiceProvider();

ServiceCollection stageCollection = new();
ServiceCollection stageCollection = root.CreateServiceCollection();
stageCollection.AddSingleton<IView>(new GameplayView());
ServiceProvider stage = stageCollection.BuildServiceProvider(parent: root);
ServiceProvider stage = stageCollection.BuildServiceProvider();

// stage can resolve both its own and parent services
AppConfig config = stage.GetRequiredService<AppConfig>();
Expand All @@ -363,7 +380,7 @@ Multi-registrations compose across the hierarchy: parent entries appear first, f

`OnActivated` and `OnDisposing` callbacks registered on the parent's `ServiceCollection` are **merged into the child provider**. When the child provider constructs a service, the parent's `OnActivated` callbacks fire first, then the child's own. When the child provider disposes, its `OnDisposing` callbacks fire first, then the parent's.

This means child services automatically participate in any lifecycle hooks the parent set up. `AddRegistry<TService>()` is built on these callbacks, so a child provider built with `BuildServiceProvider(parent: root)` contributes matching services to registries created by the parent and removes them on disposal. Some higher-level systems still use callbacks directly when they need richer behavior than a plain role list.
This means child services automatically participate in any lifecycle hooks the parent set up. `AddRegistry<TService>()` is built on these callbacks, so a child provider contributes matching services to registries created by the parent and removes them on disposal. Some higher-level systems still use callbacks directly when they need richer behavior than a plain role list.

```csharp
// Root sets up a registry-backed role list
Expand All @@ -374,10 +391,10 @@ ServiceRegistry<IUpdatable> updatables =
root.GetRequiredService<ServiceRegistry<IUpdatable>>();

// Child inherits the registry callbacks — PhysicsSystem appears in the root registry
ServiceCollection stageCollection = new();
ServiceCollection stageCollection = root.CreateServiceCollection();
stageCollection.AddSingleton<IUpdatable, PhysicsSystem>();
ServiceProvider stage = stageCollection.BuildServiceProvider(parent: root);
Assert.That(updatables.Services, Has.Some.InstanceOf<PhysicsSystem>());
ServiceProvider stage = stageCollection.BuildServiceProvider();
Assert.That(updatables, Has.Some.InstanceOf<PhysicsSystem>());

// Disposing the child removes PhysicsSystem from the registry
stage.Dispose();
Expand Down
54 changes: 37 additions & 17 deletions src/GameKit.DependencyInjection/ServiceCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@ namespace GameKit.DependencyInjection;
/// <summary>Collects service registrations and builds a <see cref="ServiceProvider"/> with singleton services eagerly resolved and transient services resolved on demand.</summary>
public class ServiceCollection
{
private readonly ServiceProvider? _parent;
private readonly HashSet<int> _registeredTypeIds = new();
private readonly Dictionary<int, List<ServiceDescriptor>> _serviceGroups = new();
private readonly List<Action<ServiceProvider>> _onStartActions = new();
private readonly List<ServiceActivatedCallback> _activatedCallbacks = new();
private readonly List<ServiceDisposingCallback> _disposingCallbacks = new();

public ServiceCollection()
{
}

internal ServiceCollection(ServiceProvider parent)
{
_parent = parent;
}

/// <summary>Registers <typeparamref name="T"/> as a singleton, constructing it via its single public constructor with dependencies resolved from the provider.</summary>
/// <typeparam name="T">The concrete service type to register. Must be a named concrete type at the call site, not a type parameter.</typeparam>
/// <remarks>This overload is intercepted by the source generator at each call site. The type argument must be a named concrete type — passing a type parameter prevents interception and causes the method to throw at runtime.</remarks>
Expand Down Expand Up @@ -222,7 +232,9 @@ public void OnDisposing(ServiceDisposingCallback callback)
/// <returns><see langword="true"/> if <typeparamref name="T"/> is registered; otherwise <see langword="false"/>.</returns>
public bool IsRegistered<T>()
{
return _registeredTypeIds.Contains(ServiceTypeId<T>.Id);
int id = ServiceTypeId<T>.Id;
return _registeredTypeIds.Contains(id) ||
_parent?.IsRegistered(id) == true;
}

/// <summary>
Expand All @@ -231,27 +243,28 @@ public bool IsRegistered<T>()
/// resolution activates them.
/// </summary>
/// <typeparam name="TService">The service role to track.</typeparam>
public void AddRegistry<TService>() where TService : class
/// <param name="comparison">An optional comparison applied before each outermost registry iteration.</param>
public void AddRegistry<TService>(Comparison<TService>? comparison = null) where TService : class
{
if (IsRegistered<ServiceRegistry<TService>>())
{
return;
}

ServiceRegistry<TService> registry = new();
ServiceRegistry<TService> registry = new(comparison);
AddSingleton(registry);
OnActivated((instance, _) =>
{
if (instance is TService service)
{
registry.Subscribe(service);
registry.Register(service);
}
});
OnDisposing((instance, _) =>
{
if (instance is TService service)
{
registry.Unsubscribe(service);
registry.Unregister(service);
}
});
}
Expand All @@ -260,20 +273,27 @@ public void AddRegistry<TService>() where TService : class
/// <returns>The fully constructed and frozen <see cref="ServiceProvider"/>.</returns>
public ServiceProvider BuildServiceProvider()
{
return BuildServiceProvider(null);
ServiceProvider provider = new ServiceProvider(_parent);

try
{
return BuildServiceProvider(provider);
}
catch
{
provider.Dispose();
throw;
}
}

/// <summary>Resolves all services, fires <c>OnStart</c> callbacks, freezes the provider, and returns it; resolution falls back to <paramref name="parent"/> when a type is not registered locally.</summary>
/// <param name="parent">An optional parent provider used as a fallback for types not registered in this collection.</param>
/// <returns>The fully constructed and frozen <see cref="ServiceProvider"/>.</returns>
public ServiceProvider BuildServiceProvider(ServiceProvider? parent)
private ServiceProvider BuildServiceProvider(ServiceProvider provider)
{
ServiceProvider provider = new ServiceProvider(parent);
provider.SetRegisteredTypeIds(_registeredTypeIds);

List<ServiceActivatedCallback>? activatedCallbacks =
MergeCallbacks(parent?.ActivatedCallbacks, _activatedCallbacks, parentFirst: true);
MergeCallbacks(_parent?.ActivatedCallbacks, _activatedCallbacks, parentFirst: true);
List<ServiceDisposingCallback>? disposingCallbacks =
MergeCallbacks(parent?.DisposingCallbacks, _disposingCallbacks, parentFirst: false);
MergeCallbacks(_parent?.DisposingCallbacks, _disposingCallbacks, parentFirst: false);
provider.SetCallbacks(activatedCallbacks, disposingCallbacks);

// Register ServiceProvider itself
Expand All @@ -285,9 +305,9 @@ public ServiceProvider BuildServiceProvider(ServiceProvider? parent)

// Set build-time resolvers so generated factories can trigger on-demand resolution
provider.SetBuildTimeResolver(
(id, type) => ResolveServiceById(id, type, provider, parent, singletonInstances, resolving),
id => TryResolveServiceById(id, provider, parent, singletonInstances, resolving),
id => ResolveServiceCollectionById(id, provider, parent, singletonInstances, resolving));
(id, type) => ResolveServiceById(id, type, provider, _parent, singletonInstances, resolving),
id => TryResolveServiceById(id, provider, _parent, singletonInstances, resolving),
id => ResolveServiceCollectionById(id, provider, _parent, singletonInstances, resolving));

// Singleton descriptors are eager, including descriptors shadowed for single-service
// resolution. Null results are cached so their factories run exactly once.
Expand All @@ -302,7 +322,7 @@ public ServiceProvider BuildServiceProvider(ServiceProvider? parent)
ResolveSingletonDescriptor(
descriptor,
provider,
parent,
_parent,
singletonInstances,
resolving);
}
Expand Down
29 changes: 29 additions & 0 deletions src/GameKit.DependencyInjection/ServiceProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public class ServiceProvider : IDisposable
private bool _disposed;
private Dictionary<int, ServiceCollectionCache>? _serviceCollections;
private Dictionary<int, ServiceCollectionRegistration[]>? _serviceCollectionRegistrations;
private HashSet<int>? _registeredTypeIds;
private readonly List<TransientDisposalRecord> _transientDisposalRecords = new();
// Tracks singleton instances in creation order for reverse-order disposal.
private readonly List<ServiceCreationRecord> _creationRecords = new();
Expand Down Expand Up @@ -62,6 +63,33 @@ internal ServiceProvider(ServiceProvider? parent)
_pending = new Dictionary<int, object>();
}

/// <summary>Creates a service collection whose providers inherit services from this provider.</summary>
public ServiceCollection CreateServiceCollection()
{
ThrowIfDisposed();
return new ServiceCollection(this);
}

internal bool IsRegistered(int id)
{
ThrowIfDisposed();
return _registeredTypeIds?.Contains(id) == true;
}

internal void SetRegisteredTypeIds(IEnumerable<int> localRegisteredTypeIds)
{
if (_parent?._registeredTypeIds is HashSet<int> parentRegisteredTypeIds)
{
_registeredTypeIds = new HashSet<int>(parentRegisteredTypeIds);
}
else
{
_registeredTypeIds = new HashSet<int>();
}

_registeredTypeIds.UnionWith(localRegisteredTypeIds);
}

private void AddChild(ServiceProvider child)
{
ThrowIfDisposed();
Expand Down Expand Up @@ -623,6 +651,7 @@ public void Dispose()
_pending = null;
_serviceCollections = null;
_serviceCollectionRegistrations = null;
_registeredTypeIds = null;
_activatedCallbacks = null;
_disposingCallbacks = null;
_buildTimeResolver = null;
Expand Down
Loading
Loading