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, neverInventoryService. 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
DependsOnservice is safe fromRegisterServicesonward.ModuleRunner(02_MODULE_MODEL.md §2) finishes theRegisterServicesstage for every module, in dependency order, beforeInitializebegins for any module. BecauseDependsOnis what the topological sort orders by, every module a given module depends on has therefore already completed its ownRegisterServicescall 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 inDependsOn(nothing prevents it at the type level, but the graph gives no guarantee it's ready), or a service its owner registers only inInitializerather thanRegisterServices—Initializehasn'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 —DependsOnexists specifically so the dependency graph is truthful and the topological sort means something. - No service locator handed to gameplay logic.
IServiceResolveris consumed atInitializetime 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 wantsIInventoryServicetakes it as a constructor parameter, not by callingServices.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 inDependsOn(it needs the interface anyway) and callsReplacein its ownRegisterServices. BecauseRegisterServicesruns in topological order, the dependency'sRegisterServices— the one that registered the default withAddSingleton— is guaranteed to have already run.ReplacethrowsServiceNotRegisteredExceptionif nothing is registered yet (nothing to replace), and throwsInvalidOperationExceptionif called a second time for the same service — exactly one replacement, for the same reasonAddSingletonallows 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.