How to prevent circular dependencies in large iOS projects

SwiftPM and Xcode already refuse a module cycle. The real damage is how the cycle comes back, as a bloated Common module or a monolith nobody can split. Five techniques, which one fits which cause, and a CI script that fails the build on a forbidden edge.

A module-level circular dependency is an odd kind of bug on iOS: the tools refuse to build it, so you almost never see one in the repository, yet you live with its consequences every day. When Booking and Profile need each other, the cycle does not go away. It changes shape. Whatever the two features both need gets moved into a module called Common, or the two features never get separated and stay inside one enormous target. This article is about cycles between targets and modules. Retain cycles are a different problem and we leave them out.

The running example is a hypothetical travel app: Booking, Profile, Analytics and a few shared modules. We produced the error messages and command output below on our own machine with Swift 6.4 and Xcode 27.1; the wording may differ slightly in your toolchain version.

Cycles are born in well-meant code

Nobody starts the day planning to build a cycle. In large codebases we keep seeing the same four origins.

The first is mutual navigation. Tapping the passenger on a booking detail opens the profile, and the "recent bookings" list on the profile opens a booking detail. Both are legitimate product requirements, and both sides want to instantiate the other side's screen:

// Profile module
import UIKit
import Booking   // "only to show recent bookings"

final class ProfileViewController: UIViewController {
    private let repository = BookingRepository.shared

    func didSelect(_ booking: BookingSummary) {
        let detail = BookingDetailViewController(bookingID: booking.id)
        navigationController?.pushViewController(detail, animated: true)
    }
}

// Booking module
import UIKit
import Profile   // to open the passenger's profile

final class BookingDetailViewController: UIViewController {
    @objc private func passengerTapped() {
        let profile = ProfileViewController(userID: passengerID)
        navigationController?.pushViewController(profile, animated: true)
    }
}

The second is a "shared" helper that quietly imports a feature. The code below looks harmless, but it ties the lowest module to the highest ones, and every feature that uses Analytics now depends transitively on every other feature:

// Analytics module
import UIKit
import Booking
import Profile

public enum ScreenTracker {
    public static func name(for viewController: UIViewController) -> String {
        switch viewController {
        case is BookingDetailViewController: "booking_detail"
        case is ProfileViewController: "profile"
        default: String(describing: type(of: viewController))
        }
    }
}

The third is a model living in the wrong module. BookingSummary is defined in Booking because that is where it was first needed. Later the profile screen wants to display it, and a Profile → Booking edge appears for the sake of one struct.

The fourth is the central piece that knows every feature: the deep link router, analytics, the feature flag registry. Knowing everything is their job. The trouble starts when the features want to call them back.

What was intended            What the code actually wants

App                          App
 ├── Booking                  ├── Booking ⇄ Profile
 ├── Profile                  │      ↓         ↓
 └── Analytics                └── Analytics ───┘
                                     └──→ Booking, Profile

What the tools do when they meet a cycle

SwiftPM stops at the manifest

Make two targets in the same package depend on each other:

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "Travel",
    targets: [
        .target(name: "Booking", dependencies: ["Profile"]),
        .target(name: "Profile", dependencies: ["Booking"]),
    ]
)

swift build stops before compiling a single source file:

error: 'travel': cyclic dependency declaration found: Booking -> Profile -> Booking

With a longer chain the message prints the whole path; in our test it read App -> Booking -> Shared -> Profile -> Booking. swift package describe fails with the same error, so you cannot even inspect a manifest that contains a cycle.

Across packages there is a subtlety. According to the SwiftPM 6.0 CHANGELOG, from tools-version 6.0 two packages may depend on each other as long as no target-level cycle is formed. We tried it: with PA and PB listing each other as dependencies and no cycle between their targets, the build passed. Dropping the same manifests to tools-version 5.9 produced this:

error: cyclic dependency between packages PA -> PB -> PA requires tools-version 6.0 or later

We do not recommend using that freedom as a design tool. A two-way arrow between packages is a sign that ownership is muddled, and it sits one commit away from a target cycle.

Xcode targets fail before the build starts

When we added two framework targets to each other's target dependencies, xcodebuild printed:

