What is Software Architecture? #
Software architecture is one of those things that can feel overbearing and “boiler-plate ridden” when done wrong, especially at the beginning of a project. It is not until your project grows significantly that a well-thought out structure bears fruit, and once your project grows to such a size it can be difficult to adapt a burgeoning codebase to a consistent pattern. It pays to think through a few things up front.
At the core of software architecture are the questions
Where do I put this? Where should this business logic go? Where should I store this data? How do I get that data from its location to a view?
We then must follow up with another question.
What does that mean for maintainability?
Software projects are never really done. Platforms change, users give feedback, stakeholders make new priorities and we are constantly measuring user engagement and trying to improve the user experience. If we can set some consistent guidelines about where to put the functional “levels” of our app we can more easily grow and adapt our projects over time.
Goals #
We think a good architecture should improve our allowance to forget things. We want to maximize the independence of each part of our app, even down to making sure the View does not know where its data comes from. We think this helps with the maintainability of a project, if you have clearly defined lines between your Views, your data, and how that data is manipulated for display future developers should be able to pick up just the pieces they need for a given task and not have to hold the entire app in their mind before making a change or adding a feature.
We also want our architecture to help facilitate the discussion around the question “where should I put this functionality?” Giving architectural guideposts for where things go can speed up development in the longterm, paying off the investment in setup we make at the start of a project.
A few words on acronyms #
Historically UIKit based iOS apps have their roots in an MVC setup (Model — View — Controller). There much nuance and opinion about how much logic should go in a UIViewController, whether to subclass UIView, how to compose views together, and how to get from screen to screen. Over time we have settled into a pattern for our UIKit apps that uses some flavor of MVC or MVVM but uses a separate Coordinator concept to manage getting from screen to screen.
SwiftUI, the hot new declarative UI framework for iOS, pushes iOS architecture toward an MVVM (Model ‑View — View Model) setup and away from UIKit’s MVC roots. SwiftUI’s struct-based Views are restricted in how much state they can reasonably manage on their own and cannot maintain and manipulate their parent or child views in the same way we can with UIKit. As we continue to develop more and more using SwiftUI, it is a good time to re-evaluate how we structure our iOS projects.
Structure #
Let’s zoom in on MVVM for a moment. The View is our structs that implement SwiftUI’s View protocol. Largely our Model layer is the same as it has always been, some tables in a database, preferences in UserDefaults, etc. The View Model sits between the View and Model, acting as the connective tissue that prepares your app’s data for display. The Model is the where and how your app stores and retrieves its data. The Model’s implementation is not important for the purpose of the discussion in this post. We will use some language around Core Data as an example, but none of the techniques discussed hinge upon its use. Instead we will focus on the relationship between View and View Model specific to how we use them with SwiftUI.
V is for View #
The View, a screen, the application’s UI — at this layer we are only concerned with organizing our information for display and responding to the user’s inputs. Our views are not concerned with where the data came from or how changes the user makes are propagated and saved, that responsibility falls to the View Model.
In general a View should have exactly one View Model that drives it, and that View Model should be the View’s only dependency. We also define a View’s requirements via a protocol, which we call a ViewContract. A View can be driven by any type that implements its contract, and we can have more than one flavor of View Model drive the same UI. As a result, a View will never know the actual type of its View Model, it is only concerned with the elements of its contract.
struct ItemListView<ViewModel: ItemListViewContract>: View {
@ObservedObject var viewModel: ViewModel
var body: some View {
ScrollView {
VStack {
TextField("Search", binding: self.viewModel.searchTerm)
ForEach(self.viewModel.listOfItems, id: \.self) { item in
ItemDetailView(viewModel: item)
}
Button(action: viewModel.buttonClick) {
Label("Refresh")
}
}
}
}
}
Abstracting the ViewModel behind a View Contract Protocol #
SwiftUI’s Preview system is a powerful tool for building and maintaining UI. Previews allow you to see what your View will look like under different environment conditions and with different sets of data. This works just fine for simple Views, but when your View is driven by a View Model that loads its data from an endpoint we need to start thinking about ways to make Previews useful without the need for setting up or mocking out all of our app’s services.
To get the most out of SwiftUI’s preview system we insert a protocol (interface for any non-Swift folks out there) that describes the data requirements for a View. For our purposes, we will call this a ViewContract. Check out Using View Model Protocols to manage complex SwiftUI Views for an introduction to this idea.
This View Contract protocol defines exactly what is needed to drive a piece of UI — its functions, variables, states, etc. This separates the requirements of the UI from their underlying Model-layer components. We can then implement that protocol in multiple concrete view model classes and use each to drive a particular view in different scenarios. The listing below is an example of what a protocol might look like for a screen that displays a list of items.
protocol ItemListViewContract: ObservableObject {
associatedtype ItemDetailViewModelType: ItemDetailViewContract
var searchTerm: String { get set }
var listOfItems: [ItemDetailViewModelType] { get }
func buttonClick()
}
We extend ObservableObject
so that SwiftUI can use the @ObservedObject
or @StateObject
property wrappers. The properties we define in the Contract should have fairly simple types (String
, Int
, etc.) or have their types hidden behind associated type with protocol requirements (e.g. ItemViewModelType
). The purpose is to hide the actual source of this information from the UI so we can’t go about returning CoreData objects directly here.
This is also where you would define a hierarchy of View Models, for example if a list view needs to provide an array of View Models for a detail screen. In the listing above, our contract requires an array of ItemViewModelType
which can be any type that implements the contract for the item detail view (ItemContentViewContract
).
The View Contract protocol approach allows us to define multiple variations on our View Models without subclassing and without tying our Views to any specific data source. We reap dividends from this setup when it comes to making the most of SwiftUI Previews. As we will see in the next section, we can define a concrete View Model that uses all hard coded data for SwiftUI Previews, or reads from a JSON file, whilst also letting our actual View Models talk to resources that are not available or difficult to use in Previews (like CoreData, an API, UserDefaults, etc.).
struct ItemListView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
ItemListView(viewModel: PreviewItemListViewModel())
}
NavigationView {
ItemListView(viewModel: PreviewItemListViewModel())
}
.colorScheme(.dark)
}
}
Creating Concrete View Models #
Now that we have our requirements defined for our View we can work on implementing a pair of View Models that we can use to drive it — one for previews and another for our app’s actual data.
Preview View Model Class #
Perhaps it is easiest to start by defining a View Model we can use for previews. We find it helpful to do this upfront, in tandem with building our actual UI. We can use hard-coded data or pass in values to create different scenarios.
final class PreviewItemListViewModel: ItemListViewContract {
var searchTerm: String = "Preview"
var listOfItems: [PreviewItemDetailViewModel] = []
func buttonClick() {}
init() {}
}
The listing above hard codes the data that we will show in previews and provides a simple implementation of the actions required by our View Contract. Additionally, we need to create another class, PreviewItemContentViewModel
, that will implement the contract for our detail view (ItemContentViewContract
).
Now that we have our UI elements figured out we can write another View Model implementation that pulls from our app’s actual data.
CoreData Backed ViewModel Class #
CoreData is by no means the only place you can store your app’s data. It is worth reiterating that this approach is intentionally agnostic of where your data comes from; the UI can only see what is defined in its contract.
This is where the data actually is accessed. It defines everything established in the protocol it implements. Here we also provide an implementation of our buttonClick
function that reloads the data.
final class ItemListViewModel: ItemListViewContract {
@Published var searchTerm: String = "CoreData 😎"
@Published var listOfItems: [ItemDetailViewModel] = []
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
self.context = context
}
private func fetch() {
// Load the list of items from CoreData and convert
// them into our detail view models
self.listOfItems = self.context
.fetch(MyCoreDataModel.searchRequest(term: self.searchTerm))
.map(ItemDetailViewModel.init)
}
func buttonClick() {
self.fetch()
}
}
If CoreData is not your backing store of choice, hopefully you can see how you might create a View Model class that implements our View Contract but gets its data from SQLite, Realm, an API, or similar. The end result is a hierarchy that looks something like the image below.
Architecture benefits #
We want the implementation of our View Models to be entirely invisible to our Views. This strong isolation between View and Model allows us to develop in parallel better. When starting work on a screen we can define the screen’s requirements based on designs and the interactions the user needs to take and codify it into a View Contract. Multiple developers can then work on implementing the each side of the contract independently.
This approach can also ease maintenance of both the UI and an app’s business logic. As long as the view receives the data in the correct format, according to its contract, the source of it can change and the page will function the same. Additionally, we can redesign or rewrite a screen and know that it will not affect the underlying business logic contained in our Models and View Models. We also can rely on the compiler when we need to change the View Contract to make sure nothing slips through the cracks.
This is all in addition to the realities of development where often the UI design is established before all of the external resources and infrastructure are ready to use. By establishing this abstraction between our Views and the rest of our app, we can easily write a View Model implementation that works for demoing the UI and later follow up with a View Model that provides real data. We have confidence to do this with minimal rework because of our requirements defined in the View Contract.
It is worth noting that not all Views warrant the creating of a View Model and View Contract. Views can be simple, for example just taking a few strings and displaying them in a stack. These types of Views are useful to keep your UI elements consistent. Once your View needs to respond to changes, manipulate state, or perform other integrations with system APIs it might be time to reach for a View Model.
Sharing functionality across screens #
Above we stated that in general a View should only have one dependency, its View Model, but often times in larger applications we need to manipulate application state in the same way across different screens and in different view models. For this we introduce a new layer into the mix between our View Models and our database, the Use Case .
Use Cases may be familiar to our Android friends, but for the unfamiliar they are stateless objects that manipulate your application state according to business requirements — essentially a codified, reusable slice of business logic. This means that you can pass around a shared Use Case, or create multiple instances and share them to each View Model as needed, using whatever dependency injection mechanism you prefer.
Below is an example of a Use Case you may write to pull together an API and some locally stored data to provide a consistent way to access a user’s login state in your app. Additionally we show two ViewModels that might make use of this shared functionality.
/// A Use Case that encapsulates the Login state of the application, allowing the
/// View Models a consistent mechanism to alter and respond to change in the state
final public class LoginStateUseCase: NSObject {
var loginState: AnyPublisher<LoginState, Never>
func login(credentials: Credentials) {
// ... Do login
}
func logout() {
// ... Do logout
}
}
// Create a shared instance so all consumers can get the same state. This can
// also be done through more nuanced DI setups
extension LoginStateUseCase {
public static let shared = ExampleUseCase()
}
/// View Model for the Login Screen that manipulates the login state by attempting
/// to login with the user's entered credentials
final class LoginScreenViewModel: LoginScreenViewContract {
@Published var usernameField: String = ""
@Published var passwordField: String = ""
let useCase: LoginStateUseCase
init(login: LoginStateUseCase = .shared) {
self.useCase = login
}
func login() {
self.useCase.login(
with: Credentials(
username: self.usernameField,
password: self.passwordField
)
)
.sink(...)
}
}
/// View Model for the Root Screen that serves as the app's top level router,
/// showing the main content or the login screen depending on the login state
final class RootScreenViewModel: RootScreenViewContract {
@Published var isLoggedIn: Bool = false
let useCase: LoginStateUseCase
init(login: LoginStateUseCase = .shared) {
self.useCase = login
self.useCase.loginState
.map { state in
return state == .loggedIn
}
.assign(to: &$isLoggedIn)
}
}
Use Cases can also depend on other Use Cases allowing you to compose your app’s business logic from multiple, independent parts. This is a powerful way to compose functionality together rather than building one giant service that gets passed about through your entire app. The diagram below shows where in the hierarchy a Use Case fits.
Moving forward #
There is a benefit to thinking through the questions of where things go and how to maintain a software project regardless of whether the specific approach described in this post works for you. Some apps will warrant additional layers and abstractions like Repositories and other Service types. Some will not benefit from the addition of Use Cases. It all depends on a project’s complexity and what the maintenance lifetime will be. The approach above works for us as a solid middle ground that provides enough structure to ease some decision making and maintenance whilst not being so heavy handed as to become burdensome and a barrier to development. Any architecture can work for you, just putting in a little thought and planning up front can go a long way.
Additional resources #
The App Architecture book from objc.io is a great introduction to multiple different architecture patterns. Check it out if you are interested in learning more.
Looking for more like this?
Sign up for our monthly newsletter to receive helpful articles, case studies, and stories from our team.
How to Prepare for our Associate Software Developer Position
June 30, 2023Tips for applying to MichiganLab's Associate Software Developer program
Read moreMichiganLabs’ approach to product strategy: Driving software success
February 12, 2024 Read moreHow our Associates are using AI tools: Advice for early-career developers
August 13, 2024Our 2024 Associates at Michigan Labs share their experiences using AI tools like GitHub Copilot and ChatGPT in software development. They discuss how these tools have enhanced their productivity, the challenges they've faced, and provide advice for using AI effectively.
Read more