cf

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:

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:

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:

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:

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.


  1. A common pattern that enhances users’ offline experience. ↩︎