error: Cycle in dependencies between targets 'Booking' and 'Profile'; building could produce unreliable results.
Cycle path: Booking → Profile → Booking
Cycle details:
→ Target 'Booking' has an explicit dependency on Target 'Profile'
→ Target 'Profile' has an explicit dependency on Target 'Booking'

The Xcode Help page on this error also points out that the dependency does not have to be declared: when a target links against another target's product, the dependency is created implicitly, and a cycle can form that way too. When we left the reverse dependency undeclared and only added the import Booking line, a clean build failed with Unable to resolve module dependency: 'Booking'. Either way the outcome is the same: a cycle between targets does not build.

Tuist runs the same check while loading the graph, and the message in its source code has the form Found circular dependency between targets: ....

Nobody sees a cycle inside a single module

What the tools reject is a cycle between targets. Types inside one module can tangle as much as they like, because Swift compiles all files of a module as one unit. As long as BookingDetailViewController and ProfileViewController live in the same app target, they instantiate each other freely and no tool complains.

The bill arrives the day you try to split that target. You move the Profile folder into a new target, the compiler hands you hundreds of Cannot find type ... in scope errors, and half of them are edges pointing the wrong way. This is usually where "we estimated two sprints for modularization" ends. There is a cheap measurement you can take before committing: on a throwaway branch, move the folder into a new target, build, and count the errors that point backwards. That number is the number of edges you have to break.

Five techniques that break a cycle

1. Split interface and implementation modules

This is the technique that pays off most often. Each feature puts the promise it makes to others into a small module: ProfileInterface holds only protocols and plain value types, Profile holds the real screens. Other features depend on the interface only.

// ProfileInterface module
import UIKit
import TravelModels

@MainActor
public protocol ProfileScreenBuilding {
    func makeProfileScreen(for user: UserID) -> UIViewController
}

// BookingInterface module
import UIKit
import TravelModels

@MainActor
public protocol BookingScreenBuilding {
    func makeBookingDetail(for id: BookingID) -> UIViewController
}

public protocol BookingHistoryProviding: Sendable {
    func recentBookings(for user: UserID, limit: Int) async throws -> [BookingSummary]
}

Booking no longer knows Profile, only the promise:

// Booking module
import UIKit
import BookingInterface
import ProfileInterface
import TravelModels

final class BookingDetailViewController: UIViewController {
    private let bookingID: BookingID
    private let profile: any ProfileScreenBuilding
    var passengerID = UserID("u-1")

    init(bookingID: BookingID, profile: any ProfileScreenBuilding) {
        self.bookingID = bookingID
        self.profile = profile
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }

    @objc private func passengerTapped() {
        let screen = profile.makeProfileScreen(for: passengerID)
        navigationController?.pushViewController(screen, animated: true)
    }
}
Before                       After

Booking ⇄ Profile            Booking ──→ ProfileInterface ←── Profile
                             Profile ──→ BookingInterface ←── Booking
                             (interfaces depend on TravelModels only)

A side benefit: when a view inside Profile changes, Booking is not recompiled, because the module it depends on has not changed.

2. Let the composition root do the wiring

The protocols exist, but somebody has to hand ProfileFeature to Booking. That job belongs to the one place allowed to see everything, the application layer. We keep the app target thin and put the wiring in a module called AppComposition.

One thing needs saying plainly here. You have removed the module cycle, but the cycle in the object graph is still there: BookingFeature wants a ProfileScreenBuilding at construction and ProfileFeature wants a BookingScreenBuilding. You cannot initialise both first. The fix is to pass the dependency as a closure resolved on first use instead of a ready-made value:

// Booking module
@MainActor
public final class BookingFeature: BookingScreenBuilding {
    private let profile: @MainActor () -> any ProfileScreenBuilding
    private let analytics: any AnalyticsTracking

    public init(
        profile: @escaping @MainActor () -> any ProfileScreenBuilding,
        analytics: any AnalyticsTracking
    ) {
        self.profile = profile
        self.analytics = analytics
    }

    public func makeBookingDetail(for id: BookingID) -> UIViewController {
        analytics.track(BookingOpened(bookingID: id))
        return BookingDetailViewController(bookingID: id, profile: profile())
    }
}

