Decoupled Cross-Module Navigation in Modular iOS
decoupling feature modules.
depend on a shared abstraction, register concrete routers in a coordinator or registry, resolve at runtime.
importing module B directly or coupling features through a god object.
WHAT THIS TESTS This evaluates your understanding of modularization, the dependency inversion principle, and how to keep feature modules independently buildable and testable.
A GOOD ANSWER COVERS The key is dependency inversion. Create a thin shared module, sometimes called Interfaces or Routing, that both features can depend on but which depends on neither. There you declare an abstraction, for example a protocol like ProfileRouting or a route value identifying B's screen. Module A depends only on this abstraction and asks it to navigate, never importing B. A coordinator pattern or a route registry, assembled at the composition root, the app target that knows about every module, holds B's concrete router and fulfills A's request. Common implementations include a coordinator hierarchy, a registry mapping route identifiers to factory closures, or a deep-link router that parses a URL-like route. The concrete dependency is injected at startup, so A and B compile in isolation.
COMMON WRONG ANSWERS Importing module B directly into A, which reintroduces the coupling. Putting all navigation in one massive coordinator that imports every feature, becoming a god object that defeats modularity. Using runtime string lookups with no shared contract, which loses type safety and fails silently.
LIKELY FOLLOW-UPS How do you pass data to B's screen without leaking its types into A? How does this enable feature teams to build in parallel? How would you unit test A's navigation with a mock router? How does this interact with SwiftUI navigation versus UIKit coordinators?
ONE CONCRETE EXAMPLE The Feed module needs to open a user's profile owned by the Profile module. A shared Routing module defines protocol AppRouter with func openProfile(userId: String). Feed receives an AppRouter via initializer injection and calls router.openProfile when a row is tapped. At the app's composition root, the concrete AppRouterImpl, which does import Profile, builds and presents the profile screen. Feed compiles without ever importing Profile, and tests inject a spy AppRouter to assert the call.
Get five bites like this every day.
Tezvyn delivers a daily feed of 60-second tech bites with quizzes to lock in what you learn.