iOS Development

Should you use all these dependencies?

12 min readRafał Dubiel
#iOS#Dependencies

Every iOS developer knows this moment: you start a new project, open Package.swift and wonder whether to add a library or write it yourself?

Personally, I prefer to write most code myself and believe that using many popular libraries is style over substance. I like to be independent, but that doesn't mean I like to make my life difficult.

In this article, I'll share the decision criteria I use, show concrete examples, and tell you about lessons learned from painful migrations.


Hidden costs of dependencies

Before we move on to the criteria, it's worth realizing what we're actually paying for each library in the project.

Binary size

This is the most obvious cost. You add Alamofire to send 3 requests? You just added ~2MB to your application. Kingfisher for basic image caching? Another ~3MB.

In the era of 5G, this seems irrelevant, but the App Store still shows the application size, and users still pay attention to it, especially in markets with more expensive data transfer. Imagine this situation: you're on vacation somewhere remote, internet access is weak, but you want to download an app for a specific purpose, you have two to choose from. One weighs 250MB, the other 80MB, which would you choose?

Compilation time

Each dependency is code that needs to be compiled. In small projects, this is almost imperceptible, but with 20-30 libraries, a clean build can take minutes. This is a real cost during development.

Vulnerability to attacks

In security-sensitive applications, every external library is a potential attack vector. Supply chain attacks are not theory, they have happened and will happen. Auditing the code of 15 libraries is a completely different scale of problem than auditing your own 2000 lines.

Technical debt

This is a cost that reveals itself with delay. The library stops being developed, the author changes the API in a major version, a conflict appears with another dependency. Suddenly a simple upgrade becomes a task for weeks.


Horror Story: RxSwift for one Observable

A few years ago, I joined a project that had RxSwift added as a dependency. A large application, tens of thousands of lines of code. Naturally, I assumed that RxSwift was the foundation of the architecture here: UI bindings, data streams, complex operators.

I decided to look through where we actually use Rx.

There were 3 places. Three BehaviorRelay in the user settings layer. Something like:

class SettingsManager {
    let isDarkModeEnabled = BehaviorRelay<Bool>(value: false)
    let fontSize = BehaviorRelay<FontSize>(value: .medium)
    let notificationsEnabled = BehaviorRelay<Bool>(value: true)
}

That's it. No complex operators. No flatMapLatest. No combineLatest with five sources. Three observable values...

And the cost?

  • ~3MB to the binary (RxSwift + RxCocoa + RxRelay),
  • Extended compilation time: Rx is a lot of generic code,
  • Entry barrier for new developers: "you need to know Rx to work on this project" (for three properties),
  • Updates - Rx must keep up with changes in Swift.

How did this happen? Classic. Someone at the beginning of the project knew Rx, used it for the first implemented functionality, and then the project grew in a different direction, but the dependency remained.

What could have been done instead?

A simple, native Observable in ~25 lines:

@propertyWrapper
final class Observable<Value> {

    var wrappedValue: Value {
        get { value }
        set {
            value = newValue
            observers.values.forEach { $0(newValue) }
        }
    }

    var projectedValue: Observable<Value> { self }

    private var value: Value
    private var observers: [UUID: (Value) -> Void] = [:]

    init(wrappedValue: Value) {
        self.value = wrappedValue
    }

    func observe(_ observer: @escaping (Value) -> Void) -> UUID {
        let id = UUID()
        observers[id] = observer
        observer(value) // emit current value
        return id
    }

    func removeObserver(_ id: UUID) {
        observers.removeValue(forKey: id)
    }
}

Usage:

class SettingsManager {
    @Observable var isDarkModeEnabled = false
    @Observable var fontSize: FontSize = .medium
}

// Subscription
let id = settingsManager.$isDarkModeEnabled.observe { isDark in
    print("Dark mode: \(isDark)")
}

// Cleanup
settingsManager.$isDarkModeEnabled.removeObserver(id)

Zero dependencies. Full control. Every programmer will understand what's happening.

And if you need something more extensive, since iOS 13 you have Combine, which is built into the system and offers everything that RxSwift does, without additional megabytes.


Case Study: Alamofire vs URLSession

Alamofire is probably the most popular library in the iOS ecosystem. Most networking tutorials use it. But do you actually need it?

What does alamofire offer?

  • Elegant, chainable API,
  • Automatic response validation,
  • Retry logic,
  • Request/response interceptors,
  • Upload/download with progress tracking,
  • Certificate pinning,
  • Network reachability.

What do most projects actually use?

  • Sending GET/POST requests,
  • Decoding JSON to Codable,
  • Error handling,
  • Maybe simple retry,