// AppComposition module
@MainActor
public final class AppContainer {
    private let analytics: any AnalyticsTracking = ConsoleAnalytics()

    public init() {}

    private(set) lazy var booking: any BookingScreenBuilding = BookingFeature(
        profile: { [unowned self] in self.profile },
        analytics: analytics
    )

    private(set) lazy var profile: any ProfileScreenBuilding = ProfileFeature(
        bookings: { [unowned self] in self.booking },
        history: BookingHistoryService()
    )
}

lazy var and the closure work together: whichever feature is requested first gets built, and the other is resolved when the first screen is opened. The container lives as long as the app, so unowned is safe here. Keeping that container from growing to 400 lines is a topic for another article.

3. Move navigation to route types and a router in the app layer

When two features touch each other in two or three places, an interface module is enough. When dozens of screens navigate to each other, writing a protocol per pair gets tiring. At that point we tell features to state where they want to go and stay ignorant of how to get there. The route type and the router protocol sit in a small Navigation module at the bottom; the implementation lives in the app layer:

// Navigation module (low level, depends on TravelModels only)
import TravelModels

public enum AppRoute: Hashable, Sendable {
    case bookingDetail(BookingID)
    case profile(UserID)
}

@MainActor
public protocol AppRouting: AnyObject {
    func show(_ route: AppRoute)
}

// AppComposition module
@MainActor
final class AppRouter: AppRouting {
    private let navigationController: UINavigationController
    private let container: AppContainer

    init(navigationController: UINavigationController, container: AppContainer) {
        self.navigationController = navigationController
        self.container = container
    }

    func show(_ route: AppRoute) {
        let screen: UIViewController = switch route {
        case .bookingDetail(let id): container.booking.makeBookingDetail(for: id)
        case .profile(let user): container.profile.makeProfileScreen(for: user)
        }
        navigationController.pushViewController(screen, animated: true)
    }
}

Deep links fit the same mould. Each feature supplies a small handler that turns its own URLs into an AppRoute, the app layer collects the handlers in an array and forwards the first matching route to the router. The piece that "knows every feature" stays in the app layer, and the features do not know it.

Before                            After

DeepLinkRouter ──→ Booking        AppRouter (app) ──→ Booking, Profile
      ↑    └─────→ Profile        Booking, Profile ──→ Navigation
Booking, Profile (calling it)     (AppRoute + AppRouting)

The cost is visible: AppRoute becomes a file every team touches, and because the compiler wants the switch to be exhaustive, a new route also means a change in the app layer. We find that acceptable, since the conflicts amount to one-line case additions.

4. Move shared types down

If a model is the only reason for the edge, you do not need a protocol. Take BookingSummary out of Booking and put it in TravelModels, below both features.

// TravelModels module
import Foundation

public struct BookingSummary: Identifiable, Hashable, Sendable {
    public let id: BookingID
    public let title: String
    public let startDate: Date

    public init(id: BookingID, title: String, startDate: Date) {
        self.id = id
        self.title = title
        self.startDate = startDate
    }
}
Before                       After

Profile ──→ Booking          Profile ──→ TravelModels ←── Booking
(for BookingSummary only)

We have one rule: what moves down must be data without behaviour. Models, identifier types and error enums move down. Once a type that makes network calls, holds a cache or knows about UI moves down, TravelModels becomes the new Common within a few months.

5. Use closures or events for one-way notifications

Some edges ask no question, they only announce something. When the session ends, the booking cache has to be cleared, and the shortest path is calling BookingCache.shared.clear() from inside Profile. But Profile needs no answer. Let it publish the event and stay unaware of who listens:

// Profile module
public final class SessionStore: SessionEvents {
    private let stream: AsyncStream<UserID>
    private let continuation: AsyncStream<UserID>.Continuation

    public init() {
        (stream, continuation) = AsyncStream.makeStream(of: UserID.self)
    }

    public func logouts() -> AsyncStream<UserID> { stream }

    public func logOut(_ user: UserID) {
        // Delete the token, tell the server...
        continuation.yield(user)
    }
}

// AppComposition module, inside AppContainer
public func start() {
    let logouts = session.logouts()
    let cache = bookingCache
    Task {
        for await user in logouts {
            await cache.clear(for: user)
        }
    }
}

