DEV Community

Paul Stamp
Paul Stamp

Posted on

ServiceKit V2 — The Async Service Locator for Unity

I just shipped V2 of ServiceKit, my lightweight dependency management package for Unity. Before I get into what's new, I want to address the thing some of you are already typing into the comments: yes, ServiceKit is a service locator. On purpose. I think that's the right shape for Unity, and V2 is where I've stopped hedging about it.

Why a service locator, not a "proper" DI framework

The "service locator is an anti-pattern" mantra largely comes from enterprise .NET, where you control object construction, processes are long-lived, and writing an installer for two hundred services is routine. Unity isn't that world. Unity instantiates your components for you, so something has to do late-binding resolution regardless of what you call it. The heavyweight DI frameworks respond to that by adding more ceremony, not less. Contexts, scopes, installers, factories, binding chains. Adopting Zenject is a lifestyle decision. You don't use Zenject so much as restructure your project around it.

Service locators are the opposite trade. Low friction, no framework imposed on the rest of your codebase, incremental. You can drop ServiceKit in to solve one pain point without rewriting everything around it.

The two classic critiques of service locators are hidden dependencies and async timing. V2 is where I've tried to put both to bed.

Hidden dependencies, un-hidden

[InjectService] on fields surfaces the dependency graph at the class level. It's not quite a constructor signature, but it's visible to code review, tooling, and the new Roslyn analyzers. That covers most of the "you can't see what a class needs" complaint.

[Service(typeof(IPlayerController))]
public class PlayerController : ServiceKitBehaviour, IPlayerController
{
    [InjectService] private IPlayerService _playerService;
    [InjectService] private IAudioService  _audioService;

    protected override void InitializeService()
    {
        _playerService.LoadPlayer();
    }
}
Enter fullscreen mode Exit fullscreen mode

Two compile-time analyzers ship in V2:

  • SK003 flags [Service(typeof(IFoo))] attributes on classes that don't actually implement IFoo.
  • SK005 catches ServiceKitBehaviour subclasses that forget base.Awake(). Both catch the kind of bug that's annoying to hunt down at runtime.

Async resolution, the real headache fixed

This is the one I'm most pleased with. The traditional service locator's real failure mode in Unity isn't philosophical, it's the timing problem. You ask for IAudioService in Awake on the wrong GameObject, it isn't registered yet, you get null, and you're debugging initialisation order for three hours.

V2 resolves this cleanly. Field injection is a single await:

// V1
await _serviceKit.Inject(this)
    .WithErrorHandling()
    .WithTimeout()
    .ExecuteWithCancellationAsync(token);

// V2
await _serviceKit.InjectAsync(this, destroyCancellationToken);
Enter fullscreen mode Exit fullscreen mode

And under the hood there's a new atomic 3-state resolution primitive that performs registration and readiness checks inside a single lock:

var status = locator.TryResolveService(typeof(IMyService), out var service);
// ServiceResolutionStatus.Ready
// ServiceResolutionStatus.RegisteredNotReady
// ServiceResolutionStatus.NotRegistered
Enter fullscreen mode Exit fullscreen mode

Previously, consumers had to check registration, then readiness, then fetch, giving the world three chances to change between steps. V2 closes that window.

Alongside that: task forwarding in GetServiceAsync now happens inside locks, double-registration is blocked with Interlocked operations, and circular-dependency detection uses types rather than string comparison. Less likely to bite you in production.

Generics out, attributes in

The other big ergonomic change. V1 asked you to inherit from ServiceKitBehaviour<T>, which got painful fast when your own classes needed generics too. V2 replaces that with a plain base class and an explicit attribute:

// V1
public class AudioManager : ServiceKitBehaviour<IAudioService>, IAudioService { }

// V2
[Service(typeof(IAudioService))]
public class AudioManager : ServiceKitBehaviour, IAudioService { }
Enter fullscreen mode Exit fullscreen mode

Registration intent is declarative instead of tangled in type parameters, and abstract base classes can keep their own generics without fighting ServiceKit's.

Migrating from V1

Mostly mechanical: drop the generic parameter on ServiceKitBehaviour, add [Service(typeof(IYourInterface))], and replace builder chains with InjectAsync(this, token). The README has the full migration guide, including how to handle abstract base classes that mix ServiceKit generics with their own.

Try it

Install via Package Manager → Add package from git URL:

https://www.pkglnk.dev/servicekit.git
Enter fullscreen mode Exit fullscreen mode

Repo and docs: github.com/PaulNonatomic/ServiceKit

MIT licensed. Issues and PRs welcome. If you think I'm wrong about the locator vs DI trade-off, I want to hear it. V2 is the foundation I want to build on, so I'm happy to argue about it.

Top comments (1)

Collapse
 
aibughunter profile image
AI Bug Slayer 🐞

The async service locator pattern makes a lot of sense for Unity where initialization order can be unpredictable. V2 making it explicit rather than hedging is the right call!