Native solution (simple example)

// Simple API client in ~50 lines

enum APIError: Error {
    case invalidURL
    case noData
    case decodingError(Error)
    case httpError(statusCode: Int)
    case networkError(Error)
}

final class APIClient {
    private let session: URLSession
    private let decoder: JSONDecoder

    init(session: URLSession = .shared, decoder: JSONDecoder = JSONDecoder()) {
        self.session = session
        self.decoder = decoder
    }

    func request<T: Decodable>(
        url: String,
        method: String = "GET",
        body: Data? = nil,
        headers: [String: String] = [:]
    ) async throws -> T {
        guard let url = URL(string: url) else {
            throw APIError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = method
        request.httpBody = body

        headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }

        if body != nil {
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        }

        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw APIError.noData
        }

        guard (200...299).contains(httpResponse.statusCode) else {
            throw APIError.httpError(statusCode: httpResponse.statusCode)
        }

        do {
            return try decoder.decode(T.self, from: data)
        } catch {
            throw APIError.decodingError(error)
        }
    }
}

// Usage
let client = APIClient()
let user: User = try await client.request(url: "https://api.example.com/user/1")

When does Alamofire make sense?

  • You need advanced retry with exponential backoff,
  • You have a complex authentication flow with token refreshing,
  • You need interceptors for logging/modifying requests,
  • You do a lot of upload/download with progress tracking.

When is a native solution enough?

  • Standard CRUD operations,
  • Simple authentication flow,
  • A few or a dozen endpoints,
  • An application where networking is not the main functionality.

Decision criteria

After years of trial and error, I've developed a checklist that I go through before adding each dependency to a project:

1. Am I solving a real problem?

Sounds trivial, but surprisingly often we add libraries "just in case" or because everyone does it. Before you reach for a dependency, make sure that:

  • You have a specific problem to solve - not a hypothetical one,
  • Native API really isn't enough (check Apple's documentation, they often add new capabilities),
  • The problem is complex enough that your own implementation will take a lot of time,

2. What is the cost of your own implementation?

Calculate realistically:

  • How long will writing take?
  • How long will testing edge cases take?
  • Do you have domain knowledge (e.g., cryptography, image processing)?

If the answer to the last question is "no", then a library is probably a better choice. Don't write your own cryptography.

3. What does the library's code and maintenance look like?

Red flags:

  • Last commit > 6 months ago (unless the library is "finished"),
  • One contributor,
  • Lots of open issues without responses,
  • Lack of migration documentation between versions,
  • Lack of tests.

Green flags:

  • Active development,
  • Several maintainers or backing of a large company,
  • Semantic versioning,
  • Changelog,
  • Good documentation.

4. How much of this library will I actually use?

This is a key question. If you only use AF.request() from Alamofire, you probably don't need Alamofire.

If you use less than 20% of the library's capabilities, consider your own implementation of that part.

5. What is plan B?

Always ask yourself: what if this library stops being developed in 2 years?

  • Will I be able to fork it and maintain it?
  • Will migration to an alternative be simple?
  • Can I limit coupling so that replacement is local?

Risk mitigation strategy

Even if you decide on a library, you can mitigate the risk.

Wrapper/Adapter pattern

Don't use the library directly throughout the code. Create your own abstraction layer:

// Instead of using Kingfisher directly in 50 places:
imageView.kf.setImage(with: url)

// Create your own interface:
protocol ImageLoader {
    func loadImage(from url: URL, into imageView: UIImageView)
}

final class KingfisherImageLoader: ImageLoader {
    func loadImage(from url: URL, into imageView: UIImageView) {
        imageView.kf.setImage(with: url)
    }
}

// If Kingfisher disappears, you write a new implementation:
final class NativeImageLoader: ImageLoader {
    func loadImage(from url: URL, into imageView: UIImageView) {
        // URLSession + cache
    }
}

Cost? A dozen lines of code. Benefit? The ability to replace the library in one day instead of a week.

Version locking

Use specific versions, not ranges:

// Risky
.package(url: "...", from: "5.0.0")

// Safer
.package(url: "...", exact: "5.6.2")

Yes, this requires manual updates. But at least you know when and what changes - you have full control.


Tools and dependency audit process

Theory is theory, but how do you actually check the state of your dependencies in practice? From time to time, you can conduct a simple audit.

Step 1: Dependency tree review

Start by seeing what you actually have in the project, including transitive dependencies (dependencies of your dependencies):

swift package show-dependencies

For an Xcode project:

xcodebuild -project MyApp.xcodeproj -showdependencies

