Artículos /

Service Locator
en Unity

Estaba desarrollando Proyecto M, un ARPG en Unity, y en un momento dado me encontré con un problema que no sabía cómo resolver bien. Tenía la UI en una escena aditiva separada — Bootstrap persistente, escena de UI siempre cargada, escenas de juego que se cargan y descargan — y necesitaba que un componente del jugador accediera a la barra de habilidades, que vivía en la otra escena.

Con [SerializeField] no es posible: las dos escenas no coexisten en el Editor en el momento de asignar la referencia. FindObjectOfType funciona pero es frágil y poco eficiente — recorre toda la jerarquía de objetos en escena cada vez que se llama. Y encima ya tenía cinco singletons con .Instance dispersos por el proyecto — AudioManager, PoolManager, SceneLoader… — cada uno con su propio ciclo de vida y sin ninguna estrategia unificada.

Investigando soluciones di con el patrón Service Locator. No lo conocía antes — es de esos patrones que no necesitas hasta que el proyecto crece lo suficiente para que los singletons empiecen a doler. Una vez entendido, la solución fue clara.

Si quieres leer la explicación canónica, el capítulo de Game Programming Patterns de Bob Nystrom es el mejor punto de partida.

Antes de entrar en código, un poco de teoría — prometo que es corta.

Un Service Locator es un registro central donde los servicios se registran al iniciarse y se consultan por tipo cuando se necesitan. Es más ligero que un contenedor de inyección de dependencias: no requiere frameworks externos, no gestiona ciclos de vida automáticamente y cabe en menos de 30 líneas.

La diferencia respecto a un singleton clásico es que el consumidor no conoce la implementación concreta ni cómo fue creada — solo pide "dame algo que implemente este tipo". Eso resuelve tanto el problema de referencias cross-escena como el de tener los singletons dispersos: todos se registran en el mismo sitio y cualquiera los puede obtener desde cualquier escena.

¿Por qué no DI? Service Locator vs DI Container
Un contenedor de DI inyecta dependencias automáticamente en el constructor o en campos marcados. El Service Locator requiere que el consumidor lo llame de forma explícita. Para proyectos Unity sin framework externo como Zenject o VContainer, el Service Locator ofrece la misma flexibilidad con cero dependencias adicionales.

La clase es estática. Internamente usa un Dictionary<Type, object> indexado por tipo. Las operaciones principales son Register, Get y TryGet — esta última para casos donde el servicio puede no estar presente sin que sea un error.

C#
using System;
using System.Collections.Generic;
using UnityEngine;

public static class ServiceLocator
{
    private static readonly Dictionary<Type, object> _services = new();

    /// Registra un servicio. Si ya existía, lo sobreescribe.
    public static void Register<T>(T service) where T : class
    {
        _services[typeof(T)] = service;
    }

    /// Obtiene un servicio. Emite warning si no está registrado.
    public static T Get<T>() where T : class
    {
        if (_services.TryGetValue(typeof(T), out var service))
            return (T)service;

        Debug.LogWarning($"[ServiceLocator] No encontrado: {typeof(T).Name}");
        return null;
    }

    /// Intenta obtener un servicio sin emitir warnings.
    public static bool TryGet<T>(out T service) where T : class
    {
        if (_services.TryGetValue(typeof(T), out var obj))
        {
            service = (T)obj;
            return true;
        }
        service = null;
        return false;
    }

    /// Elimina un servicio del registro.
    public static void Unregister<T>() where T : class
        => _services.Remove(typeof(T));

    /// Limpia todos los registros (útil en tests).
    public static void ClearAll() => _services.Clear();
}

El servicio se registra en su propio Awake() o Start(). El consumidor lo solicita cuando lo necesita — normalmente también en Start(), una vez que los servicios están inicializados. Se desregistra en OnDestroy() para evitar referencias huérfanas.

C#
// El servicio se registra a sí mismo
public class AudioManager : MonoBehaviour
{
    private void Awake()
        => ServiceLocator.Register<AudioManager>(this);

    private void OnDestroy()
        => ServiceLocator.Unregister<AudioManager>();

    public void PlaySFX(AudioClip clip) { /* ... */ }
}

// Cualquier consumidor lo solicita sin saber dónde ni cómo vive
public class PlayerController : MonoBehaviour
{
    private AudioManager _audio;

    private void Start()
        => _audio = ServiceLocator.Get<AudioManager>();

    private void OnAttackLand()
        => _audio?.PlaySFX(_hitClip);
}

En Proyecto M tengo esta arquitectura de escenas:

  • Bootstrap — primera escena, DontDestroyOnLoad, inicializa servicios de infraestructura
  • UI Scene — cargada de forma aditiva y persistente, contiene HUD y barras de habilidades
  • Game Scene — se carga y descarga en cada nivel; contiene el mundo, el jugador y los enemigos

Un componente del jugador (Game Scene) necesita acceder a la barra de habilidades (UI Scene). Con [SerializeField] no es posible: las dos escenas no coexisten en el Editor en el momento de asignar la referencia. Las alternativas habituales como FindObjectOfType escalan mal y son frágiles ante renombrados.

Con el Service Locator, la barra de habilidades se registra en su Start() cuando la escena de UI carga. El jugador la obtiene en su propio Start(), que siempre se ejecuta después. La referencia se resuelve en tiempo de ejecución sin ningún acoplamiento de escena.

C#
// UI Scene — se registra al cargarse
public class AbilityBar : MonoBehaviour
{
    private void Start()
        => ServiceLocator.Register<AbilityBar>(this);
}

// Game Scene — lo obtiene sin saber dónde vive
public class PlayerController : MonoBehaviour
{
    private AbilityBar _abilityBar;

    private void Start()
        => _abilityBar = ServiceLocator.Get<AbilityBar>();
}

Cuando el proyecto ya tiene singletons con .Instance, no hace falta migrar todo a la vez. El atributo [Obsolete] permite mantener la API existente funcionando mientras se van migrando los consumidores gradualmente. Los warnings de compilación indican exactamente qué archivos siguen usando el acceso antiguo.

C#
public class PoolManager : MonoBehaviour
{
    // Acceso legacy — funciona pero genera warning de compilación
    [Obsolete("Usa ServiceLocator.Get<PoolManager>() en su lugar")]
    public static PoolManager Instance { get; private set; }

    private void Awake()
    {
        Instance = this; // compatibilidad temporal
        ServiceLocator.Register<PoolManager>(this);
    }
}

// Los archivos que aún usan PoolManager.Instance
// generan warning CS0618 — fácil de localizar y migrar uno a uno
¿Cuándo tiene sentido? El criterio práctico
El Service Locator añade una capa de indirección. Para un prototipo con dos singletons estables esa capa no vale la pena. El criterio es si el número de servicios globales crecerá de forma predecible — en ese caso centralizar desde el principio cuesta menos que refactorizar cuando ya hay diez singletons dispersos. En Proyecto M, la previsión del roadmap hacía clara esa trayectoria.