Injeção de dependência em Swift sem framework

5 minswiftarquiteturatestes

Não comecei esse projeto pensando em “preciso de um sistema de injeção de dependência”. Comecei com uma tela de contas, um AccountsViewModel, e um repositório instanciado dentro do próprio init.

final class AccountsViewModel: ObservableObject {
    @Published var accounts: [Account] = []
    private let repository = RemoteAccountsRepository(client: URLSessionHTTPClient(baseURL: .production))

    func load() async {
        accounts = (try? await repository.fetchAccounts()) ?? []
    }
}

Funcionava. Só que testar isso significava bater na rede de verdade, ou subir um servidor local só pra rodar um teste unitário. Nenhuma das duas opções cabia no que eu queria, que era rodar o teste em segundos, sem depender de nada externo.

A correção óbvia trouxe outro problema

Tirei o repositório concreto de dentro do ViewModel e passei a receber ele pronto, por trás de um protocolo.

protocol AccountsRepository {
    func fetchAccounts() async throws -> [Account]
}

final class AccountsViewModel: ObservableObject {
    @Published var accounts: [Account] = []
    private let repository: AccountsRepository

    init(repository: AccountsRepository) {
        self.repository = repository
    }

    func load() async {
        accounts = (try? await repository.fetchAccounts()) ?? []
    }
}

Isso resolveu o teste dessa tela. O problema é que agora alguém precisa montar um AccountsRepository de verdade e entregar pro init, em algum lugar do app. Com uma tela só, dá pra fazer isso na mão, direto na View. Com quinze telas, cada uma com duas ou três dependências, isso vira repetitivo rápido, e cada lugar que monta a dependência tem chance de montar diferente do outro.

A primeira tentativa foi um Container com dicionário

Copiei o que frameworks como Swinject fazem, só que na mão. Um Container com um dicionário de fábricas, registrado por tipo:

final class Container {
    private var factories: [String: () -> Any] = [:]

    func register<T>(_ type: T.Type, factory: @escaping () -> T) {
        factories[String(describing: type)] = factory
    }

    func resolve<T>(_ type: T.Type = T.self) -> T {
        guard let factory = factories[String(describing: type)] else {
            fatalError("Nada registrado para \(type)")
        }
        return factory() as! T
    }
}

No começo pareceu flexível. Registrava tudo no AppDelegate, resolvia onde precisava. O problema apareceu numa tela que eu tinha acabado de adicionar: esqueci de registrar o NotificationsRepository e só descobri isso abrindo a tela pelo simulador. O app crashava no fatalError, sem nenhum aviso antes disso.

Dava pra evitar esquecendo menos. Mas o ponto é que o compilador não ajudava em nada ali. resolve<T>() aceita qualquer tipo, pra qualquer coisa, e só descobre se existe implementação quando alguém chama. Isso é delegar pro runtime um erro que o Swift consegue pegar em compile time, se você deixar o tipo fazer esse trabalho.

Trocando o dicionário por propriedades de verdade

A mudança foi trocar o Container genérico por um objeto comum, com uma propriedade por dependência, tipada de verdade. Sem String(describing:), sem as!, sem registro dinâmico.

final class AppDependencies {
    private let baseURL: URL

    init(baseURL: URL) {
        self.baseURL = baseURL
    }

    lazy var httpClient: HTTPClient = URLSessionHTTPClient(baseURL: baseURL)
    lazy var accountsRepository: AccountsRepository = RemoteAccountsRepository(client: httpClient)
    lazy var notificationsRepository: NotificationsRepository = RemoteNotificationsRepository(client: httpClient)

    func makeAccountsViewModel() -> AccountsViewModel {
        AccountsViewModel(repository: accountsRepository)
    }

    func makeNotificationsViewModel() -> NotificationsViewModel {
        NotificationsViewModel(repository: notificationsRepository)
    }
}

