Published on

Introducing Puddles - A Native SwiftUI App Architecture

Meet Puddles
puddles
swiftui

There is an infinite number of ways to build an app and there is an even larger number of opinions on ways to build an app well.

It's easy to come up with an idea of the perfect app architecture, as long as your definition is abstract enough. It only ever breaks down when you try to apply the idea to a real project, and then gets obliterated when you try to apply it to multiple real projects, each with unique requirements, constraints and quirks.

There is no definitive answer to the question of what an objectively good app architecture is or should be. You can attempt to come up with one but in practice, there will always be pain points and aspects that just don't work out as expected. Whenever you start a new project, you have to make a choice on what pain points you are willing to accept, all while knowing that many of them won't even reveal themselves right at the start.

Today, I am releasing my own selection of pain points. I call it Puddles.

For the past year or two, I've been struggling to find a satisfying way of building apps in SwiftUI, without the structural support of UIKit for things like navigation and communication between components. I've tried a lot of different approaches, but none of them really appealed to to me. So, about seven months ago, I decided to try and design my own architecture.

Every project is different and unique in its own way, making it impossible to find a solution that truly works for everything. I started this project with a few core goals in mind that are important to me and that I couldn't really satisfy with any of the existing patterns.

  1. it should take minimal commitment to use Puddles. It has to be easy to integrate into existing projects and just as easy to remove if it doesn't work out.
  2. It should never restrain you. It has to be possible to deviate from the suggested patterns and techniques.
  3. It should feel like native SwiftUI with as little abstraction as possible.
  4. It should be mockable and previewable without effort, throughout every part of the app.

See, it is possible to find the (subjective) perfect solution for each and every one of these ideas. But it is really hard to find one that satisfies all of them. Puddles is my attempt at finding a compromise, suggesting an architecture as close to my personal ideal solution as possible.

I didn't want to over-engineer anything. While it is certainly possible – and absolutely valid – to solve a lot of problems and trade-offs by building layers upon layers onto what Swift and SwiftUI already provide, I wanted to stay as close to the native ecosystem as possible to not only allow for more flexibility and freedom, but to also keep everything as lightweight as possible. Right now, you could easily fork the Puddles repository and modify or maintain it yourself. It's not much code and most of it should be fairly straightforward. I would like to keep it that way, as much as possible.

Another key point in the design of Puddles was that I didn't want to build on the traditional MVVM pattern that has become quite popular with SwiftUI. I know this is highly opinionated, but strict MVVM as we know it in SwiftUI simply doesn't feel right to me. It restricts you in a lot of ways and renders many of the amazing tools that SwiftUI offers almost unusable or at least makes them very tedious to use. Extracting all the view's logic out of the View struct feels like working against the framework. My opinion about this might change over time and the good thing is that it should be relatively easy to pivot Puddles if need be. That's another reason why I designed it to be flexible and lightweight.

The way Puddles is designed has a few shortcomings. The most significant one: Unit testing. While you can test the components in the Core layer, as well as the implementation of the Providers, it becomes really hard to properly and thoroughly test Modules, since they are SwiftUI views and there's currently no way of accessing a view's state outside the SwiftUI environment. That is a trade-off you have to be willing to accept when deciding to try building an app with Puddles.

With all that said, I'd like to emphasize that Puddles might not be the best way to build your SwiftUI app and you might even lightly or strongly dislike it. It is an attempt at coming up with an alternative to traditional MVVM. You should always consider your needs, constraints and willingness to try something new and possibly risky. If you do decide to give Puddles a try, though, then I genuinely hope that you succeed in building a modular and maintainable app - and have fun along the way.

That's enough of a preamble. Let's get into the architecture itself. There will be dedicated articles for every aspect of Puddles and you can find a more thorough and detailed overview here or in the repository. The following is just meant to be a quick collection of the key ideas of the architecture.

Puddles suggests an architecture that separates your code base into 4 distinct layers, each with its own responsibilities and functions, encouraging a modular and maintainable project structure for your app.

Apps in Puddles are made up of Modules, which generally can be thought of as individual screens - for example, Home is a Module responsible for showing the home screen while Login could be responsible for a screen showing a login page. Modules are SwiftUI views, so they can be composed together in a natural and familiar way to form the overall structure of the app.


/// The Root Module - the entry point of a simple example app.
struct Root: View {
/// A global router instance that centralizes the app's navigational states for performant and convenient access across the app.
@ObservedObject var rootRouter = Router.shared.root
var body: some View {
Home()
.sheet(isPresented: $rootRouter.isShowingLogin) {
Login()
}
.sheet(isPresented: $rootRouter.isShowingNumbersExample) {
NumbersExample()
}
}
}

Modules define the screens and behavior of the app by composing simple, generic components together. They have access to the environment where they can get access to a controlled, abstract interface that drives the app's interaction with external data and other frameworks.


