Skip to content

Service Registry

How one module calls another, without either one knowing the other's concrete type.


1. Shape

A deliberately small registry — singleton registration and constructor-style resolution only. ADR-0002 rejects a full dependency-injection container with lifetimes and scoping as more machinery than the problem needs; every case in this project is "one instance, resolved by interface, for the life of the server."

namespace Applejack.Core.Services;

/// <summary>
/// Write side of the registry, available during <see cref="GameModule.RegisterServices"/>.
/// Registration is closed once boot reaches <see cref="GameModule.Initialize"/> --
/// registering after boot is a programmer error and throws.
/// </summary>
public interface IServiceRegistry
{
    /// <summary>
    /// Registers <paramref name="instance"/> as the sole implementation of <typeparamref name="TService"/>.
    /// Registering a second implementation for the same <typeparamref name="TService"/> is a
    /// programmer error and throws -- there is no "last one wins". Takes a finished instance,
    /// never a type to construct -- see §4, "no reflection-based activation".
    /// </summary>
    void AddSingleton<TService>(TService instance) where TService : class;
}

/// <summary>
/// Read side of the registry, available from <see cref="GameModule.Initialize"/> onward and
/// to any type constructed after boot.
/// </summary>
public interface IServiceResolver
{
    /// <summary>
    /// Resolves the registered implementation of <typeparamref name="TService"/>.
    /// </summary>
    /// <exception cref="ServiceNotRegisteredException">
    /// No module registered <typeparamref name="TService"/>. Thrown, not returned as null --
    /// a missing service is a startup-shaped problem, not a runtime one to check for.
    /// </exception>
    TService GetRequiredService<TService>() where TService : class;
}

2. Rules

  • Interfaces only. A module registers IInventoryService, never InventoryService. A consumer that resolves the concrete type has reached into the module's internals, which 00_CONSTITUTION.md and 02_MODULE_MODEL.md §5 both forbid.
  • Singleton only. Every registered service lives for the server's lifetime. If a future need genuinely requires a shorter lifetime (per-connection, per-request), that is a new capability to design deliberately, not a default to reach for.
  • Resolving a DependsOn service is safe from RegisterServices onward. ModuleRunner (02_MODULE_MODEL.md §2) finishes the RegisterServices stage for every module, in dependency order, before Initialize begins for any module. Because DependsOn is what the topological sort orders by, every module a given module depends on has therefore already completed its own RegisterServices call by the time this one runs — so resolving one of their already-registered services here is safe, not merely convenient. What is still unsafe: resolving from a module not in DependsOn (nothing prevents it at the type level, but the graph gives no guarantee it's ready), or a service its owner registers only in Initialize rather than RegisterServicesInitialize hasn't run for anyone yet at this stage.
  • A module resolves only its declared DependsOn. Nothing prevents resolving an undeclared module's service at the type level, but doing so is a review rejection — DependsOn exists specifically so the dependency graph is truthful and the topological sort means something.
  • No service locator handed to gameplay logic. IServiceResolver is consumed at Initialize time to wire concrete collaborators into a type's constructor; it is not threaded through the codebase as an ambient way to fetch things lazily. A component that wants IInventoryService takes it as a constructor parameter, not by calling Services.Get<IInventoryService>() from inside a method. This is what keeps dependencies visible in a type's signature instead of hidden inside its body.
  • Replace<TService> swaps a registration that already exists. This is extension point 4 from 02_MODULE_MODEL.md §6 — "replace a default implementation" — made concrete: a fork's module declares the owning module in DependsOn (it needs the interface anyway) and calls Replace in its own RegisterServices. Because RegisterServices runs in topological order, the dependency's RegisterServices — the one that registered the default with AddSingleton — is guaranteed to have already run. Replace throws ServiceNotRegisteredException if nothing is registered yet (nothing to replace), and throws InvalidOperationException if called a second time for the same service — exactly one replacement, for the same reason AddSingleton allows exactly one registration: no silent last-one-wins.

3. Worked example

// Inventory module depends on Items. IInventoryService needs IItemRegistry (which Items
// registers) at construction time -- constructor injection, not a settable field -- so both
// resolving the dependency and registering the finished service happen together, here in
// RegisterServices. Safe per the rule above: ItemsModule is in DependsOn, so its
// RegisterServices is guaranteed to have already run.
public sealed class InventoryModule : GameModule
{
    public override string Name => "Inventory";
    public override IReadOnlyList<Type> DependsOn => [typeof(CoreModule), typeof(ItemsModule)];

    public override void RegisterServices(IServiceRegistry registry, IServiceResolver resolver)
    {
        var items = resolver.GetRequiredService<IItemRegistry>();
        registry.AddSingleton<IInventoryService>(new InventoryService(items));
    }
}

public sealed class InventoryService : IInventoryService
{
    private readonly IItemRegistry _items;

    // Constructed by hand in RegisterServices, not by the registry itself -- the registry
    // has no activation/reflection step. See §4.
    public InventoryService(IItemRegistry items) => _items = items;
}

A module still resolves in Initialize instead whenever construction can wait that long — most services can, and doing so keeps RegisterServices limited to registration alone. Use RegisterServices for resolution only when, as above, the service needs a DependsOn collaborator at construction time and must be registered before Initialize runs for anyone (because something else's RegisterServices needs to resolve it, in turn).

3.1. Replacing a default

InventoryModule registers InventoryStore as the sole implementation of IInventoryService. A fork that wants different transfer or capacity rules doesn't edit Code/Inventories/ — it declares InventoryModule in its own module's DependsOn and replaces the registration once every framework module has finished registering:

public sealed class HouseRulesModule : GameModule
{
    public override string Name => "HouseRules";
    public override IReadOnlyList<Type> DependsOn => [typeof(CoreModule), typeof(InventoryModule)];

    public override void RegisterServices(IServiceRegistry registry, IServiceResolver resolver)
    {
        // Safe per §2: InventoryModule is a declared dependency, so its RegisterServices --
        // which registered the default IInventoryService with AddSingleton -- has already run.
        registry.Replace<IInventoryService>(new HouseInventoryService(resolver.GetRequiredService<IItemRegistry>()));
    }
}

Register HouseRulesModule in the fork's bootstrap list after the framework modules (02_MODULE_MODEL.md §4) — nothing about this requires editing InventoryModule itself.

4. No reflection-based activation

The registry does not construct anything on your behalf and never inspects a type's constructor via reflection to auto-wire it — that is forbidden for gameplay types on cost and fragility grounds. A module constructs its own services by hand in RegisterServices or Initialize, passing already-resolved dependencies as ordinary constructor arguments. This keeps construction order explicit and debuggable — stepping through Initialize in a debugger shows exactly what got built and in what order, with no hidden container magic.

5. Testing against the registry

Because IServiceRegistry and IServiceResolver are plain interfaces, module wiring is unit-testable with a fake registry that stores registrations in a dictionary — no engine, no Scene. See the worked test in 02_MODULE_MODEL.md §9.