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.