Diseño de software

Patrón Singleton en Swift: la verdadera razón por la que shared se considera un antipatrón

Al desarrollar con Swift, es muy habitual encontrarse con singletons creados con una sola línea, static let shared.

4 min de lectura
Imagen de portada de Patrón Singleton en Swift: la verdadera razón por la que shared se considera un antipatrón

Al desarrollar con Swift, es muy habitual encontrarse con singletons creados con una sola línea, static let shared.

Al crear una app para iOS por primera vez, es fácil cubrirlo todo con singletons: el gestor de red, la sesión del usuario e incluso la gestión de caché.

Porque resulta cómodo. Basta con llamar a Manager.shared desde cualquier lugar.

Pero cuando el proyecto crece empiezan a ocurrir cosas extrañas. Las pruebas se vuelven imposibles. Corriges una pantalla y aparece un error en un lugar completamente ajeno.

Hay buenas razones para que los singletons se consideren un antipatrón.

En este artículo veremos qué es exactamente el patrón Singleton en Swift, por qué su abuso se considera un antipatrón y cuándo puede seguir siendo válido, desde una perspectiva práctica.

La conclusión es que el singleton no es malo por sí mismo; el problema es permitir que su estado se modifique globalmente desde cualquier lugar. Por eso, abusar de shared bloquea las pruebas, oculta dependencias y puede provocar problemas de concurrencia.

Patrón Singleton en Swift: ¿qué es exactamente?

Un singleton es un patrón de diseño que garantiza que exista una única instancia en toda la app.

En Swift se puede crear de forma muy breve. Esa es tanto su ventaja como su trampa.

final class NetworkManager {
    static let shared = NetworkManager()  // Solo uno en la app
    private init() {}                     // Bloquear la creación externa
    func request(_ url: URL) { /* ... */ }
}

static let se inicializa exactamente una vez de forma segura para subprocesos gracias a Swift. Por eso, crear la instancia es seguro incluso sin un lock adicional.

También es fundamental impedir la creación externa mediante private init(). Sin ello, solo sería un objeto global, no un singleton.

Hasta aquí parece muy limpio. El problema empieza porque se puede llamar desde cualquier lugar.


¿Por qué la instancia shared se considera un antipatrón?

Explicaré las tres razones que más se señalan, en el orden en que las experimenté.

Primero, las pruebas se convierten en un infierno.

Si llamas directamente a NetworkManager.shared en el código, no hay forma de sustituirlo por un objeto mock durante las pruebas.

Las solicitudes pueden llegar al servidor real o las pruebas pueden compartir estado, de modo que cambiar el orden cambia el resultado.

Segundo, las dependencias quedan ocultas.

Si una función usa UserSession.shared a escondidas internamente, su firma por sí sola no permite saber qué necesita.

Esto resulta especialmente peligroso al colaborar, porque hay que revisar todo el código para ver las relaciones de dependencia.

Tercero, los problemas de concurrencia del estado global mutable.

Si el singleton contiene propiedades y varios subprocesos las leen y escriben simultáneamente, se produce una carrera de datos.

Que la creación de la instancia sea segura no significa que también lo sean los cambios de estado internos. Confundir ambas cosas puede provocar crashes.

Mi código de la época en que cubría todo con shared; ahora me estremece verlo
Mi código de la época en que cubría todo con shared; ahora me estremece verlo

En resumen:

La verdadera razón por la que se critica a los singletons no es que exista uno solo, sino que se pueda obtener y modificar desde cualquier lugar.


Entonces, ¿no se deben usar nunca los singletons?

No. Todavía los uso en situaciones concretas.

Hay cosas cuya existencia única es natural. Por ejemplo, UserDefaults.standard, FileManager.default y URLSession.shared; Apple también usa singletons en sus bibliotecas estándar.

Esta regla práctica puede ayudarte:

  • El estado casi no cambia y predominan las lecturas → un singleton está bien
  • Debe existir realmente uno solo en toda la app → considera un singleton
  • Debe comportarse de otra forma en las pruebas → se recomienda la inyección de dependencias

La clave es no llamar directamente a shared dentro del código, sino inyectarlo desde fuera.

shared como valor predeterminado e inyección de un objeto falso para las pruebas
shared como valor predeterminado e inyección de un objeto falso para las pruebas

Aunque uses el mismo singleton, este cambio mejora enormemente la capacidad de prueba.

protocol Networking { func request(_ url: URL) }
extension NetworkManager: Networking {}

final class FeedViewModel {
    private let network: Networking
    init(network: Networking = .shared) {  // El valor predeterminado es un singleton; para las pruebas, inyecta mock 
        self.network = network
    }
}

En el uso normal, el singleton predeterminado resulta cómodo; en las pruebas, puedes introducir un objeto falso, lo que aporta flexibilidad.

No has eliminado el singleton; solo has cambiado la forma de llamarlo.


3 reglas que sigo en la práctica

Por último, compartiré los criterios que me he impuesto en el trabajo.

  1. No crear singletons cuyo estado mutable se modifique desde varios lugares. Si se necesita estado, hay que definir claramente quién lo posee.
  2. No llamar directamente a shared dentro de una función; inyectarlo mediante el inicializador o los parámetros.
  3. Los singletons con acceso concurrente deben implementarse como actor o protegerse con una cola serial.

Con solo seguir estas tres reglas, casi nunca llegarás a una situación en la que el proyecto se deteriore por culpa de los singletons.

El singleton no es una herramienta incorrecta; simplemente es tan fácil que invita a abusar de ella.

Antes de esparcir shared por todas partes solo porque resulta cómodo, pregúntate una vez: «¿Podré probar esto más adelante?». Esa pregunta te salvará dentro de 6 meses.

Seguir leyendo