Modular Architecture at Scale

The Problem: Monolith Paralysis

Last year, we inherited a 200K+ line Android codebase. Single monolithic module. 8-minute builds. Tests that were more aspirational than actual (coverage: 12%). Teams couldn't work independently – changes to one screen had a way of breaking another. Deploy frequency? Weeks. Feature velocity? Crawling.

The team knew something had to change. We couldn't scale feature delivery with this structure.

Why Modularization?

The goals were concrete:

  • Reduce build times by 50%+ – Parallel module builds, incremental compilation.
  • Enable independent feature development – Teams own their modules, deploy without coordination.
  • Improve testability – Smaller modules, faster unit tests, higher coverage.
  • Make the codebase navigable – Clear boundaries, explicit dependencies, easier onboarding.

The Target Architecture

We designed a three-layer modular structure:

1. Core Layer

Shared infrastructure – no business logic, no UI:

  • core-network – Retrofit, OkHttp, API clients, DTOs.
  • core-database – Room setup, DAOs, database schema.
  • core-ui – Design system, reusable UI components, themes.
  • core-analytics – Event tracking, crash reporting.

2. Feature Layer

Independent, self-contained features:

  • feature-auth – Login, signup, password reset.
  • feature-dashboard – Home screen, analytics widgets.
  • feature-payments – Payment flows, transaction history.
  • feature-settings – User preferences, account management.

3. App Layer

Brings it all together:

  • app – Main app module, navigation graph, dependency injection setup.

Simple structure. Each feature owns its UI, domain logic, and data access. Minimal coupling through interfaces.

The Migration Strategy

We didn't do a "big bang" refactor. Instead:

Phase 1: Extract Core

First, we pulled out all infrastructure into core modules. No business logic – just APIs, database setup, UI components. This took 2 weeks and was low-risk.

Phase 2: One Feature at a Time

Then we started extracting features from the monolith. Started with the simplest: feature-auth. Took 3 days. Once it worked, the pattern was clear.

Phase 3: Connect the Dots

Finally, we set up the app module with a proper navigation graph and dependency injection (we used Hilt). Features expose interfaces; the app module wires them together.

Key Technical Decisions

Use implementation not api

Each feature uses implementation for its dependencies. This hides transitive dependencies and keeps the API surface small.

// feature-payments build.gradle.kts
dependencies {
    implementation(project(":core:core-network"))
    implementation(project(":core:core-ui"))
    implementation(libs.retrofit)
    implementation(libs.hilt)
}

Domain Models Over DTOs

Each feature has its own domain model. The API layer (core-network) returns DTOs. The feature translates them. This keeps features independent.

Navigation via Interfaces

Features don't know about each other. Instead, the app module defines navigation interfaces:

// In app module
interface PaymentNavigation {
    fun navigateToPaymentFlow()
}

// Feature provides implementation
class PaymentNavigationImpl : PaymentNavigation { ... }

The Bumps Along the Way

Circular Dependencies

Early on, feature-dashboard wanted to reference feature-payments. That created a circle. We fixed it by introducing a payment-api module that both could reference.

Shared Models

Both payments and dashboard needed a Transaction model. We put shared domain models in core-models. Solves the problem cleanly.

Gradle Build Cache

First attempt: each module built independently, but we didn't set up Gradle's build cache. Discovered this when local builds were fast but CI was slow. Fixed it – CI now leverages the cache.

The Results

Build time: 8 minutes → 3 minutes (62% reduction)
Clean builds went from 8 to 10 minutes (parallel module builds FTW).

Test coverage: 12% → 75% (in refactored modules)
Smaller modules meant faster tests, so developers ran them locally.

Feature velocity: +40% in first quarter
Teams could work in parallel; new payment feature shipped in 2 weeks (used to take 4).

Lessons Learned

1. Don't Over-Modularize Too Early

We created core-analytics early because we thought we'd need it. Turned out most features didn't use it. Start with problems, not predictions.

2. Ownership Matters

Once each team owned their feature module, quality improved. They cared about keeping the API clean, the tests passing, the code maintainable.

3. Invest in DI Setup

Hilt made this possible. Without dependency injection, wiring modules together would've been a nightmare. Worth the upfront time.

What's Next

We're monitoring build times – targeting 90 seconds for incremental builds. We're also exploring dynamic feature modules for rarely-used features. And we're documenting the module structure so new engineers understand the mental model quickly.

The bigger win? Teams ship faster. The architecture supports 3x the feature delivery. And the codebase is actually fun to work with.

Have thoughts on modularization? Reach out on LinkedIn.