When abstractions are worth it
Abstractions aren’t free — but sometimes they’re the difference between a painless change and a rewrite. This article examines three real-world scenarios where they prove their value, and reflects on how to approach them through intentional architectural decisions.
Here is your typical mobile app screen — it loads data from somewhere and displays it in a list. When a user taps an item, the app navigates to some sort of detail screen:
This is a ubiquitous pattern, no matter the type of app:
The data could come from a remote API or a local database. The quickest, dirtiest way to implement this would be to fetch data directly in the view:
import SwiftUI
import SwiftData
@Model
class Recipe {
let title: String
init(title: String) {
self.title = title
}
}
struct RecipesList: View {
@Query var recipes = [Recipe]
var body: some View {
List(recipes) {
Text($0.title)
}
}
}
import SwiftUI
import CoreData
@objc(Recipe)
class Recipe: NSManagedObject {
@NSManaged var title: String
}
struct RecipesList: View {
@FetchRequest(
sortDescriptors: []
) var recipes: FetchedResults<Recipe>
var body: some View {
List(recipes) {
Text($0.title)
}
}
}
import SwiftUI
import Foundation
struct Recipe: Decodable {
let title: String
}
struct RecipesList: View {
@State var recipes = [Recipe]()
var body: some View {
List(recipes) {
Text($0.title)
}
.task {
let (data, _) = try! await URLSession.shared.data(from: URL(string: "https://api.service.com/recipes")!)
recipes = try! JSONDecoder().decode([Recipe].self, from: data)
}
}
}
This approach ties the view with the specific data source implementation. This may seem acceptable — after all, it works. But there are cases where this tight coupling becomes a liability. Let’s look at three common scenarios where using abstraction makes a difference.
When third-party infrastructure could be deprecated
URLSession
is the default framework provided by Apple to make remote requests, and it has been here for quite some time.
Let’s imagine you have multiple screens in your app where you’re making requests directly in the view — without an abstraction layer. For example, in a recipe app, you might have something like this:
struct RecipesList: View {
@State var recipes = [Recipe]()
var body: some View {
List(recipes) {
Text($0.title)
}
.task {
let (data, _) = try! await URLSession.shared.data(from: URL(string: "https://api.service.com/recipes")!)
recipes = try! JSONDecoder().decode([Recipe].self, from: data)
}
}
}
struct MenuList: View {
@State var menus = [Menu]()
var body: some View {
List(menus) {
Text($0.title)
}
.task {
let (data, _) = try! await URLSession.shared.data(from: URL(string: "https://api.service.com/menus")!)
recipes = try! JSONDecoder().decode([Menu].self, from: data)
}
}
}
struct IngredientList: View {
@State var ingredients = [Ingredient]()
var body: some View {
List(ingredients) {
Text($0.title)
}
.task {
let (data, _) = try! await URLSession.shared.data(from: URL(string: "https://api.service.com/ingredients")!)
recipes = try! JSONDecoder().decode([Ingredient].self, from: data)
}
}
}
struct ShoppingList: View {
@State var items = [ListItem]()
var body: some View {
List(items) {
Text($0.title)
}
.task {
let (data, _) = try! await URLSession.shared.data(from: URL(string: "https://api.service.com/shopping")!)
recipes = try! JSONDecoder().decode([ListItem].self, from: data)
}
}
}
What if Apple shipped a new framework replacing URLSession
? You’d need to update potentially N screens. That might sound unlikely, but it has happened before: URLSession
replaced NSURLConnection
in iOS 9.
Through abstractions and composition, updating the whole app would be as easy as changing a single line:
let app = makeApp(
- httpClient: URLConnectionHTTPClient()
+ httpClient: URLSessionHTTPClient()
)
protocol HTTPClient {
func get(url: URL) async throws -> Data
}
func makeApp(httpClient: HTTPClient) -> RecipesTabbar {
let r = RecipesList(client: httpClient)
let m = MenuList(client: httpClient)
let i = IngredientList(client: httpClient)
let s = ShoppingList(client: httpClient)
return RecipesTabbar(
recipes: r,
menus: m,
ingredients: i,
shoppingList: s,
)
}
You may think this is a somewhat contrived example — and you’d be right. However, it’s a historical case that justifies the point.
A more common scenario is migrating from Alamofire
to URLSession
. In recent years, URLSession
has become powerful enough to cover the vast majority of use cases without relying on a third-party framework. If you had decoupled your Alamofire
logic behind an abstraction, the migration would be just as simple:
let app = makeApp(
- httpClient: AlamofireHTTPClient()
+ httpClient: URLSessionHTTPClient()
)
When you want a secondary data source as fallback
As before, the straightforward way of implementing a fallback1 solution is placing all the logic in the view:
struct RecipeListView: View {
@State var recipes = [Recipe]()
var body: some View {
...
.navigationTitle("🔪 Latest recipes")
.task {
do {
recipes = try await loadFromURLSession()
} catch {
recipes = try await loadFromCoreDataCache()
}
}
}
func loadFromURLSession() async throws -> [Recipe] {
let (data, _) = try URLSession.shared.data(from: ...)
return try JSONDecoder(..., data)
}
func loadFromCoreDataCache() async throws -> [Recipe] {
let cdEntities = try RecipeCacheCoreDataManager.fetch()
return cdEntities.mapToDomainObjects()
}
}
But again, the view would be coupled with frameworks (URLSession
and CoreData
) and wouldn’t be reusable.
Maybe reusability isn’t important for this view right now, but what if later on you decide to add a favorites feature that only fetches data from a local data source, while also keeping the original screen?
Decoupling through abstractions allows that level of flexibility:
protocol RecipesLoader {
func execute() async throws -> [Recipe]
}
struct RecipeList: View {
@State var recipes = [Recipe]()
let loader: RecipesLoader
var body: some View {
List(recipes) { Text($0.title) }
.task {
recipes = try await loader.execute()
}
}
}
struct Tabbar: View {
let favs = RecipeList(CoreDataFavoritesLoader())
let latest = RecipeList(RemoteWithCoreDataFallback())
var body: some View {
TabView {
NavigationView {
favs.navigationTitle("🔪 Favorite recipes")
}
.tabItem {
Image(systemName: "heart")
Text("Favorites")
}
NavigationView {
latest.navigationTitle("🔪 Latest recipes")
}
.tabItem {
Image(systemName: "clock")
Text("Latest")
}
}
}
}
When infrastructure isn’t implemented yet
You’re tasked with the creation of a RecipeList
. You know the data will come from a remote API designed by your backend team. You need to start the development before the data API is ready.
Abstractions allow parallel teamwork. You can build a working screen and wire it to real infrastructure later:
#Preview {
let loader = MockRecipeLoader()
RecipeList(loader)
}
You can then integrate the screen when remote is ready:
class RemoteRecipesLoader: RecipesLoader {
let apiURL: String
...
func execute() async throws -> [Recipe] {
let (data, _) = try await URLSession...
return try JSONDecoder(..., data)
}
}
struct RecipesApp: App {
let loader = RemoteRecipesLoader()
var body: some Scene {
WindowGroup {
TabView {
RecipeList(loader)
}
}
}
}
Conclusions
“Abstractions aren’t free — but they’re often worth the cost once change becomes inevitable.”
Abstractions allow decoupling and improve flexibility, making infrastructure switching easier without breaking the system. They might take a bit more effort at first, but the long-term benefits often outweigh the cost.
“Don’t be religious, be intentional”
That said, coupling can sometimes be practical and even okay. For instance, if you’re building a simple prototype or a feature that’s unlikely to change, starting with direct URLSession calls might be the right choice. The key is making a conscious decision: “I’m coupling this now because X, Y, Z, and I know how to refactor it later if needed.”
Being aware of tradeoffs and potential liabilities, and knowing how to transition from a simple architecture to a more modular one as your project evolves is far more valuable than obsessing over decoupling.
-
A common pattern that enhances users’ offline experience. ↩︎