Skip to content
tezvyn:

Decoupled Cross-Module Navigation in Modular iOS

MediumHow cards are made

Summary

decoupling feature modules.

Key points

depend on a shared abstraction, register concrete routers in a coordinator or registry, resolve at runtime.

Watch out for

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.

Interview question

Feature module A must open a screen in module B without compiling against B. Which approach preserves decoupling and type safety?

  • a.Merge A and B into one module to remove the dependency entirely
  • b.Import B's view controller directly but only inside a #if DEBUG block
  • c.Use a global string-keyed lookup table with no shared contract
  • d.A depends on a shared routing protocol; the app's composition root injects B's concrete routerCorrect
Why?

Dependency inversion through a shared abstraction lets A call navigation without importing B, while the composition root supplies B's implementation. String-keyed lookups lose type safety, and merging modules destroys the modularity you wanted.

Just read this? Test yourself on what you have been reading.

Put your scrolling time to good use

Learn one idea, try a quiz and save useful cards for revision. Tezvyn makes it easy to learn and stay current in your tech field, a few minutes at a time.

The iPhone app is on the way

We are building it. Until it lands, nothing here is held back from you: every interview card, your saved cards, streaks and the job board all work in Safari, plus hundreds of free practice quizzes of thirty questions each. Sign in and it all carries over to the app the day it arrives.

Want it as an icon? Tap Share at the bottom of Safari, then Add to Home Screen. It opens full screen and the cards you have read stay available offline.

Get it on Google PlayiPhone app coming soon

We are hiring for this. Open roles that interview on ios — each one lists the topics its interview covers.

See open roles