Puddles is an app architecture that divides your codebase in 4 layers.
- The Core, that defines all the business logic of your app in an isolated local Swift package.
- The Providers, that connect the Core with the rest of your app.
- The Components, that make up the actual UI components of your app.
- The Modules, that take the UI components and populate them with data from the Providers, forming the actual screens of your app.
This article focuses on the second layer, the Provider.
In the Puddles architecture, views never interact with external data and other frameworks directly, but rather through a set of objects exposing a controlled interface that hides away any implementation details and logic specific to the nature and origin of the provided data. These objects are called Providers. They are classes conforming to ObservableObject
, allowing them to be injected into the SwiftUI environment and observed by any view in the app.
The purpose of this is to make the views fully agnostic of the origin or nature of the data they consume. It shouldn't matter for those views if the data was mocked, fetched from a backend or loaded from a local database. This is important because it allows you to preview and test each and every module in your app without ever touching the views themselves.
An app can have an arbitrary amount of Providers, depending on your needs and the complexity of your dependencies. Here are a few examples:
// Responsible for everything related to maps.@MainActor final class MapProvider: ObservableObject {}// Responsible for everything related to books.@MainActor final class BookProvider: ObservableObject {}// Responsible for everything related to authentication and user management.@MainActor final class AuthProvider: ObservableObject {}// Responsible for everything related to the app's settings.@MainActor final class SettingsProvider: ObservableObject {}// Responsible for everything related to the app's dashboard.@MainActor final class DashboardProvider: ObservableObject {}
These Providers are then injected into the SwiftUI environment from the app's entry view. This makes them available to all views in the app.
@mainstruct YourApp: App { var body: some Scene { WindowGroup { Root() .environmentObject(MapProvider.live) .environmentObject(BookProvider.live) .environmentObject(AuthProvider.live) .environmentObject(SettingsProvider.live) .environmentObject(DashboardProvider.live) } }}
And as described above, we can easily inject mock Providers into parts of the view hierarchy.
struct Root: View { var body: some View { TabView { DashboardModule() .environmentObject(AuthProvider.mock) // Overriding AuthProvider with a mock auth provider SettingsModule() } }}
With this, the DashboardModule
will behave exactly as before, but it will interact with the mocked data we gave it, which we can manipulate in any way we want.
And finally, we can use mocked instances of those Providers to enable fully interactive, working SwiftUI Previews for every Module in the app, from the smallest of screens up to the entire Root Module.
struct Dashboard_Previews: PreviewProvider { static var previews: some View { DashboardModule() .withMockProviders() // Helper type }}
This has been a huge focus in designing the architecture because it makes development so much more convenient in my opinion. Many other approaches I tried lacked exactly this ability to have usable Previews that can actually interact with mocked data in a realistic way, instead of just having a bunch of .constant()
bindings or empty action closures.
With a future update of Puddles, this will become even easier thanks to the
ability of the new Observable
types to be injected as EnvironmentValue
,
allowing automatic default values for Previews and the live app. No more
manually injecting these objects!
But how do these instances like .mock
and .live
look like? The trick is to make use of dependency injection.
The most fundamental aspect of a Provider is that it also only accesses external data through a controlled interface that can be injected upon initialization. This interface can then be used to supply live data from your backend or database, or mocked data for use in SwiftUI previews or for testing purposes.
There are a few ways to define dependencies for a Provider and there's no right or wrong way to do it. Puddles leaves this up to you. It all depends on your needs and taste. Here are two options that I usually like to use in my projects.
One way of defining dependencies is to use protocols. This is a classic way of adding customizable behavior to a type. It's similar to a delegate, just with a more concise set of responsibilities. Here's how a BookProvider
could look like using a protocol to define its dependencies.
However, there are a few downsides to using protocols. They can only be defined at the root level, therefore polluting the namespace of your app. Also, each implementation of that protocol requires writing a whole new type conforming to it, which can be a lot of tedious and painful boilerplate.
The second approach is using closures stored in properties. The great thing about this approach is that you can initialize everything inline without the need of extra types or other boilerplate. You just initialize the object and provide the body of the closures.
But this approach also has downsides, the most annoying one - in my opinion - being the poor autocomplete support for them in Xcode and the lack of named parameters within the arguments of the closure. Also, some things can just not be represented like this, for example subscripts or generic methods.
In the end, you should choose whatever you like most and you don't have to stick to one approach either. You can mix and match them as you like.
Once you have set up the dependencies for a Provider, you can simply initialize the object with whatever data sources you want. For example:
This is really neat because you can supply any kind of data from any source you want without ever touching the Provider code or the modules.
A Provider should never depend on another Provider. This makes it really difficult to use them properly in a SwiftUI environment, since overriding a specific Provider in the view hierarchy does not change the reference to that replaced Provider inside of other Providers.
In most cases, you should define dependencies within the TODO
If you cannot define the dependencies between the targets in the Core package itself, the best way to resolve them in the app is to do it on the Adapter level. You can initialize the Adapter with the individual Providers and then pass it to the Module. For more information, see below.
Next, let's talk about the actual implementation of a Provider. While this is something that's entirely up to you - there is no Provider protocol requiring you to implement specific things or anything like that - there are a few common patterns that can help to reduce frictions with SwiftUI and the general structure of your app.
Sometimes, you only need a passthrough object that provides controlled but direct access to some external data. And oftentimes, these Providers don't even have any state of their own and only expose a few methods to access the data on-demand. But they could also expose the means to create an AsyncStream
or Publisher
to allow a Module or an Adapter to observe changes over time.
However, it is also not wrong to give it internal state, or even some @Published
properties. In many cases, this can simplify the Module a lot but it depends on the situation and use case of the data and Provider.
The simple example below shows a Provider that exposes a few methods to load, add and remove books.
These Providers usually only manage raw data. In almost every nontrivial case, you would want to consume that data through a Module-specific Adapter, which can add semantics and functionality on top, like sorting, filtering or combining it with data from other providers.
Another kind of Providers are those used for a specific app-wide purpose, like an authentication state or app settings. These Providers usually don't expose raw data, but rather a limited set of published properties and actions for the modules to consume and observe.
Take the following AuthProvider
for example. Its job is to manage the authentication state of the user. It publishes a User
object that can easily be read and observed by any Module in the app. It also provides methods for login, logout or registration, making auth really convenient to use from any Module.
Lastly, you can also create Providers that are specific to a single Module. This is not always useful but some Modules need a dedicated, globally available data source that lives through the entire lifetime of the app, for example a tab view or a home screen.
Just be careful that these aren't consumed by multiple Modules at the same time. This can cause unexpected behavior and is usually not what you want.
Since Providers are mostly globally shared objects that can't cater to individual Modules, it is sometimes useful to have another object in between the two that not only prepares the data specifically for the Module and adds semantics to it, but also adds and resolves dependencies between multiple Providers. This is what Adapters can be used for.
An Adapter is really just another ObservableObject
, but its lifetime is tied to the lifetime of the Module it belongs to. It's similar to a view model but only responsible for handling data coming from one or more Providers - it is not meant to be responsible for the entire state management of the view.
However, initializing a @StateObject
with objects from the SwiftUI environment is surprisingly annoying, since said environment is only available when the lifetime of the view has started, which happens strictly after its first initialization. There are a few solutions for that and you can choose whichever you like best, but Puddles provides a component that makes this somewhat more convenient, in my opinion.
That component is called StateObjectHosting
. It is a simple view wrapper that lets you initialize an ObservableObject
from a parent view, which will then be passed in as an @ObservedObject
to the content closure. This way, you can initialize an Adapter with Providers from the environment and pass it into the Module.
Puddles defines a similar component called StateHosting
that does the same
but for anything that can be stored as a @State
. In those cases, the content
closure is passed in a binding to that state.
It will also be compatible with the new Observable
objects coming with iOS
17.
I hope this article could give a general overview over the concept of a Provider in Puddles. Fundamentally, there's nothing special about them. It's just their specific place and purpose within the architecture that makes them somewhat useful.