Arquitetura inicial para apps iOS com SwiftUI: organização de pastas e camadas

Capítulo 10

Tempo estimado de leitura: 8 minutos

+ Exercício

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

CamadaResponsabilidadeEvitar
ViewsUI e interação (botões, listas, navegação). Chama ações do ViewModel.Fazer URLSession, ler/gravar dados, regras complexas.
ViewModelsEstado 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”.
ModelsEstruturas de dados (DTOs, entidades), validações simples, tipos.Depender de SwiftUI ou de Views.
ServicesIntegrações: rede, persistência, analytics. Expor protocolos para desacoplar.Referenciar Views/ViewModels diretamente.
ResourcesAssets, 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.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

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 opcionalmente Features e Shared).
  • 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.

Agora responda o exercício sobre o conteúdo:

Ao organizar um app SwiftUI em camadas para reduzir acoplamento e facilitar testes, qual prática mantém a View focada apenas em UI e evita que ela saiba de onde os dados vêm (API, cache ou mock)?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

A View deve apenas renderizar a UI e disparar ações do ViewModel. O ViewModel coordena serviços (via protocolos) e as dependências são criadas na borda do app e injetadas, evitando que a View conheça rede ou persistência.

Próximo capitúlo

Acessibilidade no iOS com SwiftUI: VoiceOver, Dynamic Type e contraste

Arrow Right Icon
Capa do Ebook gratuito iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store
71%

iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store

Novo curso

14 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.