As SwiftUI starts to make its way into production apps, the architectural debates are brewing again. It’s still early to know what works yet in SwiftUI because it takes at least a year of aging for cracks in the architecture to show. We’re a long way from that since the majority of enterprises won’t be dropping iOS 12 for another couple years.
However, with the S.O.L.I.D. principles and Clean Architecture under our belt, we can at least experiment and get a head start on the next era of mobile development. In this post, I’d like to share my attempt in creating a scalable app in SwiftUI, which is still a work in progress. My hope is the Swift community will either evolve it or shoot it down early so we can continue our quest together.
Redux Anyone?
We rarely have to reinvent the wheel… we can stand on the shoulders of giants and pick something that works in a similar paradigm. For SwiftUI, one thing for sure is that a data-driven architecture is the way to go. Redux, based on the Elm architecture, fits so well with SwiftUI’s data-driven approach. In fact, many of the slides in WWDC 2019 resemble Redux architecture tremendously, even if Apple will never admit it. Let’s have a look.
It all starts with Elm in 2012. I suggest you briefly read the Elm documentation. It’s an easy read and quite fascinating how simple, elegant, yet powerful the architecture is. The flow is update > model > view:

Then Redux enters the scene in 2015, which was based on Facebook’s Flux, which is all inspired from Elm. Here’s what the Redux diagram looks like:

Same idea, but Redux added a reducer layer in between the action and state to manage complexity at scale. The flow is the same though.. action > state > view. Now let’s see the slide from the WWDC 2019 / Data Flow Through SwiftUI talk:

Apart from Apple doing its eye-candy thing, its the same flow.. action > state > view. There is another very important, underlying concept in all these cases, unidirectional flow!
Now that we see Redux is the new MVC for Apple, I ran with this approach 👨💻
In the Details
These are super simplistic diagrams that give you a high level overview of how the data flows through the views, but that’s not even half the story. Most architectural debates I’ve seen only talk about how the view is rendered… MVC, MVVM, Redux, etc. How about the data persistence layer or dependency injection? For my implementation, I wanted to illustrate a real-world example with all the details involved to make a real app, not just Mickey Mouse apps that we see time and time again. I fleshed out the details and mirrored actual code.
I divided my architecture into the following components:
- Core: Dependency Injection
- State: Data Storage
- Render: Reactive Views
Together, these 3 components make up the architecture I’m proposing here. Where these components live are also part of the architecture. The data pieces reside in the Swift Package, and the view pieces are in the App Project. Decoupling these groups from each other allow them to evolve independently or even serve different needs. For example, the Swift Package that deal with data can be placed into a non-Redux app, or even multiple platforms like watchOS, macOS, etc. Let’s get started.
The Whole Enchilada
Before diving into the code, the entire architecture is diagramed below. It’s very detailed, but mirrors actual code I link to at the end.

