Por que pensar em arquitetura desde o começo
Em um app pequeno, é comum colocar tudo na mesma View: chamadas de rede, validação, persistência e regras de negócio. Funciona no início, mas rapidamente surgem problemas: código difícil de testar, telas “inchadas”, dependências espalhadas e mudanças simples que quebram partes inesperadas. Uma arquitetura inicial não precisa ser complexa; ela só precisa deixar claro onde cada tipo de código mora e como as partes conversam.
Neste capítulo, vamos organizar um projeto SwiftUI em camadas simples: Views, ViewModels, Models, Services e Resources. Em seguida, vamos refatorar um mini-projeto (com lista de itens, carregamento remoto e persistência local) movendo responsabilidades para as camadas corretas e adicionando injeção simples de dependências via Environment e inicializadores.
Estrutura de pastas sugerida (simples e escalável)
Uma estrutura inicial clara para iniciantes pode ser assim:
MyApp/ ├─ App/ │ ├─ MyAppApp.swift │ └─ AppContainer.swift ├─ Features/ │ └─ Items/ │ ├─ Views/ │ ├─ ViewModels/ │ ├─ Models/ │ └─ Services/ ├─ Shared/ │ ├─ Networking/ │ ├─ Persistence/ │ ├─ UIComponents/ │ └─ Extensions/ └─ Resources/ ├─ Assets.xcassets ├─ Localizable.strings └─ Preview Content/Você pode começar sem Features/ e criar depois, mas ele ajuda muito quando o app cresce: cada funcionalidade fica “fechada” em um lugar.
Responsabilidade de cada pasta/camada
| Camada | Responsabilidade | Evitar |
|---|---|---|
| Views | UI e interação (botões, listas, navegação). Chama ações do ViewModel. | Fazer URLSession, ler/gravar dados, regras complexas. |
| ViewModels | Estado da tela, transformação de dados para a UI, coordena chamadas a Services. | Conhecer detalhes de UI (cores, fontes) e persistência “na mão”. |
| Models | Estruturas de dados (DTOs, entidades), validações simples, tipos. | Depender de SwiftUI ou de Views. |
| Services | Integrações: rede, persistência, analytics. Expor protocolos para desacoplar. | Referenciar Views/ViewModels diretamente. |
| Resources | Assets, strings, arquivos de preview, mocks estáticos. | Colocar lógica de app aqui. |
Como evitar acoplamento (regras práticas)
1) Views não conhecem “como” os dados são obtidos
A View deve saber apenas: “tenho um ViewModel com estado e ações”. Ela não deve saber se os dados vêm de API, cache, banco local ou mock.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
2) Dependa de protocolos, não de implementações
Quando o ViewModel depende de um serviço, ele deve depender de um protocolo. Assim você troca a implementação (real vs mock) sem mexer no ViewModel.
protocol ItemsFetching { func fetchItems() async throws -> [Item]}3) Um sentido de dependência
Uma direção saudável é: View → ViewModel → Services → (Networking/Persistence). Models podem ser usados por todos, mas não devem importar SwiftUI.
4) Estado e efeitos colaterais separados
O ViewModel guarda estado (ex.: items, isLoading, errorMessage) e dispara efeitos (carregar, salvar, remover) chamando Services. Services fazem o trabalho “sujo” (rede/persistência).
Refatoração prática do mini-projeto (passo a passo)
A seguir, um roteiro para pegar um app que hoje tem lógica de rede e persistência “dentro da View” e organizar em camadas. Adapte os nomes ao seu mini-projeto.
Passo 1) Criar a pasta Feature e mover a View
Crie Features/Items/Views e mova sua tela principal (ex.: ItemsListView.swift) para lá. Não altere comportamento ainda; apenas mova.
Passo 2) Extrair Models (dados) para uma pasta dedicada
Crie Features/Items/Models e mova/centralize seus modelos. Exemplo:
import Foundationstruct Item: Identifiable, Codable, Equatable { let id: UUID let title: String let detail: String?}Se você consome uma API, pode separar DTO do modelo de UI. Para começar, você pode usar o mesmo tipo se estiver simples, mas mantenha a possibilidade de evoluir.
Passo 3) Criar Services com protocolos (rede e persistência)
Crie Features/Items/Services. Vamos separar em dois serviços: um para buscar itens e outro para salvar/carregar localmente. Comece pelos protocolos:
import Foundationprotocol ItemsFetching { func fetchItems() async throws -> [Item]}protocol ItemsStoring { func load() throws -> [Item] func save(_ items: [Item]) throws}Agora crie implementações. Exemplo de serviço de rede (simplificado):
import Foundationfinal class ItemsAPIService: ItemsFetching { private let baseURL: URL init(baseURL: URL) { self.baseURL = baseURL } func fetchItems() async throws -> [Item] { let url = baseURL.appendingPathComponent("items") let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode([Item].self, from: data) }}E um armazenamento simples (ex.: arquivo JSON no Documents). Isso mantém persistência fora da View:
import Foundationfinal class ItemsFileStore: ItemsStoring { private let fileURL: URL init(filename: String = "items.json") { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! self.fileURL = docs.appendingPathComponent(filename) } func load() throws -> [Item] { guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } let data = try Data(contentsOf: fileURL) return try JSONDecoder().decode([Item].self, from: data) } func save(_ items: [Item]) throws { let data = try JSONEncoder().encode(items) try data.write(to: fileURL, options: [.atomic]) }}Se seu mini-projeto já usa UserDefaults/SwiftData, a ideia é a mesma: encapsule em um tipo que implemente ItemsStoring.
Passo 4) Criar o ViewModel e mover a lógica para ele
Crie Features/Items/ViewModels e adicione um ViewModel que coordena busca e cache local. Ele recebe dependências por inicializador (injeção simples).
import Foundationimport Observation@MainActor@Observablefinal class ItemsListViewModel { private let fetcher: ItemsFetching private let store: ItemsStoring var items: [Item] = [] var isLoading = false var errorMessage: String? = nil init(fetcher: ItemsFetching, store: ItemsStoring) { self.fetcher = fetcher self.store = store } func onAppear() { do { items = try store.load() } catch { errorMessage = "Não foi possível carregar o cache local." } Task { await refresh() } } func refresh() async { isLoading = true defer { isLoading = false } do { let remote = try await fetcher.fetchItems() items = remote try store.save(remote) } catch { errorMessage = "Falha ao atualizar itens." } }}Observe o que mudou: a View não sabe mais sobre URL, JSON, arquivo, UserDefaults ou SwiftData. Ela só chama onAppear() e refresh().
Passo 5) Ajustar a View para usar o ViewModel (sem acoplamento)
Agora a View fica focada em UI. Exemplo:
import SwiftUIstruct ItemsListView: View { @State private var viewModel: ItemsListViewModel init(viewModel: ItemsListViewModel) { _viewModel = State(initialValue: viewModel) } var body: some View { List(viewModel.items) { item in VStack(alignment: .leading) { Text(item.title) if let detail = item.detail { Text(detail).font(.subheadline).foregroundStyle(.secondary) } } } .overlay { if viewModel.isLoading { ProgressView() } } .alert("Erro", isPresented: .constant(viewModel.errorMessage != nil)) { Button("OK") { viewModel.errorMessage = nil } } message: { Text(viewModel.errorMessage ?? "") } .task { viewModel.onAppear() } .refreshable { await viewModel.refresh() } }}Se você preferir, pode usar @StateObject com ObservableObject. O importante é manter a dependência via inicializador e não criar serviços dentro da View.
Injeção simples de dependências (Environment e AppContainer)
Para não instanciar serviços em cada tela manualmente, crie um “container” de dependências no app. Isso não é um framework; é só um objeto que guarda as implementações.
Passo 1) Criar um container
Em App/AppContainer.swift:
import Foundationfinal class AppContainer { let itemsFetcher: ItemsFetching let itemsStore: ItemsStoring init(itemsFetcher: ItemsFetching, itemsStore: ItemsStoring) { self.itemsFetcher = itemsFetcher self.itemsStore = itemsStore } static func live() -> AppContainer { AppContainer( itemsFetcher: ItemsAPIService(baseURL: URL(string: "https://example.com")!), itemsStore: ItemsFileStore() ) }}Passo 2) Expor o container no Environment
No arquivo do app (MyAppApp.swift), injete o container:
import SwiftUI@mainstruct MyAppApp: App { private let container = AppContainer.live() var body: some Scene { WindowGroup { RootView() .environment(container) } }}Para isso funcionar, use o novo padrão de Environment com tipos (iOS 17+). Em versões anteriores, você pode usar EnvironmentObject. Exemplo com iOS 17+:
import SwiftUIextension EnvironmentValues { @Entry var container: AppContainer = .live()}Se preferir manter explícito, você pode evitar EnvironmentValues e passar o container por inicializador na RootView.
Passo 3) Montar o ViewModel na borda (composition root)
A “borda” do app (RootView) é um bom lugar para criar ViewModels com dependências reais. Exemplo:
import SwiftUIstruct RootView: View { @Environment(AppContainer.self) private var container var body: some View { ItemsListView( viewModel: ItemsListViewModel( fetcher: container.itemsFetcher, store: container.itemsStore ) ) }}Assim, a tela continua testável e desacoplada: ela recebe um ViewModel pronto.
Preparando o código para crescer: padrões simples
Organize por Feature quando houver mais de uma tela
Quando a funcionalidade “Items” tiver detalhes, criação/edição, filtros etc., mantenha tudo dentro de Features/Items e crie subpastas por tela se necessário:
Features/Items/ ├─ ItemsList/ │ ├─ ItemsListView.swift │ └─ ItemsListViewModel.swift ├─ ItemDetail/ │ ├─ ItemDetailView.swift │ └─ ItemDetailViewModel.swift ├─ Models/ └─ Services/Crie serviços pequenos e focados
Em vez de um “MegaService”, prefira serviços com uma responsabilidade: ItemsAPIService, AuthService, ProfileStore. Isso reduz acoplamento e facilita testes.
Use protocolos para permitir mocks em Preview e testes
Crie implementações fake para Previews:
struct ItemsFetcherMock: ItemsFetching { let result: [Item] func fetchItems() async throws -> [Item] { result }}final class ItemsStoreMock: ItemsStoring { private var memory: [Item] = [] func load() throws -> [Item] { memory } func save(_ items: [Item]) throws { memory = items }}Em Preview, injete o ViewModel com mocks:
#Preview { ItemsListView( viewModel: ItemsListViewModel( fetcher: ItemsFetcherMock(result: [ Item(id: UUID(), title: "Exemplo", detail: "Preview") ]), store: ItemsStoreMock() ) )}Evite “import SwiftUI” fora de Views/ViewModels
Models e Services devem depender de Foundation sempre que possível. Isso mantém o núcleo do app mais reutilizável e testável.
Checklist rápido de refatoração (para aplicar no seu projeto)
- Criei pastas:
Views,ViewModels,Models,Services,Resources(e opcionalmenteFeatureseShared). - Views não fazem rede nem persistência.
- ViewModels dependem de protocolos (ex.:
ItemsFetching,ItemsStoring). - Services implementam esses protocolos e encapsulam detalhes (URLSession, arquivo, UserDefaults, SwiftData).
- Dependências são criadas na borda do app (Root/App) e injetadas via inicializador ou
Environment. - Previews usam mocks para não depender de internet ou dados reais.