With a single listener one AsyncStream is enough; if you need several listeners, create a stream per listener or keep a plain list of closures. The same inversion fixes the Analytics example: the Analytics module defines an AnalyticsEvent protocol, each feature writes its own event types, and Analytics imports no feature at all.

NotificationCenter with string-keyed userInfo technically breaks the same edge, but it hides the dependency where the compiler cannot see it. We do not count that as a fix.

Which technique for which cause

Cause of the cycle Technique Our verdict
Two features open each other's screens at a few touch points Interface module + composition root Our default
Many features navigate to each other, deep links exist Route type + router in the app layer We switch to this beyond 8 to 10 features
The edge exists only because of a model or identifier type Move the type down The cheapest fix, check for it first
One feature merely notifies the other Closure or AsyncStream, wired in the app If no answer is expected, do not write a protocol
A "shared" module imports a feature Put the protocol in the shared module, let the feature conform A lower layer never imports upwards, no exceptions
Two features are tangled at dozens of points Merge them Probably one domain; redraw the boundary

The last row is the least popular answer. If two modules need twenty protocols between them, the boundary is in the wrong place, and rethinking your domain boundaries costs less than writing the interfaces.

Detect it and keep it from coming back

SwiftPM's own commands

swift package show-dependencies sounds like the right tool, but it works at package level. If you keep all modules as targets inside one package, it shows only the external dependencies:

.
└── swift-collections<https://github.com/apple/swift-collections.git@1.7.1>

The target-level edges are in the output of swift package describe. Its --type option accepts text, json and mermaid. These are the fields we need from the JSON:

swift package describe --type json \
  | jq -c '.targets[] | {name, target_dependencies}'
{"name":"TravelModels","target_dependencies":null}
{"name":"ProfileInterface","target_dependencies":["TravelModels"]}
{"name":"Profile","target_dependencies":["ProfileInterface","BookingInterface","TravelModels","Analytics"]}
{"name":"BookingInterface","target_dependencies":["TravelModels"]}
{"name":"Booking","target_dependencies":["BookingInterface","ProfileInterface","TravelModels","Analytics"]}
{"name":"AppComposition","target_dependencies":["Booking","Profile","BookingInterface","ProfileInterface","Analytics"]}
{"name":"Analytics","target_dependencies":null}

--type mermaid prints the same graph as a flowchart, handy for pasting into a PR description. If you use Tuist, tuist graph does the same at project level: --format accepts dot, json, png and svg, and --skip-test-targets and --skip-external-dependencies cut the noise.

A script that fails CI on a forbidden edge

The tools already reject the cycle. What we want to catch is the edge one step before it: Profile → Booking alone is not a cycle and SwiftPM accepts it, until somebody adds the opposite direction. The rule is simple: features may not depend on each other, interfaces may depend on core only, core cannot see upwards.

#!/usr/bin/env python3
"""Fails the build when the module graph contains a forbidden edge.
Usage: swift package describe --type json | Scripts/check-module-graph.py
"""
import json
import re
import sys

# Layers from bottom to top. Order matters: first match wins.
LAYERS = [
    ("core",      re.compile(r"^(TravelModels|Navigation|Analytics|Networking|DesignSystem)$")),
    ("interface", re.compile(r"Interface$")),
    ("app",       re.compile(r"^AppComposition$")),
    ("feature",   re.compile(r".*")),
]

# Which layer may depend on which.
ALLOWED = {
    "core":      {"core"},
    "interface": {"core"},
    "feature":   {"core", "interface"},
    "app":       {"core", "interface", "feature"},
}

# Known exceptions with a ticket: (from, to). This list may only shrink.
BASELINE = set()


def layer_of(name):
    return next(layer for layer, pattern in LAYERS if pattern.search(name))


package = json.load(sys.stdin)
targets = [t for t in package["targets"] if t["type"] != "test"]
violations = []

for target in targets:
    source = target["name"]
    for dependency in target.get("target_dependencies") or []:
        if (source, dependency) in BASELINE:
            continue
        if layer_of(dependency) not in ALLOWED[layer_of(source)]:
            violations.append(
                f"{source} ({layer_of(source)}) -> {dependency} ({layer_of(dependency)})"
            )