lazy var cuida da ordem de criação sozinho. accountsRepository só instancia httpClient na primeira vez que alguém acessa, e as chamadas seguintes reaproveitam a mesma instância. Não precisei escrever essa ordem na mão em lugar nenhum.

E se eu esquecer de criar o NotificationsRepository? makeNotificationsViewModel() nem compila, porque notificationsRepository não existe ali dentro. O erro migrou do simulador pro Xcode, antes de eu rodar qualquer coisa.

Com poucas dependências dá pra acompanhar esse grafo só lendo o código. Quando cresce, ajuda desenhar:

flowchart LR
    AppDependencies --> HTTPClient
    HTTPClient --> AccountsRepository
    HTTPClient --> NotificationsRepository
    AccountsRepository --> AccountsViewModel
    NotificationsRepository --> NotificationsViewModel

Onde a injeção manual começa a doer

Isso resolveu o problema que eu tinha, mas trouxe outro, que só apareceu depois de um tempo.

AppDependencies foi crescendo. Cada tela nova significava uma propriedade nova ali dentro, então virou o arquivo que todo mundo mexia. Dois PRs tocando AppDependencies ao mesmo tempo davam conflito de merge quase sempre na mesma região do arquivo.

Também não tem escopo automático. lazy var dá uma instância compartilhada, reaproveitada em todo acesso. Se eu quisesse uma instância nova a cada tela, pra não vazar estado entre navegações diferentes, precisava trocar lazy var por um método make...() que cria uma instância nova a cada chamada. Essa escolha eu faço caso a caso, olhando pra cada dependência, sem nenhuma ferramenta avisando se acertei ou errei.

Quando entrou um feature flag trocando RemoteAccountsRepository por uma versão com cache local, o init de AppDependencies ganhou um if. Um só, não incomodou. Mas dava pra imaginar esse arquivo virando uma sequência de ifs se o número de flags crescesse.

Testando sem precisar de mock

O motivo original de eu ter feito tudo isso era testar o AccountsViewModel sem bater na rede. Com o protocolo já separado, o teste nem passa perto de AppDependencies.

struct FakeAccountsRepository: AccountsRepository {
    var result: Result<[Account], Error> = .success([])

    func fetchAccounts() async throws -> [Account] {
        try result.get()
    }
}

func test_load_publishesFetchedAccounts() async {
    let fake = FakeAccountsRepository(result: .success([Account(id: "1", balance: 100)]))
    let sut = AccountsViewModel(repository: fake)

    await sut.load()

    XCTAssertEqual(sut.accounts.count, 1)
}

Não tem biblioteca de mock aqui. FakeAccountsRepository é uma struct comum, conformando ao mesmo protocolo que a implementação real usa. O teste monta o AccountsViewModel direto, sem passar por container nenhum.

Isso só funciona porque AccountsViewModel recebe AccountsRepository pelo init e não sai buscando ele em lugar nenhum. Se em algum momento o ViewModel puxasse a dependência de dentro de um container global, esse teste precisaria subir esse container inteiro só pra testar uma tela.

Quando eu voltaria a usar um framework de DI

Não acho que montar isso na mão seja melhor do que usar uma lib pronta. Pra esse projeto, com um número pequeno de telas e um time pequeno, o AppDependencies cobria o que eu precisava e o custo de manter aquilo era baixo.

Se o grafo de dependências ficasse bem maior, ou se eu precisasse trocar implementação em runtime, não em build, e sim baseado numa flag remota carregada depois do app já estar rodando, a resolução em compile time deixa de ajudar. Nesse cenário o container dinâmico, com os problemas que descrevi lá em cima, volta a fazer sentido, porque o que ele perde em segurança de tipo ele ganha em flexibilidade.

Também não cheguei a resolver o problema do arquivo único crescendo. Dava pra quebrar AppDependencies em containers menores, um por módulo, e compor eles depois. Não precisei disso nesse projeto, mas é o próximo ponto que eu mexeria se o app continuasse crescendo.