2025-08-01 7 min read Software Engineer

What Modular Architecture Is Quietly Very Good At

When people talk about modular architecture, they usually mention the same benefits: faster builds, easier testing, clearer ownership. All true. But there’s one scenario where modularization quietly proves its worth, and it rarely comes up in architecture discussions: Merging two applications into one.

The Situation

A client wanted to consolidate two iOS apps into a single product. Both apps were in the automotive/EV charging space — finding stations, managing charging sessions, related features. Users were jumping between two apps to do things that should have lived in one place.

Both applications (a bit luckily, a bit intentionally 😅) had modular architectures. Both had similar dependencies. But they weren’t built together — different repositories, different timelines, slightly different decisions along the way.

My job was to migrate about a dozen feature modules from one app into the other. Solo dev. The whole process took around 2-3 months.

Why Modularization Made This Possible

Without modular architecture, this would have been a rewrite. You’d be untangling spaghetti, guessing at boundaries, and praying nothing breaks. With modules, you have units of work. Each feature is already isolated, already has defined inputs and outputs, already has its own tests.

The migration strategy was straightforward:

  • Feature modules: migrate as-is, adjust to fit the target app’s dependencies
  • Core/shared modules: use what the target app already has, only add what’s missing
  • UI components: same approach — add only what doesn’t exist

The modules from the source app worked. They were tested, battle-proven in production. The question wasn’t whether they were correct — it was whether they’d still be correct after adapting them to the target app’s architecture.

Here’s a subtle point: even if the dependency interfaces had been radically different — say, one app used reactive streams and the other used async/await, or they had completely different navigation paradigms — the structure of dependencies would likely still match. Well-executed modular architecture tends to converge on similar divisions of core dependencies: networking, data layer, analytics, navigation, feature flags. The interfaces might differ, but the seams are in the same places. That’s what makes migration possible without starting from scratch.

The Real Challenge: Reviewable Changes

Here’s what surprised me most: the hardest part wasn’t the migration itself. It was making the migration reviewable.

Think about it. You’re moving thousands of lines of code that already work. You’re adjusting navigation patterns, renaming things to match conventions, tweaking how modules connect to shared dependencies. If you dump all of that into a single pull request, no one can review it meaningfully. The diff is enormous, and 95% of it is “this code moved from there to here.”

What the team actually needed to verify was the 5% — the adjustments, the integration points, the places where I made decisions that could break things.

The solution was to split the work into two types of PRs:

1. Pure migration PRs — Move the code exactly as it is. No changes. The diff is huge, but the review is simple: “Does this match the source?” You can even automate parts of this verification.

The trick: exclude migrated sources from compilation. Both Tuist and XcodeGen support this with a single modification:

// Tuist - Project.swift
Target(
    sources: [
        .glob(
            pattern: "Sources/**", 
            excluding: "Sources/**/ExcludedFromBuild/**"
        )
    ]
)
# XcodeGen - project.yml
targets:
  MyApp:
    sources:
      - path: Sources
        excludes:
          - "Sources/**/ExcludedFromBuild/**"

This way, the code lands in the repo (reviewable as “does this match the source?”), but doesn’t break the build until you’re ready to wire it up.

2. Adaptation PRs — Now make the changes. Adjust navigation. Rename to match conventions. Wire up to different shared modules. These PRs are small and focused. Reviewers can actually reason about what changed and why.

Here’s what typical adaptation diffs looked like:

Swapping core dependencies — same concept, different interface:

// Before (App A's pattern)
-import AppANetworking
-@Injected private var api: APIClientType

// After (App B's pattern)  
+import NetworkingCore
+@Injected(\.networkClient) private var api: NetworkClient

Adapting analytics — both apps tracked events, just differently:

// Before
-analytics.record(event: .screenViewed(name: "PaymentSummary"))

// After
+analytics.log(event: .screenView(screenName: ScreenNames.paymentSummary))

Quick workaround when you know you’ll drop a dependency — sometimes the source app used something that won’t survive long-term. Instead of a proper rewrite, a thin adapter buys time:

// Temporary adapter - will be removed when we migrate to AppBLogger
struct LegacyLoggerAdapter: AppALoggerType {
    private let newLogger: AppBLogger
    
    func logNonFatal(_ error: Error) {
        // Bridge to new system, drop legacy error types
        newLogger.log(category: .migration, message: error.localizedDescription)
    }
}

This pattern lets you ship working code now and clean up later — without blocking the migration on a full rewrite of logging infrastructure.

This separation made the whole process tractable. Without it, we’d either skip meaningful review (risky) or burn weeks on reviews that couldn’t catch real issues anyway (wasteful).

The Human Factor

Both apps were built by different teams — different cultures, different levels of experience with modularization, different opinions about what “good architecture” looks like. The migration wasn’t just a technical exercise. Part of the work was soft conversations: understanding why certain decisions were made, explaining the constraints of the target app, finding compromises.

And sometimes you just adapt. Even when you’re convinced the original implementation was better, you adjust to match the target app’s conventions. Consistency matters more than local perfection. The goal is a coherent codebase, not a museum of “best practices” from two different eras.

The Tooling Gap

One thing I’d do differently: lean harder on AI-assisted engineering. This project happened in early 2025, when tools like Cursor were less mature — especially for iOS and complex multi-module setups. A lot of the mechanical work (renaming, adjusting imports, fixing build errors after dependency changes) could probably be accelerated significantly today guided by well defined rules for coding agents.

The pattern-matching nature of migration work seems like a good fit for AI assistance. You’re not inventing — you’re translating. That’s exactly where these tools shine.

The Broader Point

Modular architecture is usually sold as something that helps when your app gets big. And it does. But it also helps in scenarios you don’t plan for, like suddenly needing to merge two products, or extracting a feature into a separate app, or onboarding a team that only needs to understand one part of the system.

The investment in clear boundaries pays off in ways that are hard to predict upfront. App merging is just one example. The pattern is: anything that requires moving, extracting, or combining code becomes dramatically easier when that code is already organized into self-contained units.

If you’re on the fence about modularization because your app “isn’t that big yet”, consider that size isn’t the only variable. Organizational changes, product pivots, acquisitions, these things happen. And when they do, you’ll be glad your codebase has seams.

This kind of migration being achievable by a solo developer in 2–3 months wasn’t just “good execution”. It was only realistically reviewable and safe because both codebases were already modular. Sure, you can always duct-tape two apps together in a less structured setup, but it’s far less likely anyone can meaningfully get through the changes in code review, keep high confidence in runtime behavior, and avoid piling on a new layer of technical debt at the same time.