diff --git a/CHANGELOG.md b/CHANGELOG.md
index c872d01..95cd91f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+## [3.1.0] - 2026-06-21
+
+### Added
+- **Optional Addressables support.** A new `AddressableServiceKitBehaviour` loads its `ServiceKitLocator` through Addressables (via a typed `ServiceKitLocatorAssetReference`) instead of a direct serialized reference — so the locator isn't pulled into every bundle/scene that references it, and its Addressables handle is released on destroy. It is compiled only when the Addressables package is installed (the `SERVICEKIT_ADDRESSABLES` define) and costs nothing otherwise.
+- `ServiceKitBehaviour.PrepareLocatorAsync()` — a `protected virtual` hook (default no-op, completes synchronously) run in `Awake` before registration, so a subclass can source its locator from an asynchronous origin and have `ResolveLocator()` return it. Normal behaviours are unaffected and still register synchronously within `Awake`.
+
## [3.0.0] - 2026-06-08
### Changed
diff --git a/README.md b/README.md
index cbd3d45..f9d3c34 100644
--- a/README.md
+++ b/README.md
@@ -879,6 +879,22 @@ Create a settings asset via `Assets > Create > ServiceKit > Settings` (loaded fr
| `DebugLogging` | `false` | Verbose registration/ready logging (editor) |
| `DefaultServiceKitLocator` | — | Locator used for auto-assignment; takes highest priority |
+### Addressable Locators (optional)
+
+If the [Addressables](https://docs.unity3d.com/Packages/com.unity.addressables@latest) package is installed, derive from `AddressableServiceKitBehaviour` instead of `ServiceKitBehaviour` to load the locator by Addressables reference rather than a direct serialized one. The locator loads asynchronously before the service registers (so it isn't pulled into every bundle that references it) and its handle is released on destroy:
+
+```csharp
+[Service(typeof(IPlayerService))]
+public class PlayerService : AddressableServiceKitBehaviour, IPlayerService
+{
+ // Assign the locator via the ServiceKitLocatorAssetReference field in the inspector;
+ // leave the inherited ServiceKitLocator field empty.
+ protected override void InitializeService() { /* ... */ }
+}
+```
+
+This is gated behind the `SERVICEKIT_ADDRESSABLES` define (set automatically when Addressables is present) and compiles to nothing otherwise. Under the hood it overrides the `protected virtual ServiceKitBehaviour.PrepareLocatorAsync()` hook — the same hook you can override to source a locator from any other asynchronous origin.
+
## Best Practices
### Service Design
diff --git a/Runtime/AddressableServiceKitBehaviour.cs b/Runtime/AddressableServiceKitBehaviour.cs
new file mode 100644
index 0000000..1a5ca92
--- /dev/null
+++ b/Runtime/AddressableServiceKitBehaviour.cs
@@ -0,0 +1,81 @@
+#if SERVICEKIT_ADDRESSABLES
+using System;
+using UnityEngine;
+using UnityEngine.AddressableAssets;
+using UnityEngine.ResourceManagement.AsyncOperations;
+
+#if SERVICEKIT_UNITASK
+using Cysharp.Threading.Tasks;
+#else
+using System.Threading.Tasks;
+#endif
+
+namespace Nonatomic.ServiceKit
+{
+ ///
+ /// A typed Addressables reference to a , so the inspector slot only
+ /// accepts locator assets.
+ ///
+ [Serializable]
+ public class ServiceKitLocatorAssetReference : AssetReferenceT
+ {
+ public ServiceKitLocatorAssetReference(string guid) : base(guid) { }
+ }
+
+ ///
+ /// A whose locator is loaded through Addressables instead of a direct
+ /// serialized reference. The locator is loaded asynchronously before the service registers (so it is
+ /// not pulled into every bundle or scene that references it), and the Addressables handle is released on
+ /// destroy. Only compiled when the Addressables package is installed (the SERVICEKIT_ADDRESSABLES
+ /// define). Assign the locator via the AssetReference field below; leave the inherited serialized
+ /// ServiceKitLocator field empty.
+ ///
+ public abstract class AddressableServiceKitBehaviour : ServiceKitBehaviour
+ {
+ [SerializeField] private ServiceKitLocatorAssetReference _serviceKitLocatorReference;
+
+ private ServiceKitLocator _loadedLocator;
+ private AsyncOperationHandle _loadHandle;
+ private bool _hasLoadHandle;
+
+ // Return the addressable-loaded locator; the inherited serialized field is unused for this variant.
+ protected override IServiceLocator ResolveLocator() => _loadedLocator;
+
+#if SERVICEKIT_UNITASK
+ protected override async UniTask PrepareLocatorAsync()
+#else
+ protected override async Task PrepareLocatorAsync()
+#endif
+ {
+ if (_serviceKitLocatorReference == null || !_serviceKitLocatorReference.RuntimeKeyIsValid())
+ {
+ Debug.LogError($"[ServiceKit] {GetType().Name} has an invalid or missing Addressables reference to a ServiceKitLocator.", this);
+ return;
+ }
+
+ _loadHandle = _serviceKitLocatorReference.LoadAssetAsync();
+ _hasLoadHandle = true;
+
+ // AsyncOperationHandle.Task is awaitable from both the UniTask and the Task build, and avoids a
+ // dependency on UniTask's optional Addressables integration.
+ _loadedLocator = await _loadHandle.Task;
+
+ if (_loadHandle.Status != AsyncOperationStatus.Succeeded || _loadedLocator == null)
+ {
+ Debug.LogError($"[ServiceKit] {GetType().Name} failed to load its ServiceKitLocator from Addressables.", this);
+ }
+ }
+
+ protected override void OnDestroy()
+ {
+ // Unregister via the base first (it still sees the loaded locator), then release the handle.
+ base.OnDestroy();
+
+ if (!_hasLoadHandle) return;
+ Addressables.Release(_loadHandle);
+ _hasLoadHandle = false;
+ _loadedLocator = null;
+ }
+ }
+}
+#endif
diff --git a/Runtime/AddressableServiceKitBehaviour.cs.meta b/Runtime/AddressableServiceKitBehaviour.cs.meta
new file mode 100644
index 0000000..3322a9a
--- /dev/null
+++ b/Runtime/AddressableServiceKitBehaviour.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 62c10afa7e32e5b4a84e8bb49cc421a2
\ No newline at end of file
diff --git a/Runtime/ServiceKitBehaviour.cs b/Runtime/ServiceKitBehaviour.cs
index 236c5d1..77602f5 100644
--- a/Runtime/ServiceKitBehaviour.cs
+++ b/Runtime/ServiceKitBehaviour.cs
@@ -104,11 +104,30 @@ protected virtual async void Awake()
if (IsObjectDestroyed()) return;
CacheDestroyToken();
+
+ // Optional async step: a subclass can source its locator asynchronously here (e.g. load it via
+ // Addressables) before registration. The default no-op completes synchronously, so behaviours
+ // that don't override it still register within this same Awake call.
+ await PrepareLocatorAsync();
+ if (IsObjectDestroyed()) return;
+
RegisterServiceWithLocator();
await PerformServiceInitializationSequence();
}
+ ///
+ /// Optional asynchronous step run during Awake, before the service registers. Override it to source
+ /// the locator from an async origin (e.g. load a via Addressables)
+ /// and have return it. The default returns immediately, so behaviours
+ /// that don't override it register synchronously within Awake exactly as before.
+ ///
+#if SERVICEKIT_UNITASK
+ protected virtual UniTask PrepareLocatorAsync() => UniTask.CompletedTask;
+#else
+ protected virtual Task PrepareLocatorAsync() => Task.CompletedTask;
+#endif
+
private bool IsObjectDestroyed()
{
return !this || !gameObject;
diff --git a/Runtime/com.nonatomic.servicekit.runtime.asmdef b/Runtime/com.nonatomic.servicekit.runtime.asmdef
index 78777c5..ed08b61 100644
--- a/Runtime/com.nonatomic.servicekit.runtime.asmdef
+++ b/Runtime/com.nonatomic.servicekit.runtime.asmdef
@@ -2,7 +2,10 @@
"name": "com.nonatomic.servicekit.runtime",
"rootNamespace": "Nonatomic.ServiceKit",
"references": [
- "GUID:f51ebe6a0ceec4240a699833d6309b23"
+ "GUID:f51ebe6a0ceec4240a699833d6309b23",
+ "GUID:9e24947de15b9834991c9d8411ea37cf",
+ "GUID:593a5b492d29ac6448b1ebf7f035ef33",
+ "GUID:84651a3751eca9349aac36a66bba901b"
],
"includePlatforms": [],
"excludePlatforms": [],
@@ -16,6 +19,11 @@
"name": "com.cysharp.unitask",
"expression": "2.5.10",
"define": "SERVICEKIT_UNITASK"
+ },
+ {
+ "name": "com.unity.addressables",
+ "expression": "1.0.0",
+ "define": "SERVICEKIT_ADDRESSABLES"
}
],
"noEngineReferences": false
diff --git a/Samples~/12 - Addressable Locator.meta b/Samples~/12 - Addressable Locator.meta
new file mode 100644
index 0000000..b81ec4d
--- /dev/null
+++ b/Samples~/12 - Addressable Locator.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: e1f2a3b4c5d6e7f8091a2b3c4d5e6f70
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Samples~/12 - Addressable Locator/README.md b/Samples~/12 - Addressable Locator/README.md
new file mode 100644
index 0000000..3ce0473
--- /dev/null
+++ b/Samples~/12 - Addressable Locator/README.md
@@ -0,0 +1,33 @@
+# Addressable Locator
+
+Shows how to load a `ServiceKitLocator` through **Addressables** instead of a direct serialized
+reference, using `AddressableServiceKitBehaviour`.
+
+## Why
+
+A direct `[SerializeField] ServiceKitLocator` reference ties the locator asset into every bundle or
+scene that references it. Loading it by Addressables reference keeps it a single, shared, ref-counted
+asset and gives you explicit load/release. `AddressableServiceKitBehaviour` does the async load before
+the service registers, and releases the handle on destroy - you don't write any of that boilerplate.
+
+> Requires the **Addressables** package (`com.unity.addressables`). The sample code is compiled only
+> when it is installed (the `SERVICEKIT_ADDRESSABLES` define).
+
+## Setup
+
+1. Create a `ServiceKitLocator` asset: **Assets > Create > ServiceKit > ServiceKitLocator**.
+2. Mark it **Addressable** (tick the *Addressable* box in its inspector, or add it to an Addressable
+ group).
+3. Put `ExampleAddressableService` on a GameObject. In its inspector:
+ - assign the locator asset to the **`Service Kit Locator Reference`** (the AssetReference field);
+ - leave the inherited **`Service Kit Locator`** field **empty**.
+4. Press Play. On `Awake` the service loads the locator via Addressables, registers itself, and runs
+ `InitializeService()`. On destroy, the Addressables handle is released.
+
+## Notes
+
+- This pattern fits a **service that registers itself** into an addressably-loaded locator. If instead
+ you have a central object that loads the locator once and populates it from another system, that is a
+ different shape and this base class won't simplify it - load the locator yourself and use the locator
+ API directly.
+- The async load uses `UniTask` automatically if it is installed, otherwise `System.Threading.Tasks`.
diff --git a/Samples~/12 - Addressable Locator/README.md.meta b/Samples~/12 - Addressable Locator/README.md.meta
new file mode 100644
index 0000000..84608e4
--- /dev/null
+++ b/Samples~/12 - Addressable Locator/README.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: b4c5d6e7f8091a2b3c4d5e6f70819203
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Samples~/12 - Addressable Locator/Scripts.meta b/Samples~/12 - Addressable Locator/Scripts.meta
new file mode 100644
index 0000000..ab2f314
--- /dev/null
+++ b/Samples~/12 - Addressable Locator/Scripts.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: f2a3b4c5d6e7f8091a2b3c4d5e6f7081
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs b/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs
new file mode 100644
index 0000000..a1128ca
--- /dev/null
+++ b/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs
@@ -0,0 +1,36 @@
+#if SERVICEKIT_ADDRESSABLES
+using Nonatomic.ServiceKit;
+using UnityEngine;
+
+namespace ServiceKitSamples.AddressableLocator
+{
+ public interface IExampleAddressableService
+ {
+ void DoSomething();
+ }
+
+ ///
+ /// Example service whose locator is loaded through Addressables rather than a direct serialized
+ /// reference. Because it derives from :
+ /// - assign a ServiceKitLocator AssetReference on this component in the inspector
+ /// (and leave the inherited ServiceKitLocator field empty);
+ /// - the locator is loaded asynchronously before this service registers, so it is not pulled
+ /// into every bundle/scene that references it;
+ /// - the Addressables handle is released automatically when this object is destroyed.
+ /// Only compiles when the Addressables package is installed (the SERVICEKIT_ADDRESSABLES define).
+ ///
+ [Service(typeof(IExampleAddressableService))]
+ public class ExampleAddressableService : AddressableServiceKitBehaviour, IExampleAddressableService
+ {
+ protected override void InitializeService()
+ {
+ Debug.Log("[Sample] ExampleAddressableService initialized - its locator was loaded via Addressables.");
+ }
+
+ public void DoSomething()
+ {
+ Debug.Log("[Sample] ExampleAddressableService.DoSomething()");
+ }
+ }
+}
+#endif
diff --git a/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs.meta b/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs.meta
new file mode 100644
index 0000000..bc222c5
--- /dev/null
+++ b/Samples~/12 - Addressable Locator/Scripts/ExampleAddressableService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c5d6e7f8091a2b3c4d5e6f7081920314
diff --git a/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef b/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef
new file mode 100644
index 0000000..70282be
--- /dev/null
+++ b/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef
@@ -0,0 +1,22 @@
+{
+ "name": "ServiceKitSamples.AddressableLocator",
+ "rootNamespace": "ServiceKitSamples.AddressableLocator",
+ "references": [
+ "GUID:1b0144b25b4099e45a5beb8f8f7bee63"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [
+ {
+ "name": "com.unity.addressables",
+ "expression": "1.0.0",
+ "define": "SERVICEKIT_ADDRESSABLES"
+ }
+ ],
+ "noEngineReferences": false
+}
diff --git a/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef.meta b/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef.meta
new file mode 100644
index 0000000..f6d57c9
--- /dev/null
+++ b/Samples~/12 - Addressable Locator/ServiceKitSamples.AddressableLocator.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: a3b4c5d6e7f8091a2b3c4d5e6f708192
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/PlayMode/PrepareLocatorAsyncTests.cs b/Tests/PlayMode/PrepareLocatorAsyncTests.cs
new file mode 100644
index 0000000..c7bacda
--- /dev/null
+++ b/Tests/PlayMode/PrepareLocatorAsyncTests.cs
@@ -0,0 +1,75 @@
+using System.Collections;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+namespace Nonatomic.ServiceKit.Tests.PlayMode
+{
+ ///
+ /// Validates the hook: a behaviour that sources
+ /// its locator asynchronously must defer registration until the load completes, then register and
+ /// initialize normally. This is the exact mechanism AddressableServiceKitBehaviour relies on,
+ /// tested here without the Addressables package - a stands
+ /// in for the async load so the core contract is verified deterministically.
+ ///
+ public class PrepareLocatorAsyncTests
+ {
+ public interface IAsyncLocatorService { }
+
+ [Service(typeof(IAsyncLocatorService))]
+ private sealed class AsyncLocatorConsumer : ServiceKitBehaviour, IAsyncLocatorService
+ {
+ public ServiceKitLocator LocatorToLoad;
+ public readonly TaskCompletionSource LoadGate = new TaskCompletionSource();
+ private ServiceKitLocator _loaded;
+ public bool Initialized { get; private set; }
+
+ // Null until the async "load" completes - mirrors an addressable locator not yet resolved.
+ protected override IServiceLocator ResolveLocator() => _loaded;
+
+#if SERVICEKIT_UNITASK
+ protected override async Cysharp.Threading.Tasks.UniTask PrepareLocatorAsync()
+#else
+ protected override async Task PrepareLocatorAsync()
+#endif
+ {
+ // Stand-in for an async locator source (e.g. _serviceKitLocatorReference.LoadAssetAsync).
+ await LoadGate.Task;
+ _loaded = LocatorToLoad;
+ }
+
+ protected override void InitializeService() => Initialized = true;
+ }
+
+ [UnityTest]
+ public IEnumerator PrepareLocatorAsync_DefersRegistration_UntilLocatorLoaded()
+ {
+ var locator = ScriptableObject.CreateInstance();
+ var go = new GameObject(nameof(AsyncLocatorConsumer));
+ go.SetActive(false);
+ var consumer = go.AddComponent();
+ consumer.LocatorToLoad = locator;
+
+ go.SetActive(true); // Awake -> CacheDestroyToken -> await PrepareLocatorAsync (gated open)
+
+ // While the locator "load" is pending, registration must NOT have happened yet.
+ for (var i = 0; i < 30; i++) yield return null;
+ Assert.IsFalse(locator.IsServiceRegistered(),
+ "Registration must be deferred until PrepareLocatorAsync completes");
+
+ consumer.LoadGate.SetResult(true); // complete the async locator load
+
+ for (var i = 0; i < 600 && !consumer.Initialized; i++) yield return null;
+
+ Assert.IsTrue(locator.IsServiceRegistered(),
+ "Service must register once the locator has loaded");
+ Assert.IsTrue(consumer.Initialized,
+ "InitializeService must run after the async locator load completes");
+
+ Object.Destroy(go);
+ locator.ClearServices();
+ Object.Destroy(locator);
+ }
+ }
+}
diff --git a/Tests/PlayMode/PrepareLocatorAsyncTests.cs.meta b/Tests/PlayMode/PrepareLocatorAsyncTests.cs.meta
new file mode 100644
index 0000000..7ece178
--- /dev/null
+++ b/Tests/PlayMode/PrepareLocatorAsyncTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 48ec21e8b46481745ab26b1dfaa6a0ff
\ No newline at end of file
diff --git a/package.json b/package.json
index d44ac53..e15491d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "com.nonatomic.servicekit",
- "version": "3.0.0",
+ "version": "3.1.0",
"displayName": "Service Kit",
"description": "A lightweight dependency injection and service locator framework for Unity. Attribute-based registration, async resolution, fluent API, optional dependencies, service tags, scene-aware lifecycle, and UniTask support.",
"unity": "2022.3",
@@ -86,6 +86,11 @@
"displayName": "11 - Error Handling",
"description": "TryResolveService, timeout recovery, circular dependency exemption, and custom error handlers.",
"path": "Samples~/11 - Error Handling"
+ },
+ {
+ "displayName": "12 - Addressable Locator",
+ "description": "Loading a ServiceKitLocator via Addressables with AddressableServiceKitBehaviour (requires the Addressables package).",
+ "path": "Samples~/12 - Addressable Locator"
}
]
}