/// A Module rendering a screen where you can fetch and display facts about random numbers.
struct NumbersExample: View {
/// A Provider granting access to external data and other business logic around number facts.
@EnvironmentObject var numberFactProvider: NumberFactProvider
/// A local state managing the list of already fetched number facts.
@State private var numberFacts: [NumberFact] = []
// The Module's body, composing the UI and UX from various generic view components.
var body: some View {
NavigationStack {
List {
Button("Add Random Number Fact") { addRandomFact() }
Section {
ForEach(numberFacts) { fact in
NumberFactView(numberFact: fact)
}
}
}
.navigationTitle("Number Facts")
}
}
private func addRandomFact() {
Task {
let number = Int.random(in: 0...100)
try await numberFacts.append(.init(number: number, content: numberFactProvider.factAboutNumber(number)))
}
}
}

The Components layer is made up of many small, generic SwiftUI views that, put together, form the UI of your app. They don't own any data or have access to external business logic. Their only purpose is to take pieces of information and describe how they should be displayed.


/// A simple component that displays a number fact.
struct NumberFactView: View {
var numberFact: NumberFact // Data model
var body: some View {/* ... */}
}

In addition, Puddles comes with a set of tools that make it easy to add fully interactive previews to your view components.


private struct PreviewState {
var numberFact: NumberFact = .init(number: 5, content: Mock.factAboutNumber(5))
}
struct NumberFactView_Previews: PreviewProvider {
static var previews: some View {
StateHosting(PreviewState()) { $state in // Binding to the preview state
List {
NumberFactView(numberFact: state.numberFact)
Section {/* Debug Controls ... */}
}
}
}
}

The Providers drive the app's interaction with external data and other frameworks by exposing a controlled and stable interface to the Modules. This fully hides any implementation details and logic specific to the nature and origin of the provided data, allowing you to swap dependencies without ever touching the Modules relying on them.


@MainActor final class NumberFactProvider: ObservableObject {
struct Dependencies {
var factAboutNumber: (_ number: Int) async throws -> String
}
private let dependencies: Dependencies
init(dependencies: Dependencies) {/* ... */}
// The views only ever use the public interface and know nothing about the dependencies
func factAboutNumber(_ number: Int) async throws -> String {
try await dependencies.factAboutNumber(number)
}
}

Using this dependency injection, you have full control over what data the Provider is distributing to the app. And since they are distributed through the SwiftUI environment, you can inject different dependencies into any part of your view hierarchy, including mocked variants to enable fully interactive previews.

NumberFactProvider.swift
App.swift
Root.swift
Copy

extension NumberFactProvider {
static var mock: NumberFactProvider = {/* Provide mocked data */}()
static var live: NumberFactProvider = {
let numbers = Numbers() // From the Core Swift package
return .init(
dependencies: .init(factAboutNumber: { number in
try await numbers.factAboutNumber(number)
})
)
}()
}

The Core layer forms the backbone of Puddles. It is implemented as a local Swift package that contains the app's entire business logic in the form of (mostly) isolated components, divided into individual targets. Everything that is not directly related to the UI belongs in here, encouraging building modular types that are easily and independently modifiable and replaceable.


let package = Package(
name: "Core",
dependencies: [/* ... */],
products: [/* ... */],
targets: [
.target(name: "Models"), // App Models
.target(name: "Extensions"), // Useful extensions and helpers
.target(name: "MockData"), // Mock data
.target(name: "BackendConnector", dependencies: ["Models"]), // Connects to a backend
.target(name: "LocalStore", dependencies: ["Models"]), // Manages a local database
.target(name: "CultureMinds", dependencies: ["MockData"]), // Data Provider for Iain Banks's Culture book universe
.target(name: "NumbersAPI", dependencies: ["MockData", "Get"]) // API connector for numbersAPI.com
]
)

You can build targets that connect to your backend, local database or any external framework dependency and provide an interface for the app to connect to them.


import Get // https://github.com/kean/Get
/// Fetches random facts about numbers from https://numbersapi.com
public final class Numbers {
private let client: APIClient
public init() {/* ... */}
public func factAboutNumber(_ number: Int) async throws -> String {
let request = Request<String>(path: "/\(number)")
return try await client.send(request).value
}
}

Puddles has more to offer, like Routers, Queryables and Signals that I will all introduce in separate articles. But for now, I hope this gives you a good overview of what Puddles is and how it can help you build better apps.

Puddles 1.0.0 is just the beginning of the development. There are a lot of things I would like to add and improve over time. The next step will be evaluating how the release of iOS 17 and Swift 5.9 can improve the way you work with Puddles through Macros and more. I don't have any final features planned, but there are a few things I would like to explore in the near future.