I’ll explain each part. I put much detail in the diagram to illustrate the exact code, which can be put side-by-side to follow it.
Dependency Injection
If you’ve read my previous posts, you’ll know I’m a big advocate of dependency injection. It decouples your code from each other and from 3rd party libraries, it makes testing so easy and pleasant, and so on.
Simply put, the dependency injection is a factory for instances. In this implementation, the dependency is a container of functions that resolve protocols to concrete types:
// In the Swift Package
public protocol NewsCore {
func dependency() -> ConstantsType
func dependencyStore() -> ConstantsStore
func dependency() -> DataProviderType
func dependency() -> SeedStore
func dependency() -> RemoteStore
func dependency() -> CacheStore
func dependency() -> ArticleProviderType
func dependency() -> ArticleCache
func dependency() -> ArticleRemote
func dependency() -> NetworkServiceType
func dependency() -> NetworkStore
func dependency() -> LogProviderType
func dependency() -> [LogStore]
func dependency() -> Theme
}
public extension NewsCore {
func dependency() -> DataProviderType {
DataProvider(
constants: dependency(),
cacheStore: dependency(),
seedStore: dependency(),
remoteStore: dependency(),
log: dependency()
)
}
func dependency() -> RemoteStore {
RemoteNetworkStore(
articleRemote: dependency(),
log: dependency()
)
}
func dependency() -> CacheStore {
CacheDiskStore(
fileManager: dependency(),
jsonDecoder: dependency(),
jsonEncoder: dependency(),
constants: dependency(),
log: dependency()
)
}
}
The consumers can conform to this dependency container protocol and override some of the factory functions if needed with their own concrete types:
// In the app project
struct AppCore: NewsCore {
func dependency() -> CacheStore {
CacheRealmStore(
constants: dependency(),
log: dependency()
)
}
}
Then to actually use the dependency container, we have to create it in a root composition we define; an area where all instances are created from. This can be known as the environment components and we’ll place this in the AppDelegate
since it’s the first place where any new dev will look:
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
private lazy var dataProvider: DataProviderType = core.dependency()
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
dataProvider.configure()
dataProvider.pull()
}
}
// MARK: - Environment Components
private enum Root {
/// Root dependency injection container
static let core = AppCore()
}
private extension UIApplicationDelegate {
var core: NewsCore { Root.core }
}
Since the core package uses this dependency container in its own internal logic, the app injects concrete types all the way inside the package without the package knowing what the concrete type is. In this example, AppCore
swapped out the cache storage from disk-based to a Realm database. And the Swift Package did not have to take on a dependency on Realm or know anything about it. It resolves the type based on the protocol and uses it like it always did before.
Data Cache Architecture
Data management is the next part of the architecture. Having offline cache is a common use case for the majority of apps. This would have to be adjusted to work for financial apps or situations where you need real-time data all the time. This implementation is for cases where eventual data consistency is ok and a better user experience for offline or slow internet.
The Swift Package manages the cache-first approach of always providing data to the app instantly. The idea is cache would be served to the caller immediately, then passively call the remote network for any updates behind the scenes. A diff occurs to see if new updates exist so it can double-refresh the caller if needed, or ignore if the data is the same.
First the cache is fed an embedded seed of data if the cache is empty. This can be configurations or even the last 2 years of blog posts so the app would not have to request history on first launch. That way, even if first launch happens while offline, the app is still usable.
Next thing that occurs is how the cache is kept up to date. This part happens by the cache persisting a timestamp of when the last update was requested from the remote network. When it requests updates from the network, it provides this timestamp to request any data that has been modified or created after this date. This would require tweaks from your backend devs to accommodate this flow, such as if there are no updates after this “last updated at” timestamp, a 304 Not Modified
HTTPS status code is returned. What’s nice about this if there is no updates, there’s nothing to parse, saving data usage and CPU.
This data sync between seed > cache > remote is abstracted behind a Data Provider
. What happens behind it can be swapped out for different cache or remote repositories using dependency injection in the previous section.
The DataProvider
juggles the seed, cache, and remote stores to handle the syncing between them. It’s a bit algorithmic, but its less than 200 lines of code:
public struct DataProvider: DataProviderType {
private let constants: ConstantsType
private let cacheStore: CacheStore
private let seedStore: SeedStore
private let remoteStore: RemoteStore
private let log: LogProviderType
init(constants: ConstantsType, cacheStore: CacheStore, seedStore: SeedStore, remoteStore: RemoteStore, log: LogProviderType) {
self.constants = constants
self.cacheStore = cacheStore
self.seedStore = seedStore
self.remoteStore = remoteStore
self.log = log
}
}
public extension DataProvider {
func configure() {
cacheStore.configure()
seedStore.configure()
remoteStore.configure()
}
func reset() {
cacheStore.delete()
}
}
public extension DataProvider {
// Handle simultanuous update requests in a queue
private static let queue = DispatchQueue(label: "\(DispatchQueue.labelPrefix).DataProvider.sync")
private static var tasks = [((Result) -> Void)]()
private static var isUpdating = false
func pull(completion: ((Result) -> Void)?) {
Self.queue.async {
if let completion = completion {
Self.tasks.append(completion)
}
guard !Self.isUpdating else {
self.log.info("Data pull already in progress, queuing...")
return
}
Self.isUpdating = true
self.log.info("Data pull requested...")
// Determine if cache seeded before or just get latest from remote
guard let lastUpdatedAt = self.cacheStore.lastUpdatedAt else {
self.log.info("Seeding cache storage first time begins...")
self.seedFromLocal()
return
}
self.log.info("Write remote into cache storage begins, last updated at \(lastUpdatedAt)...")
self.seedFromRemote(after: lastUpdatedAt)
}
}
}
// MARK: - Helpers
private extension DataProvider {
func seedFromLocal() {
seedStore.fetch {
guard case .success(let local) = $0, !local.isEmpty else {
self.log.error("Failed to retrieve seed data, falling back to remote server...")
let request = DataAPI.RemoteRequest()
self.remoteStore.fetchLatest(after: nil, with: request) {
guard case .success(let value) = $0 else {
self.executeTasks($0)
return
}
self.log.debug("Found \(value.articles.count) articles to remotely write into cache storage.")
let request = DataAPI.CacheRequest(payload: value, lastUpdatedAt: Date())
self.cacheStore.createOrUpdate(with: request, completion: self.executeTasks)
}
return
}
self.log.debug("Found \(local.articles.count) articles to seed into cache storage.")
let lastSeedDate = local.articles.compactMap { $0.publishedAt }.max() ?? Date()
let request = DataAPI.CacheRequest(payload: local, lastUpdatedAt: lastSeedDate)
self.cacheStore.createOrUpdate(with: request) {
guard case .success = $0 else {
self.executeTasks($0)
return
}
self.log.debug("Seeding cache storage complete, now updating from remote storage.")
// Fetch latest beyond seed
let request = DataAPI.RemoteRequest()
self.remoteStore.fetchLatest(after: lastSeedDate, with: request) {
guard case .success(let remote) = $0 else {
self.executeTasks(.success(local))
return
}
self.log.debug("Found \(remote.articles.count) articles to remotely write into cache storage.")
let request = DataAPI.CacheRequest(payload: remote, lastUpdatedAt: Date())
self.cacheStore.createOrUpdate(with: request) {
guard case .success = $0 else {
self.executeTasks(.success(local))
return
}
let combinedPayload = CorePayload(
articles: local.articles + remote.articles
)
self.log.debug("Seeded \(local.articles.count) articles from local "
+ "and \(remote.articles.count) articles from remote into cache storage.")
self.executeTasks(.success(combinedPayload))
}
}
}
}
}
}
private extension DataProvider {
func seedFromRemote(after date: Date) {
let request = DataAPI.RemoteRequest()
remoteStore.fetchLatest(after: date, with: request) {
guard case .success(let value) = $0 else {
self.executeTasks($0)
return
}
self.log.debug("Found \(value.articles.count) articles to remotely write into cache storage.")
let request = DataAPI.CacheRequest(payload: value, lastUpdatedAt: Date())
self.cacheStore.createOrUpdate(with: request, completion: self.executeTasks)
}
}
}
private extension DataProvider {
func executeTasks(_ result: Result) {
Self.queue.async {
let tasks = Self.tasks
Self.tasks.removeAll()
Self.isUpdating = false
self.log.info("Data pull request complete, now executing \(tasks.count) queued tasks...")
DispatchQueue.main.async {
tasks.forEach { $0(result) }
}
}
}
}
Consumers will have to get the DataProvider
instance and call pull
to do the syncing. This should be done early on in the app lifecycle:
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
private lazy var dataProvider: DataProviderType = core.dependency()
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
dataProvider.configure()
dataProvider.pull()
}
}
The pull
is also thread-safe so it can be called by every sub-provider, like ArticleProvider
, FavoriteProvider
, etc so that every model gets this cache update mechanism for free. This is why the pull
is appending tasks into an array while it is locked, so requests from sub-providers get queued up until the cache gets updated.
At this point, the infrastructure is there for keeping the cache up to date. Binding the DataProvider
to the AppState
observable is still a work in progress and should be lazy loaded as needed. In the meantime, we will place the global state into the Root
enum we’ve defined in the AppDelegate
:
// AppState.swift
class AppState: StateType, ObservableObject {
@Published var articles: [Article] = []
@Published var favorites: [String] = []
}
...
// ApplicationDelegate.swift
...
private enum Root {
/// Root dependency injection container
static let core = AppCore()
/// Root application storage
static let state = AppState()
}
...
We are just instantiating an observable object of the state so it can automatically bind to the views à la SwiftUI.
Reactive Views Architecture
Now that we have the Core and State components, it’s time to feed them to the Render component for the final step.
The scene render is responsible for constructing views. It assembles all the piece together needed for the view: model, action, reducer. The views never know about each other, nor do they create other views directly. Every view has to ask the SceneRender
to construct the view they are requesting. The render component centralizes how views are created. It can be thought of as a dependency injection for views.
How does the render component look like? First we must combine the AppCore
and AppState
to create the render component. We’ll again put this into the root composition and expose to the UISceneDelegate
:
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
...
}
// MARK: - Environment Components
private enum Root {
/// Root dependency injection container
static let core = AppCore()
/// Root application storage
static let state = AppState()
/// Root builder for all scenes.
///
/// NavigationView(
/// render.listArticles()
/// )
///
/// Create views only through scene renders.
static let render = SceneRender(
core: core,
state: state
)
}
private extension UIApplicationDelegate {
var core: NewsCore { Root.core }
}
extension UISceneDelegate {
var render: SceneRender { Root.render }
}
This is what the SceneRender
looks like to assemble views:
/// Root composer used to construct all views.
struct SceneRender {
private let core: NewsCore
private let state: AppState
init(core: NewsCore, state: AppState) {
self.core = core
self.state = state
}
}
extension SceneRender {
func listArticles() -> some View {
ListArticlesView(
// Expose only some of the state by wrapping it
model: ListArticlesModel(parent: state),
// Views use this to dispatch actions to the reducer
action: ListArticlesActionCreator(
articleProvider: core.dependency(),
dispatch: action(to: ListArticlesReducer())
),
// Expose only some of the scene render by wrapping it
render: ListArticlesRender(parent: self)
)
}
}
Below is the SwiftUI view object it’s constructing:
struct ListArticlesView: View {
@ObservedObject var model: ListArticlesModel
let action: ListArticlesActionCreator?
let render: ListArticlesRender?
var body: some View {
List(model.articles) { article in
NavigationLink(
destination: self.render?
.showArticle(id: article.id)
) {
Text(article.title)
.font(.body)
}
}
.navigationBarTitle(Text("News"))
.navigationBarItems(trailing:
Button(
action: {
self.model.articles.isEmpty
? self.action?.fetchArticles()
: self.action?.clearArticles()
},
label: {
Text(self.model.articles.isEmpty ? "Load" : "Clear")
.font(.body)
}
)
).onAppear {
self.action?.fetchArticles()
}
}
}
The scene render constructs the view by assembling:
- Model that is a subset of the entire app state
- Action that sends an action to a reducer
- Render that is a subset of the entire scene render
The callers do not have to worry about these components, they just know that they want the listArticles()
view, or in the above example render.showArticle(id: article.id)
.
All Together Now
Below is the pieces that highlight the architecture in the diagram.
AppCore.swift:
struct AppCore: NewsCore {
func dependency() -> CacheStore {
CacheRealmStore(
constants: dependency(),
log: dependency()
)
}
}
AppState:
class AppState: StateType, ObservableObject {
@Published var articles: [Article] = []
@Published var favorites: [String] = []
}
AppDelegate:
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
private lazy var dataProvider: DataProviderType = core.dependency()
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
dataProvider.configure()
dataProvider.pull()
}
}
// MARK: - Environment Components
private enum Root {
/// Root dependency injection container
static let core = AppCore()
/// Root application storage
static let state = AppState()
/// Root builder for all scenes.
///
/// NavigationView(
/// render.listArticles()
/// )
///
/// Create views only through scene renders.
static let render = SceneRender(
core: core,
state: state
)
}
private extension UIApplicationDelegate {
var core: NewsCore { Root.core }
}
extension UISceneDelegate {
var core: NewsCore { Root.core }
var render: SceneRender { Root.render }
}
SceneDelegate.swift:
class SceneDelegate: ScenePluggableDelegate {}
// MARK: - Events
extension SceneDelegate {
override func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
guard let scene = scene as? UIWindowScene else { return }
// Build and assign main window
window = UIWindow(windowScene: scene)
defer { window?.makeKeyAndVisible() }
// Handle deep link if applicable
if let userActivity = connectionOptions.userActivities.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb }),
let webpageURL = userActivity.webpageURL
{
log.info("Link passed to app: \(webpageURL.absoluteString)")
set(rootViewTo: render.fetch(for: webpageURL))
return
}
// Assign default view
set(rootViewTo: render.launchMain())
}
}
extension SceneDelegate {
override func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
super.scene(scene, continue: userActivity)
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let webpageURL = userActivity.webpageURL else {
return
}
log.info("Link passed to app: \(webpageURL.absoluteString)")
set(rootViewTo: render.fetch(for: webpageURL))
}
}
// MARK: - Helpers
private extension SceneDelegate {
/// Assign root view to window. Adds any environment objects if needed.
func set(rootViewTo view: T) {
window?.rootViewController = UIHostingController(
rootView: view
)
}
}
SceneRender.swift:
/// Root composer used to construct all views.
struct SceneRender {
private let core: NewsCore
private let state: AppState
init(core: NewsCore, state: AppState) {
self.core = core
self.state = state
}
}
// MARK: - Scenes
extension SceneRender {
func launchMain() -> some View {
LaunchMainView(
// Expose only some of the composer by wrapping it
render: LaunchMainRender(parent: self)
)
}
}
extension SceneRender {
func listArticles() -> some View {
ListArticlesView(
// Expose only some of the state by wrapping it
model: ListArticlesModel(parent: state),
// Views use this to dispatch actions to the reducer
action: ListArticlesActionCreator(
articleProvider: core.dependency(),
dispatch: action(to: ListArticlesReducer())
),
// Expose only some of the scene render by wrapping it
render: ListArticlesRender(parent: self)
)
}
}
extension SceneRender {
func showArticle(id: String) -> some View {
ShowArticleView(
model: ShowArticleModel(
parent: state,
id: id
),
text: "Test string",
date: Date(),
quantity: 99,
selection: "Value 1",
action: ShowArticleActionCreator(
favoriteProvider: core.dependency(),
dispatch: action(to: ShowArticleReducer())
)
)
}
}
// MARK: - Helpers
private extension SceneRender {
/// Creates action closure for the view to send to the reducer. This separation decouples actions and reducers.
func action<Action, Reducer>(to reducer: Reducer) -> (Action) -> Void
where Reducer: ReducerType, Reducer.Action == Action {
{ action in
// Allow middleware to passively execute against action
self.middleware.forEach { $0.execute(on: action) }
// Mutate the state for the action
reducer.reduce(self.state, action)
}
}
}
LaunchMainView.swift:
struct LaunchMainView: View {
@State private var selectedTab: Tab = .latest
let render: LaunchMainRender
var body: some View {
TabView(selection: $selectedTab) {
NavigationView {
render.listArticles()
}
.tabItem {
Image(systemName: "doc.text")
Text("Latest")
}
.tag(Tab.latest)
NavigationView {
render.listFavorites()
}
.tabItem {
Image(systemName: "star.fill")
Text("Favorites")
}
.tag(Tab.favorites)
}
}
}
ListArticlesView.swift:
struct LaunchMainView: View {
@State private var selectedTab: Tab = .latest
let render: LaunchMainRender
var body: some View {
TabView(selection: $selectedTab) {
NavigationView {
render.listArticles()
}
.tabItem {
Image(systemName: "doc.text")
Text("Latest")
}
.tag(Tab.latest)
NavigationView {
render.listFavorites()
}
.tabItem {
Image(systemName: "star.fill")
Text("Favorites")
}
.tag(Tab.favorites)
}
}
}
ListArticlesAction.swift:
struct ListArticlesView: View {
@ObservedObject var model: ListArticlesModel
let action: ListArticlesActionCreator?
let render: ListArticlesRender?
var body: some View {
List(model.articles) { article in
NavigationLink(
destination: self.render?
.showArticle(id: article.id)
) {
Text(article.title)
.font(.body)
}
}
.navigationBarTitle(Text("News"))
.navigationBarItems(trailing:
Button(
action: {
self.model.articles.isEmpty
? self.action?.fetchArticles()
: self.action?.clearArticles()
},
label: {
Text(self.model.articles.isEmpty ? "Load" : "Clear")
.font(.body)
}
)
).onAppear {
self.action?.fetchArticles()
}
}
}
ListArticlesReducer.swift:
struct ListArticlesReducer: ReducerType {
func reduce(_ state: AppState, _ action: ListArticlesAction) -> AppState {
switch action {
case .loadArticles(let value):
state.articles = value
case .clearArticles:
state.articles = []
}
return state
}
}
Those are the main pieces to get a high-level overview. For a full working sample, you can try it out here.
Conclusion
SwiftUI is new, shiny, but inevitable. Although we won’t be using this in production any time soon, it’s important to get a head start to know where we’re headed. This allows us to adjust our current UIKit apps so they have a clear migration path forward to the next gen. I look forward to hearing feedback and thoughts from the community. I plan to take this architecture and retrofit some of my UIKit apps to it along with ReSwift, so the jump to SwiftUI will be almost seamless. Stay tuned!
The working code sample can be downloaded here.
Happy Coding!!
Hi iOS Jedi,
Great article. I have used DI in an Android app and C# app. Over the last several months I have been playing around with SwiftUI. I have written many iOS apps. I am currently designing/developing several advanced iOS apps for a newer company, so I am starting from scratch on this one. I have two question:
1. In your article you stated, “SwiftUI is new, shiny, but inevitable. Although we won’t be using this in production any time soon, it’s important to get a head start to know where we’re headed.” — are you still in the belief that you will not be using SwiftUI in production yet? If not, why?
2. Also in your article you stated, “Binding the DataProvider to the AppState observable is still a work in progress and should be lazy loaded as needed.” — have you had a chance to expand this code to use lazy loading? If yes, have you or will you be writing another article on this component?
Hi Basem, Thanks for your article is really useful, I have a question. Why you decided to use NetworkService and NetworkServiceType? Thanks in advance.
Hello Basem. Thanks for the interesting article and a new approach to architecture. But in the text, you missed the code for ListArticlesAction.swift, you repeat the LaunchMainView.swift example twice. I would be grateful if there is an opportunity to correct it so that it is possible to see the whole thing.
Best regards, Andrey Shcherbinin