if violations:
    print("Forbidden module dependencies:", file=sys.stderr)
    for violation in violations:
        print(f"  {violation}", file=sys.stderr)
    sys.exit(1)

print(f"Module graph is clean: {len(targets)} targets checked.")

When we added a Booking dependency to Profile and a BookingInterface dependency to Analytics in the sample package, the script exited with 1 and printed:

Forbidden module dependencies:
  Profile (feature) -> Booking (feature)
  Analytics (core) -> BookingInterface (interface)

The script only looks at target edges within one package. If your modules are spread over several packages, run it per package and bring the product_dependencies field into the rule as well. If you work with Xcode targets, you can feed the same table from tuist graph --format json; check the schema of that JSON in your own Tuist version. We cover other ways of enforcing architecture rules with tooling in a separate article.

Breaking an existing cycle in a live project, in safe steps

In a live project a cycle rarely exists as a declared cycle, because the tools do not allow it. It shows up in one of three disguises: a single target that cannot be split, a swollen Common where everything two features both needed was dumped, or runtime tricks such as NSClassFromString and a string-keyed service locator. The Common module is the graveyard of unresolved cycles.

We keep every step shippable as its own PR:

  1. Count the real edges. For the direction you want to break, list the touch points: grep -rn "BookingDetailViewController\|BookingRepository" Sources/Profile. The direction with the smaller count is the one you break.
  2. Freeze the graph. Add the script above to CI and write the existing violations into BASELINE. From that day no new forbidden edge gets in.
  3. Move the models down first. Edges that exist only because of a type disappear without any change in behaviour; this is the lowest-risk PR.
  4. Add the interface module and make the existing type conform. BookingFeature implements the protocol, but nobody uses the protocol yet. Behaviour is unchanged.
  5. Convert the callers one at a time. Each screen uses the injected BookingScreenBuilding in place of BookingDetailViewController(...). One PR per screen, each one testable.
  6. Delete the import and the dependency in the manifest. When the last import Booking is gone, remove the edge from Package.swift and take the line out of BASELINE.

Two things tell you it worked. The script is green and BASELINE is one line shorter. More concretely: add a blank line to a view file inside Booking, build, and look at the build log. There should be no compile step for Profile. If there is one, you still depend on the implementation somewhere.

The trap on the other side: an interface forest where nobody can find anything

Some teams that learn these techniques open an Interface sibling for every module by reflex. Sixty features become a hundred and twenty modules, answering "where is this protocol implemented" takes three file hops, and Xcode's "Jump to Definition" drops you at the protocol every time. The module count has a cost of its own, which we look at in the cost of modularization.

This is where we draw the line:

  • An interface module is created when a second feature that uses it appears. Leaf features that nobody calls do not get one.
  • An interface contains protocols and value types without behaviour. If we see default implementations in extensions, third-party dependencies or networking code, we send the PR back.
  • An interface does not import another interface. If that need arises, the shared type moves down into TravelModels.
  • When an interface passes ten protocols, we stop and question the boundary.

An interface module is a list of promises. If the list keeps growing, the problem is the boundary you drew.

In small teams and below ten modules, a router in the app layer plus models moved down is usually enough. Adding interface modules when the need shows itself is easier than opening all of them up front and deleting them later.

A checklist for Monday morning

  • We extracted the real target graph with swift package describe --type json (or tuist graph --format json).
  • The layers are written down: core, interface, feature, app, and who may depend on whom.
  • The forbidden-edge script runs in CI; existing violations are in BASELINE and each has a ticket.
  • No core module imports a feature or an interface.
  • For every edge between features we identified the cause: model, navigation, query or notification.
  • Edges that existed only because of a model were removed by moving the types down.
  • Mutual navigation goes through an interface protocol or a route type; the wiring is in the composition root.
  • Mutual dependencies in the object graph are resolved on first use through a closure.
  • Interface modules contain no implementation code and no third-party dependencies.
  • A change in one feature's implementation does not recompile the features that use it; we confirmed it in the build log.
Level 9 completeYour progress is kept in this browser.
Up next: Managing the compile-time dependency graph: iOS

Need support on this?

Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.

Comments and questions

Disagree with something, or stuck on this in your own project? Write to us and let's talk.