How to streamline your development workflow with pre-configured Kotlin Convention Plugins, custom Lint rules, and unified design system tokens.

Creating a new Android application from scratch in modern engineering is rarely as simple as clicking File → New → New Project in Android Studio.
Before writing a single line of business logic, developers face hours — or even days — of repetitive infrastructure setup:
- Configuring a scalable multi-module architecture.
- Setting up Gradle Version Catalogs and writing custom Kotlin Convention Plugins to eliminate build script duplication.
- Standardizing design tokens and dark theme support in Jetpack Compose.
- Wiring up Hilt Dependency Injection for Coroutine Dispatchers, network, and Application Scopes.
- Enforcing architectural boundaries and team coding standards using custom static analysis (Lint) rules.
Google’s official Now in Android (NiA) repository is the recognized gold standard for modern Android engineering. However, because NiA is a full-featured showcase app with domain logic (news feeds, authors, bookmarks, offline sync), using it as a starter kit is cumbersome: developers must strip out existing features while risking breaking build pipelines.
This Android Architecture Template bridges that gap: a clean, production-ready starter kit that extracts NiA’s best architectural patterns into an isolated foundation. To make it enterprise-ready with zero room for error, it includes a standalone automation tool. Instead of manual refactoring, run a single terminal command to rename and brand the entire architecture in seconds.
Acknowledgments & Credits
Core architectural decisions, build-logic convention plugins, and static analysis infrastructure in this template are directly inspired by Google’s official Now in Android (NiA) repository. Rather than reinventing the wheel, this template focuses on developer ergonomics—making Google’s best practices instantly usable out of the box.
1. Modular Project Architecture
The repository enforces a strict separation of concerns through a feature-based and layer-based multi-module setup:
├── app/ # Application entry point & root navigation graph
├── core/ # Shared domain, data, and infrastructure modules
│ ├── common/ # Kotlin utilities, Coroutines dispatchers & Result wrappers
│ ├── data/ # Repositories, network monitoring & data mappers
│ ├── database/ # Local persistence (Room database, entities & DAOs)
│ ├── datastore/ # User preferences & session persistence (DataStore)
│ ├── datastore-proto/ # Typed Proto DataStore schemas
│ ├── datastore-test/ # In-memory DataStore implementations for testing
│ ├── designsystem/ # Design system tokens, themes & Compose UI primitives
│ ├── domain/ # Business logic, repository contracts & Use Cases
│ ├── model/ # Pure domain models (zero framework dependencies)
│ ├── navigation/ # Type-safe navigation contracts & route arguments
│ ├── network/ # Retrofit/OkHttp clients, DTOs & network error handling
│ ├── testing/ # Test rules, Coroutine helpers, fakes & TestScope
│ └── ui/ # Reusable UI components & StateFlow extensions (stateInUi)
├── feature/ # Feature modules (API/Impl split or isolated UI screens)
├── build-logic/ # Centralized Kotlin Gradle convention plugins
└── lint/ # Custom static analysis detectors & architecture rules
Detailed Layer Breakdown
- app: Application entry point linking feature modules with global navigation.
- core: Isolated, reusable modules hosting core logic, infrastructure, and design specifications:
- core:common: Base Kotlin extensions, Coroutines Dispatchers, and state wrappers.
- core:model: Framework-independent domain data models.
- core:domain: Core business logic, interactor use cases, and repository interfaces.
- core:data: Single Source of Truth (SSOT) repositories unifying remote and local data.
- core:network: Retrofit client, OkHttp, DTO models, and network error handling.
- core:database: Local Room database configuration, tables, and DAOs.
- core:datastore / core:datastore-proto: Jetpack DataStore (Preferences & Proto) for persistent settings and tokens.
- core:designsystem: Design system tokens, color palettes, typography, and base UI primitives.
- core:ui: Reusable application UI elements and state extension helpers like stateInUi.
- core:navigation: Route definitions, navigation contracts, and argument abstractions.
- core:testing: Unit and integration testing utilities (MainDispatcherRule, TestScope, base fakes).
- core:datastore-test: In-memory test implementations for isolated DataStore tests.
- feature: Feature-specific UI and business logic modules, decoupled to optimize build parallelism.
- build-logic: Custom Gradle Convention Plugins written in Kotlin.
- lint: Custom static analysis rules enforcing architectural constraints and design system compliance.
2. Type-Safe Build Logic via Convention Plugins
Duplicating build scripts across dozens of build.gradle.kts files leads to configuration drift and maintenance burden. Following NiA’s pattern, all build logic, dependencies, Kotlin compiler parameters, and Android flags are centralized in build-logic/convention.
Pre-configured convention plugins include:
- AndroidApplicationComposeConventionPlugin
- AndroidLibraryComposeConventionPlugin
- AndroidFeatureImplConventionPlugin
- AndroidFeatureApiConventionPlugin
- AndroidRoomConventionPlugin
- HiltConventionPlugin
Adding a new module takes under a minute without repeating boilerplate Gradle configuration.
Base Kotlin and Android Configuration (configureKotlinAndroid)
Applies modern compiler options, target JVM versions, and Java 17 desugaring across all modules:
internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension<*, *, *, *, *, *>) {
commonExtension.apply {
compileSdk = 37
defaultConfig.minSdk = 26
compileOptions.apply {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
}
configureKotlin<KotlinAndroidProjectExtension>()
dependencies {
"coreLibraryDesugaring"(libs.findLibrary("android.desugarJdkLibs").get())
}
}
Key compiler flags configured in configureKotlin:
- JVM 17 Target: Applied across all Kotlin source sets.
- Experimental Coroutines API: Opt-in to kotlinx.coroutines.ExperimentalCoroutinesApi.
- Language Features: -Xcontext-parameters enabled for context parameters support.
Jetpack Compose Configuration (configureAndroidCompose)
Standardizes Jetpack Compose configurations, compiler metrics export, and component stability reports:
internal fun Project.configureAndroidCompose(commonExtension: CommonExtension<*, *, *, *, *, *>) {
commonExtension.apply {
buildFeatures.compose = true
dependencies {
val bom = libs.findLibrary("androidx-compose-bom").get()
"api"(platform(bom))
"androidTestImplementation"(platform(bom))
"implementation"(libs.findLibrary("androidx-compose-ui-tooling-preview").get())
"debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling").get())
}
}
extensions.configure<ComposeCompilerGradlePluginExtension> {
project.providers.gradleProperty("enableComposeCompilerMetrics").onlyIfTrue()
.relativeToRootProject("compose-metrics")
.let(metricsDestination::set)
project.providers.gradleProperty("enableComposeCompilerReports").onlyIfTrue()
.relativeToRootProject("compose-reports")
.let(reportsDestination::set)
stabilityConfigurationFiles.add(
isolated.rootProject.projectDirectory.file("compose_compiler_config.conf")
)
}
}
Product Flavors (configureFlavors)
Separates build configurations for local demo environments and production backends:
enum class FlavorDimension { contentType }
enum class AppFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) {
demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"),
prod(FlavorDimension.contentType),
}
fun configureFlavors(commonExtension: CommonExtension<*, *, *, *, *, *>) {
commonExtension.apply {
FlavorDimension.entries.forEach { flavorDimensions += it.name }
productFlavors {
AppFlavor.entries.forEach { flavor ->
register(flavor.name) {
dimension = flavor.dimension.name
if (commonExtension is ApplicationExtension && flavor.applicationIdSuffix != null) {
applicationIdSuffix = flavor.applicationIdSuffix
}
}
}
}
}
}
Automated Code Coverage with JaCoCo (configureJacoco)
Aggregates coverage metrics from unit tests and on-device instrumentation tests:
internal fun Project.configureJacoco(
commonExtension: CommonExtension<*, *, *, *, *, *>,
androidComponentsExtension: AndroidComponentsExtension<*, *, *>,
) {
commonExtension.buildTypes.named("debug") {
enableAndroidTestCoverage = true
enableUnitTestCoverage = true
}
androidComponentsExtension.onVariants { variant ->
tasks.register(
"create${variant.name.capitalize()}CombinedCoverageReport",
JacocoReport::class,
) {
executionData.setFrom(
project.fileTree("$buildDir/outputs/unit_test_code_coverage/${variant.name}UnitTest")
.matching { include("**/*.exec") },
project.fileTree("$buildDir/outputs/code_coverage/${variant.name}AndroidTest")
.matching { include("**/*.ec") },
)
}
}
}
3. Custom Lint Rules for Architecture Enforcement
To prevent team members from bypassing design system abstractions or testing conventions, the repository contains a dedicated :lint module with custom detectors derived directly from NiA.
1. Design System Detector (DesignSystemDetector)
Enforces using wrapped design system primitives (AppTheme, ProjectButton, ProjectTopAppBar) instead of raw Material 3 Compose components:
class DesignSystemDetector : Detector(), Detector.UastScanner {
override fun getApplicableUastTypes() = listOf(
UCallExpression::class.java,
UQualifiedReferenceExpression::class.java,
)
companion object {
val METHOD_NAMES = mapOf(
"MaterialTheme" to "AppTheme",
"Button" to "ProjectButton",
"OutlinedButton" to "ProjectOutlinedButton",
"NavigationBar" to "ProjectNavigationBar",
"CenterAlignedTopAppBar" to "ProjectTopAppBar",
)
val RECEIVER_NAMES = mapOf("Icons" to "ProjectIcons")
val ISSUE: Issue = Issue.create(
id = "DesignSystem",
briefDescription = "Design system enforcement",
explanation = "Highlights usages of standard Material composables instead of design system equivalents.",
category = Category.CUSTOM_LINT_CHECKS,
priority = 7,
severity = Severity.ERROR,
implementation = Implementation(DesignSystemDetector::class.java, Scope.JAVA_FILE_SCOPE),
)
}
}
2. Test Method Name Detector (TestMethodNameDetector)
Standardizes test naming conventions across the codebase:
- Unit Tests (PREFIX): Warns if a @Test method starts with a redundant test prefix and offers an automated quick-fix.
- Instrumentation Tests (FORMAT): Ensures methods in androidTest adhere to a structured given_when_then format.
class TestMethodNameDetector : Detector(), SourceCodeScanner {
override fun applicableAnnotations() = listOf("org.junit.Test")
private fun PsiMethod.detectPrefix(context: JavaContext, usageInfo: AnnotationUsageInfo) {
if (!name.startsWith("test")) return
context.report(
issue = PREFIX,
location = context.getNameLocation(this),
message = PREFIX.getBriefDescription(RAW),
quickfixData = LintFix.create()
.name("Remove prefix")
.replace().pattern("""test[s_]*""").with("").autoFix().build(),
)
}
}
4. Reactive State Abstractions and Data Tooling
Sealed Result Hierarchy and Data Flow Handling (Result<T>)
The :core:common module provides a standardized model for safely wrapping and processing reactive Flow streams:
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable) : Result<Nothing>
data object Loading : Result<Nothing>
}
fun <T> Flow<T>.asResult(): Flow<Result<T>> = map<T, Result<T>> { Result.Success(it) }
.onStart { emit(Result.Loading) }
.catch { emit(Result.Error(it)) }
Practical example of using asResult() for user state transformation:
private fun newsUiState(
topicId: String,
userNewsResourceRepository: UserNewsResourceRepository,
userDataRepository: UserDataRepository,
): Flow<NewsUiState> {
val newsStream: Flow<List<UserNewsResource>> = userNewsResourceRepository.observeAll(
query = NewsResourceQuery(filterTopicIds = setOf(topicId)),
)
val bookmark: Flow<Set<String>> = userDataRepository.userData
.map { it.bookmarkedNewsResources }
return combine(newsStream, bookmark, transform = ::Pair)
.asResult()
.map { newsToBookmarksResult ->
when (newsToBookmarksResult) {
is Result.Success -> NewsUiState.Success(newsToBookmarksResult.data.first)
is Result.Loading -> NewsUiState.Loading
is Result.Error -> NewsUiState.Error
}
}
}
sealed interface NewsUiState {
data class Success(val news: List<UserNewsResource>) : NewsUiState
data object Error : NewsUiState
data object Loading : NewsUiState
}
Data Mapping with EntityMapper
To maintain strict boundaries between network DTOs, local Room entities, and pure domain models, an explicit mapping interface is enforced across layers:
interface EntityMapper<Entity, Domain> {
fun asEntity(domain: Domain): Entity
fun asDomain(entity: Entity): Domain
}
Real-Time Network Monitoring (NetworkMonitor)
Network connectivity tracking is implemented in :core:data using callbackFlow powered by injected Coroutine Dispatchers:
internal class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context,
@Dispatcher(ArchitectureTemplateDispatcher.IO) private val ioDispatcher: CoroutineDispatcher,
) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow { ... }
.flowOn(ioDispatcher)
.conflate()
private fun ConnectivityManager.isCurrentlyConnected(): Boolean {
val networkCapabilities = getNetworkCapabilities(activeNetwork) ?: return false
return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
Simplified Flow to StateFlow Conversion (stateInUi)
Located under com.architecturetemplate.core.ui, a standard extension is provided to convert a cold Flow into UI state. Instead of repeatedly declaring boilerplate parameters for stateIn(), the stateInUi extension accepts a CoroutineScope and an initial value while encapsulating a safe timeout for unsubscribing when the app goes into the background:
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
/**
* Standard 5-second timeout to keep upstream alive during configuration changes.
*/
const val DEFAULT_SUBSCRIBE_TIMEOUT_MS = 5_000L
/**
* Converts a cold [Flow] into a hot [StateFlow] optimized for the Android UI layer.
* Uses [SharingStarted.WhileSubscribed] strategy by default to save resources in the background.
*/
fun <T> Flow<T>.stateInUi(
scope: CoroutineScope,
initialValue: T,
stopTimeoutMillis: Long = DEFAULT_SUBSCRIBE_TIMEOUT_MS,
): StateFlow<T> = stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = stopTimeoutMillis),
initialValue = initialValue
)
Usage inside a ViewModel becomes concise, leak-safe, and fully unit-testable:
val feedUiState: StateFlow<NewsFeedUiState> =
userNewsResourceRepository.observeAllBookmarked()
.map<List<UserNewsResource>, NewsFeedUiState> { NewsFeedUiState.Success(it) }
.onStart { emit(NewsFeedUiState.Loading) }
.stateInUi(
scope = viewModelScope,
initialValue = NewsFeedUiState.Loading
)
5. Flexible Custom Design System (AppTheme)
Rather than relying strictly on standard Material 3 roles, :core:designsystem provides an isolated design system via CompositionLocalProvider. This grants complete control over custom typography, color palettes, spacing metrics, and shapes.
Custom AppTheme Implementation
object AppTheme {
private val lightModeColors = AppColor(...)
private val darkModeColors = AppColor(...)
val colors: AppColor
@ReadOnlyComposable
@Composable
get() = LocalAppColor.current
val typography: AppTypography
@ReadOnlyComposable
@Composable
get() = AppTypography(...)
val sizes: AppSizes
@ReadOnlyComposable
@Composable
get() = AppSizes(...)
val shapes: AppShapes
@ReadOnlyComposable
@Composable
get() = AppShapes(...)
@Composable
operator fun invoke(
isDarkMode: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (isDarkMode) darkModeColors else lightModeColors
CompositionLocalProvider(
LocalAppColor provides colors,
LocalAppTypography provides typography,
LocalAppSize provides sizes,
LocalAppShapes provides shapes,
content = content
)
}
}
Consuming AppTheme in UI Components
@Composable
fun UserCard(
name: String,
role: String,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
shape = AppTheme.shapes.container,
color = AppTheme.colors.cardContainer,
contentColor = AppTheme.colors.onCardContainer
) {
Column(
modifier = Modifier.padding(all = AppTheme.sizes.xl) // 16.dp
) {
Text(
text = name,
style = AppTheme.typography.title,
color = AppTheme.colors.textPrimary
)
Spacer(modifier = Modifier.height(AppTheme.sizes.m)) // 8.dp
Text(
text = role,
style = AppTheme.typography.body,
color = AppTheme.colors.textSecondary
)
}
}
}
Component Verification via PreviewWrapper
A configurable PreviewWrapper allows testing system font scaling and dark mode switching directly in Android Studio’s preview panel:
@Composable
fun PreviewWrapper(
isDarkMode: Boolean = false,
fontScale: Float = 1.0f,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalDensity provides Density(density = LocalDensity.current.density, fontScale = fontScale)
) {
AppTheme(isDarkMode) {
content()
}
}
}
Example usage in Compose preview functions:
@Preview(showBackground = true, name = "Light Mode")
@Composable
fun UserCardLightPreview() {
PreviewWrapper(isDarkMode = false, fontScale = 1.0f) {
Box(modifier = Modifier.padding(all = 16.dp)) {
UserCard(name = "John Doe", role = "Android Developer")
}
}
}
@Preview(showBackground = true, name = "Dark Mode")
@Composable
fun UserCardDarkPreview() {
PreviewWrapper(isDarkMode = true, fontScale = 1.0f) {
Box(modifier = Modifier.padding(all = 16.dp)) {
UserCard(name = "John Doe", role = "Android Developer")
}
}
}
@Preview(showBackground = true, name = "Large Font (1.5x)")
@Composable
fun UserCardLargeFontPreview() {
PreviewWrapper(isDarkMode = false, fontScale = 1.5f) {
Box(modifier = Modifier.padding(all = 16.dp)) {
UserCard(name = "John Doe", role = "Android Developer")
}
}
}
6. Coroutine Dispatcher & Scope Management
To avoid hardcoded dispatchers and enable seamless unit testing, dispatchers and global application scopes are managed through explicit Dagger/Hilt qualifiers:
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Dispatcher(val architectureTemplateDispatcher: ArchitectureTemplateDispatcher)
enum class ArchitectureTemplateDispatcher {
IO, Default, Main, Unconfined
}
Hilt module for providing dispatchers:
@Module
@InstallIn(SingletonComponent::class)
internal object DispatchersModule {
@Provides
@Dispatcher(ArchitectureTemplateDispatcher.IO)
fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Dispatcher(ArchitectureTemplateDispatcher.Default)
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}
For tasks requiring a lifecycle longer than individual UI screens, an application-scoped CoroutineScope backed by a SupervisorJob is injected:
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class ApplicationScope
@Module
@InstallIn(SingletonComponent::class)
object CoroutinesScopesModule {
@Provides
@Singleton
@ApplicationScope
fun providesApplicationScope(
@Dispatcher(ArchitectureTemplateDispatcher.Default) dispatcher: CoroutineDispatcher
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
}
🛠️ 7. Automated Project Setup (Quick Start)
The core value proposition of this template is elevating an architectural starting point into an automated, fail-safe open-source product. Developers cloning the project do not need to manually refactor dozens of module manifests or package declarations.
This repository is configured as a GitHub Template. Click “Use this template” in the upper corner of the repository to generate a fresh copy.
After cloning, execute the smart automation script directly from Android Studio’s built-in Terminal:
py init_project.py com.newbrand.app
Why This Automation Makes Setup Seamless:
- Lightning-Fast Onboarding: Replaces all occurrences of the base package across build.gradle.kts, AndroidManifest.xml, and Kotlin source files in seconds.
- Intelligent Build Plugin Isolation: Isolates plugins { … } blocks and convention imports in Gradle scripts. It renames namespace and applicationId without breaking custom build-logic dependencies.
- Hybrid Source Root Support: Restructures physical directory paths on disk, handling both src/main/java and src/main/kotlin structures.
- Dynamic Refactoring (Typo Resilience): If a typo is entered during initial setup, re-running the script dynamically reads the current codebase state, detects the mismatch, and cleanly applies the corrected package name.
- Structure & Design System Protection: Skips empty placeholder directories (preserving Git structure for future feature modules), updates the :lint module without corrupting internal AST inspection rules, and safeguards ui.theme imports from breaking.
Note: Once the project sync completes successfully in Android Studio, init_project.py can be safely deleted from the root directory.
Conclusion
This architecture template solves the “blank canvas” problem in modern Android development. Instead of spending days configuring Gradle convention plugins, DI scopes, and multi-module boundaries, you receive a strict, scalable foundation out of the box. By extracting the core infrastructure from Google’s Now in Android and pairing it with a seamless automation script, it allows your team to skip the setup boilerplate and immediately start writing business logic that matters.
The complete source code and configuration files are available in the Android-Architecture-Template repository on GitHub.
Forget the “Setup Tax”: A Production-Ready Multi-Module Android Template was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.