
Every Android architecture diagram looks clean on day one. Features in boxes, arrows pointing down, a note in the README saying “features must not depend on each other.”
Then a deadline arrives. Someone needs the user’s avatar on the checkout screen, the profile feature already has a UserRepository, and it’s one import away. The build is green, the review is busy, and the arrow now points sideways. Six months later nobody can change the profile feature without breaking checkout.
Folders and naming conventions describe an architecture. They don’t enforce it. A rule that only lives in a README is a suggestion.
This article takes six rules a modular Compose app usually lives by, and moves each one out of the README and into the build:
- Only the app may depend on an implementation. Enforced by a Gradle check.
- A module’s internals stay private. Enforced by Kotlin’s internal.
- Only a feature may build its own routes. Enforced by internal routes and launchers.
- Every dependency has a definition. Enforced by Koin’s compiler plugin.
- Every failure is handled. Enforced by sealed types.
- Every feature and route is registered. Enforced by a source check that runs in CI.
Where a rule can’t be enforced, the last section makes the right way the easy way instead.
The shape: every capability is two modules
Each capability becomes a pair of Gradle modules:
- -api says what the capability can do. Interfaces only.
- -impl says how. Screens, view models, repositories, DTOs, wiring.
app/ the only module that sees implementations
features/
posts/posts-api PostsApi, PostsLauncher
posts/posts-impl screens, view models, data, Koin module
counter/counter-api
counter/counter-impl depends on posts-api, never posts-impl
core/
designsystem/ tokens, theme, components
statemanager/ AppStateViewModel
router/router-api | impl Navigation 3
network/network-api | impl Ktor
logger/logger-api | impl Kermit
Dependencies only point down: app → features → core. Features reach each other only through an -api. When checkout needs the user’s avatar, it depends on profile-api and asks through an interface. It never sees UserRepository, and profile is free to rewrite it.
Rule 1: only the app may depend on an implementation
This is the rule everything else rests on. It makes app the composition root: the one place that knows which implementation stands behind each interface. Every other module is written against contracts.
A few lines in a convention plugin check every project dependency as it’s declared:
internal fun Project.applyArchitectureGuard() {
val self = path
configurations.configureEach {
val configurationName = name
dependencies.withType(ProjectDependency::class.java).configureEach {
val target = path
val violation = when {
target == self -> null // a module's own test classpaths
target == ":app" -> "nothing may depend on :app"
target.endsWith("-impl") && self != ":app" ->
"only :app may depend on an -impl module; depend on its -api instead"
else -> null
}
if (violation != null) {
throw GradleException(
"Architecture rule broken in $self ($configurationName -> $target): $violation.",
)
}
}
}
}
Add implementation(project(“:features:posts:posts-impl”)) to the counter feature, and the build stops during configuration, before it compiles a single file:
Architecture rule broken in :features:counter:counter-impl
(implementation -> :features:posts:posts-impl):
only :app may depend on an -impl module; depend on its -api instead.
The message says what went wrong and what to do instead. The review conversation never has to happen.
Because the check runs on configureEach, it covers every configuration: implementation, api, testImplementation, and any a plugin adds later. And because the rule is about module paths, there’s no list to maintain.
Every module gets the guard without asking
A guard only helps if every module has it, and twenty modules with twenty copies of the same build setup will drift apart. So the shared setup lives in build-logic/ as convention plugins, and the Android library and application plugins both call applyArchitectureGuard().
A feature’s build file then says only what is particular to it:
plugins {
id("modular.feature.impl")
}
dependencies {
implementation(project(":features:posts:posts-api"))
implementation(project(":core:network:network-api"))
}
modular.feature.impl brings the Android library setup (and with it the guard), Compose, Koin, serialization, detekt, the design system, the state manager, the router contract, the logger contract and the test libraries. Even the Android namespace is derived from the module path: :features:posts:posts-impl becomes com.example.modularapp.features.posts.impl.
Rule 2: a module’s internals stay private
Kotlin’s internal means “visible inside this Gradle module only.” In an -impl module, almost everything is internal: routes, view models, repositories, DTOs.
internal class PostsRepositoryImpl(private val client: NetworkClient) : PostsRepository
The guard stops the wrong dependency from being declared. internal makes sure that even a module allowed to depend on posts-impl, which is only app, finds nothing there except the few classes it needs to wire the feature in.
Rule 3: only a feature may build its own routes
Navigation 3 has a pleasantly simple model: the back stack is a plain observable list of keys, and NavDisplay shows whichever composable belongs to the last key. Going forward adds a key, going back removes one.
The modular question is: who is allowed to create a key? If any feature can build PostDetailsRoute(42), every feature depends on how posts arranges its screens. So routes are private:
// posts-impl: no other module can see these
@Serializable
internal data object PostsRoute : AppRoute
@Serializable
internal data class PostDetailsRoute(val postId: Int) : AppRoute
Other features get a route from the feature’s launcher, published in its -api:
// posts-api
interface PostsApi {
val launcher: PostsLauncher
}
interface PostsLauncher {
fun posts(): AppRoute
fun postDetails(postId: Int): AppRoute
}
The launcher returns a route instead of navigating. The launcher knows where, and the caller decides how: push it, replace the current screen with it, or make it the new root.
// counter-impl, handling an effect from its view model
CollectEffects(viewModel.effects) { effect ->
when (effect) {
// Our own screen: use the route directly.
is CounterEffect.OpenDetails -> navigator.push(CounterDetailsRoute(effect.count))
// Another feature: only through its launcher.
CounterEffect.OpenPosts -> navigator.push(postsLauncher.posts())
}
}
Try to write navigator.push(PostDetailsRoute(42)) in the counter feature and it doesn’t compile. The route isn’t visible.
Each -impl contributes one ModuleRouter that maps its routes to screens. It’s also the one place the feature’s view models are built:
class PostsModuleRouter internal constructor(
private val repository: PostsRepository,
) : ModuleRouter {
override fun PolymorphicModuleBuilder<NavKey>.registerRoutes() {
subclass(PostsRoute::class)
subclass(PostDetailsRoute::class)
}
override fun EntryProviderScope<NavKey>.entries() {
entry<PostsRoute> {
PostsScreen(viewModel = viewModel { PostsViewModel(repository) })
}
entry<PostDetailsRoute> { route ->
PostDetailsScreen(viewModel = viewModel { PostDetailsViewModel(route.postId, repository) })
}
}
}
Two details matter here.
- registerRoutes() is what lets the back stack survive process death. Routes are @Serializable, and because they are private, each feature registers its own subclasses. The app merges them into one serializer.
- Each back-stack entry gets its own ViewModelStore, through Navigation 3’s view model decorator. viewModel { … } inside an entry creates a view model that lives exactly as long as that screen stays on the stack.
Navigation is an effect, never a call
Screens follow one State / Event / Effect loop, on the plain AndroidX ViewModel:
user taps ──▶ Event ──▶ ViewModel ──▶ new State ──▶ screen redraws
└────────▶ Effect ────▶ screen navigates
A view model never holds a navigator or a Context. It says what should happen, as an effect, and the screen does it. Effects travel through a buffered channel, so one sent while the screen is in the background is delivered when it comes back instead of being lost.
Rule 4: every dependency has a definition
Classic Koin resolves everything at runtime: a missing definition is a crash the first time that code path runs. Koin’s K2 compiler plugin moves that check into the build.
Each -impl has one Koin module. The definitions are functions, so the classes themselves carry no DI annotations at all:
@Module
class PostsKoinModule {
@Single
fun postsApi(): PostsApi = PostsApiImpl()
@Single
internal fun repository(client: NetworkClient): PostsRepository = PostsRepositoryImpl(client)
@Single(binds = [ModuleRouter::class])
internal fun moduleRouter(repository: PostsRepository): PostsModuleRouter = PostsModuleRouter(repository)
}
Read each function as a sentence: to provide a PostsRepository, I need a NetworkClient. The binds on the router lets the app collect every feature’s router with koin.getAll<ModuleRouter>(), without naming a single feature.
The app lists every module exactly once:
@Module(
includes = [
LoggerKoinModule::class,
NetworkKoinModule::class,
CounterKoinModule::class,
PostsKoinModule::class,
],
)
internal class AppKoinModule { /* … */ }
@KoinApplication(modules = [AppKoinModule::class])
internal object ModularKoinApplication
Remove NetworkKoinModule from that list and the app no longer compiles:
e: [Koin][KOIN-D001] Missing dependency: com.example.modularapp.core.network.NetworkClient
Three decisions are worth explaining.
- Why functions instead of annotated classes? @Single class PostsRepositoryImpl with component scanning is shorter, but then every class imports Koin and the wiring is spread across dozens of files. With functions, domain and data code stays plain Kotlin, and each capability’s wiring reads in one place.
- Why aren’t view models resolved from Koin? The router builds them with an ordinary constructor call. A post id comes from the route, is passed to the constructor, and the compiler checks it. Resolving the view model from the container would turn that into a runtime parameter lookup.
Rule 5: every failure is handled
Features never import Ktor. They see one interface, and every call returns a value instead of throwing:
interface NetworkClient {
suspend fun <T> get(
path: String,
response: DeserializationStrategy<T>,
query: Map<String, String> = emptyMap(),
): AppResult<T>
// post(…) likewise
}
sealed interface AppResult<out T> {
data class Success<out T>(val value: T) : AppResult<T>
data class Failure(val error: NetworkError) : AppResult<Nothing>
}
NetworkError is a sealed set: no connection, timeout, HTTP status, a body that doesn’t decode, unknown. The Ktor implementation catches everything and turns it into one of those, with one deliberate exception: coroutine cancellation is rethrown, so leaving a screen still cancels its request.
Each layer then speaks its own language. The repository translates network errors into the feature’s own failure type, exhaustively:
private fun NetworkError.toFailure(): PostsFailure = when (this) {
NetworkError.NoConnection -> PostsFailure.NoConnection
is NetworkError.Http -> if (code == HTTP_NOT_FOUND) PostsFailure.NotFound else PostsFailure.Unavailable
NetworkError.Timeout, is NetworkError.Unknown -> PostsFailure.Unavailable
is NetworkError.Serialization -> PostsFailure.Malformed
}
The view model keeps that failure in its state as a value. Only the screen, which has access to resources, turns it into a translated sentence. Because every step is a sealed when with no else, adding a new failure kind won’t compile until every layer handles it. There’s no “something went wrong” fallback hiding a case nobody thought about.
Rule 6: every feature and route is registered
Some mistakes have the right shape, compile perfectly, and still fail at runtime:
- A feature whose Koin module was never added to the app. Nothing asks for its types, so Koin’s graph looks complete. The feature just has no screens.
- A route missing from registerRoutes(). It navigates fine, then crashes the first time Android saves the back stack.
Neither Gradle nor the compiler can see these, so a Gradle task reads the sources and checks exactly them. ./gradlew doctor runs in CI on every pull request:
doctor found problems:
✖ route-not-registered: PostDetailsRoute (posts-impl) is missing from registerRoutes().
✖ feature-not-registered: UserProfileKoinModule is not in the app's includes.
It’s a deliberately small tool. It knows the project’s conventions, so it can check what a general-purpose linter never could.
Where the build can’t enforce, make the right way the easy way
Not every rule can be an error. For the rest, the goal is that doing the right thing takes less effort than doing the wrong one.
New features are generated, not copied. The easiest way to avoid wiring mistakes is not to wire by hand. ./gradlew newFeature –name=user-profile generates both modules: facade, launcher, route, route table, Koin module, view model, screen and a test. It also adds the feature to the app. The generated code passes detekt and doctor as it is.
The design system does the accessibility work. It’s built on Compose Foundation rather than Material: a small set of tokens (colours, spacing, radii, typography, motion) provided through composition locals and read through one object:
AppText(text = post.title, style = AppTheme.typography.title)
The tokens use staticCompositionLocalOf. A dynamic composition local tracks every place that reads it, so a change recomposes only those readers. A static one skips that tracking, and a change recomposes the whole subtree instead. For theme tokens that’s exactly right: they’re read everywhere and change only when the whole theme does.
What it costs
This isn’t free, and pretending otherwise would be a disservice.
- More modules. Two per feature, plus a composition root you have to keep current. Gradle’s configuration cache and build cache keep it fast, but it’s more to navigate.
- Indirection. Opening another feature’s screen goes through an interface and a launcher rather than a direct call. That’s the point, but it’s one more hop when reading code.
My rule of thumb: this pays off from roughly ten features, or two teams working in the same app. Below that, a single module with good discipline is probably fine. Above it, a build that enforces the boundaries is worth far more than it costs.
Everything above is in the open-source modular-compose-template, which opens on a counter screen that browses posts from JSONPlaceholder. The screens are simple on purpose; the wiring around them is the point. If you try any of these rules on a real project, I’d like to hear where they helped and where they got in the way.
Modular Jetpack Compose: Don’t Leave Your Architecture to Code Review was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.