It often turns out that one library pulls in several others. This is a good moment to ask yourself: did I know that all this is in my code?

Step 2: Binary size analysis

Want to know how much each library costs you in MB? Emerge Tools (emergetools.com) offers free analysis for open source projects. You upload an IPA, you get a breakdown:

  • Size of each framework,
  • Comparison between versions,
  • Division into code vs resources.

Alternatively, a simple manual test: remove the library, build the archive, compare the size. Time-consuming, but it works.

For a quick local preview, you can also check the DerivedData folder after a build:

# After building the project
find ~/Library/Developer/Xcode/DerivedData/MyApp-*/Build/Products -name "*.framework" -exec du -sh {} \;

Step 3: Library health check

For each dependency, check on GitHub:

Last activity:

  • Last commit < 3 months → actively developed,
  • 3-12 months → may be stable, may be abandoned,
  • more than 12 months → red flag (unless it's a small, "finished" library).

Bus factor:

  • 1 contributor → risk,
  • 3+ active → safer,
  • Company backing (Alamofire → Alamofire Software Foundation) → even better.

Issues and PRs:

  • Lots of open issues without responses → maintainer can't keep up,
  • PRs hanging for months → development stalled.

To avoid having to check all of this manually every week, it’s worth using tools that automate the detection of outdated or abandoned packages:

  • Dependabot (built into GitHub) - automatically opens pull requests with dependency updates for Swift packages (it’s been working really well with Swift Package Manager since 2023) and also reports security alerts. Configuration is simple, just add a .github/dependabot.yml file.
  • Swift Package Index - a great place to quickly check the health of a package (latest releases, star count, activity score). You can also use a CLI tool like swift-outdated, which will show you locally which packages in your project are outdated compared to what’s declared in Package.swift.

Thanks to these tools, auditing becomes much less painful - instead of manually scrolling through 20 GitHub repositories, you get notifications and ready-made pull requests.

Step 4: Compilation time analysis

Check which dependencies slow down the build the most:

# Enable detailed compilation time logs
defaults write com.apple.dt.Xcode ShowBuildOperationDuration -bool YES

After building in Xcode, in Build Log you'll see the time of each target.

Or from the command line:

xcodebuild -project MyApp.xcodeproj \
    -scheme MyApp \
    -showBuildTimingSummary \
    clean build

At the end, you'll get a summary - if a library compiles for more than 30 seconds and you use one function from it, it's worth thinking about.

My quarterly audit checklist

  1. Run swift package show-dependencies - are there any surprises?
  2. Check GitHub of each dependency - last commit, open issues,
  3. Compare IPA size with the previous period,
  4. Review compilation time, has something gotten worse?
  5. For each library: am I still using it? Is there a native alternative?

Such a check from time to time can save a lot of time with the next iOS update.


Libraries worth considering vs writing yourself

Based on my experience:

Rather take a library

  • Cryptography - never write your own,
  • Date/timezone handling - too many edge cases,
  • Parsing complex formats (XML, Markdown) - lots of edge cases,
  • Analytics SDK - integration with the provider's backend.

Rather write yourself

  • Simple networking - URLSession + async/await are completely sufficient,
  • Basic image cache - NSCache + FileManager,
  • Simple UI components - UIKit/SwiftUI are flexible enough,
  • Dependency injection - protocols + constructors or simple ServiceLocator, you don't need Swinject,
  • Logging - OSLog, you don't need CocoaLumberjack,

What libraries often appear in my projects?

There is a certain set of dependencies that often repeats in projects I participate in, namely:

  • Firebase/Sentry,
  • Lottie,
  • Inject,
  • SwiftLint,
  • Swift Collections,
  • Quick + Nimble (less frequently lately).

Summary

It's not about avoiding libraries at all costs. It's about conscious decisions.

Each dependency is a trade-off: time saved now versus potential technical debt in the future. Sometimes this trade-off is obvious (cryptography). Sometimes it requires thought (networking). Sometimes the answer is "write it yourself" (simple DI).

My rule: native solution by default, library when there's a specific reason.

That reason is not everyone uses it or that's how it is in the tutorial. It's: solves a complex problem that I don't want to/can't solve myself, is actively developed, and I can limit coupling with a wrapper.

How many dependencies does your current project have? When did you last check if all of them are needed?

Rafał Dubiel

Rafał Dubiel

Senior iOS Developer z ponad 8-letnim doświadczeniem w tworzeniu aplikacji mobilnych. Pasjonat czystej architektury, Swift i dzielenia się wiedzą ze społecznością.

Udostępnij artykuł:

Should you use all these dependencies? | FractalDev Blog