Design de software

Padrão Singleton no Swift: o verdadeiro motivo pelo qual shared é considerado um antipadrão

Ao desenvolver com Swift, é muito comum encontrar singletons criados com uma única linha, static let shared.

4 min de leitura
Imagem de capa de Padrão Singleton no Swift: o verdadeiro motivo pelo qual shared é considerado um antipadrão

Ao desenvolver com Swift, é muito comum encontrar singletons criados com uma única linha, static let shared.

Ao criar um app iOS pela primeira vez, é fácil transformar tudo em singleton: o gerenciador de rede, a sessão do usuário e até o gerenciamento de cache.

Porque é conveniente. Basta chamar Manager.shared de qualquer lugar.

Mas, conforme o projeto cresce, coisas estranhas começam a acontecer. Os testes ficam impossíveis. Você corrige uma tela e um bug aparece em um lugar sem relação alguma.

Há bons motivos para os singletons serem chamados de antipadrão.

Neste artigo, vamos entender o que é exatamente o padrão Singleton no Swift, por que seu uso excessivo é tratado como antipadrão e quando ainda pode ser usado, sob uma perspectiva prática.

Em resumo, o singleton em si não é ruim; o problema é deixar seu estado aberto para ser alterado globalmente de qualquer lugar. Por isso, abusar de shared bloqueia os testes, esconde dependências e ainda causa problemas de concorrência.

Padrão Singleton no Swift: afinal, o que é?

Singleton é um padrão de projeto que garante a existência de apenas uma instância em todo o app.

No Swift, ele pode ser criado de forma extremamente curta. Essa é sua atração e também sua armadilha.

final class NetworkManager {
    static let shared = NetworkManager()  // Apenas um no app
    private init() {}                     // Bloquear criação externa
    func request(_ url: URL) { /* ... */ }
}

static let é inicializado exatamente uma vez pelo Swift de forma thread-safe. Por isso, a criação da instância é segura mesmo sem um lock separado.

Também é essencial impedir novas criações externas usando private init(). Sem isso, é apenas um objeto global, não um singleton.

Até aqui, tudo parece bem limpo. O problema começa porque ele pode ser chamado de qualquer lugar.


Por que a instância shared é considerada um antipadrão?

Vou explicar os três motivos mais apontados, na ordem em que os vivenciei.

Primeiro, os testes viram um inferno.

Se você chama NetworkManager.shared diretamente no código, não há como substituí-lo por um objeto mock durante os testes.

As requisições podem ir para o servidor real, ou os testes podem compartilhar estado; assim, basta mudar a ordem para mudar o resultado.

Segundo, as dependências ficam escondidas.

Se uma função usa UserSession.shared secretamente por dentro, sua assinatura não revela do que ela precisa.

Isso é realmente perigoso ao trabalhar em equipe, porque só é possível enxergar as relações de dependência abrindo todo o código.

Terceiro, os problemas de concorrência do estado global mutável.

Se o singleton contém propriedades e várias threads as leem e escrevem ao mesmo tempo, ocorre uma condição de corrida de dados.

A criação da instância ser segura não torna seguras as alterações no estado interno. Confundir as duas coisas pode causar crashes.

Meu código da época em que eu espalhava shared por toda parte; hoje dá até arrepios olhar
Meu código da época em que eu espalhava shared por toda parte; hoje dá até arrepios olhar

Resumindo:

O verdadeiro motivo de os singletons serem criticados não é existir apenas um, mas poder obtê-lo e alterá-lo de qualquer lugar.


Então, nunca se deve usar singleton?

Não. Ainda uso em situações específicas.

Há coisas cuja existência única é natural. Por exemplo, UserDefaults.standard, FileManager.default e URLSession.shared; a Apple também usa singletons em suas bibliotecas padrão.

É útil adotar este critério:

  • O estado quase não muda e o acesso é principalmente de leitura → singleton está ok
  • Precisa realmente existir apenas um em todo o app → considere um singleton
  • Precisa se comportar de forma diferente nos testes → recomenda-se injeção de dependência

O ponto principal é não chamar shared diretamente no código, mas recebê-lo por injeção externa.

shared como valor padrão e um fake injetado nos testes
shared como valor padrão e um fake injetado nos testes

Mesmo usando o mesmo singleton, essa mudança aumenta bastante a testabilidade.

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

final class FeedViewModel {
    private let network: Networking
    init(network: Networking = .shared) {  // O padrão é um singleton; nos testes, injete mock 
        self.network = network
    }
}

No uso normal, o singleton padrão é conveniente; nos testes, você pode inserir um objeto fake, o que traz flexibilidade.

Você não abandonou o singleton; apenas mudou a forma de chamá-lo.


3 regras que sigo na prática

Por fim, vou compartilhar os critérios que estabeleci para mim no trabalho.

  1. Não crie singletons cujo estado mutável seja alterado em vários lugares. Se o estado for necessário, deixe claro quem é seu proprietário.
  2. Não chame shared diretamente dentro de uma função; injete-o pelo inicializador ou pelos parâmetros.
  3. Singletons com acesso concorrente devem ser feitos como actor ou protegidos por uma fila serial.

Seguir apenas essas três regras quase elimina a situação em que o projeto apodrece por causa dos singletons.

Singleton não é uma ferramenta errada; é apenas tão fácil que tende a ser usado em excesso.

Antes de espalhar shared por todo lado só porque é conveniente, pergunte a si mesmo: “Vou conseguir testar isso depois?”. Essa pergunta vai salvar você daqui a 6 meses.

Continue lendo