
A practical guide to enforcing colors, spacing, typography, shapes, and component conventions in Jetpack Compose — with a custom Android Lint rule.
A design system is only as reliable as the rules that prevent teams from bypassing it.
Most Android teams have a design system.
They define typography, colors, spacing, shapes, dimensions, and reusable Compose components.
They document usage guidelines, publish component libraries, and ask developers to follow the established conventions.
Yet a few months into development, the same problems start appearing.
One screen uses 16.dp for horizontal padding; another uses 18.dp. A developer creates a custom TextStyle because the existing typography does not look quite right.
A feature introduces a hardcoded hex color. A button uses a different corner radius from the rest of the application. Someone builds another card component because the existing one seems inconvenient to reuse.
None of these decisions looks particularly dangerous in isolation. Together, they undermine the consistency the design system was supposed to provide.
Code reviews can catch some of these violations, but they are not a reliable enforcement mechanism. Reviewers have limited time, conventions are easy to overlook, and the same feedback gets repeated across pull requests.
The better approach is to treat the design system as an engineering contract and enforce that contract through automated checks.
For Android teams using Jetpack Compose, Konsist and custom Android Lint rules solve complementary parts of this problem. Konsist can enforce architectural and structural conventions. Android Lint can inspect source code for prohibited patterns. Compose UI tests can verify how the resulting interface behaves at runtime.
Together, they turn design-system conventions from documentation into executable rules.
Start by defining what the contract actually means
Before writing a test or a lint detector, identify the rules your team expects every feature to follow.
Not every design-system convention belongs in the same enforcement mechanism. Some rules concern module boundaries, some concern source-code expressions, and others concern the rendered interface.
For example:

The distinction matters because no single tool can enforce every kind of rule equally well.
Konsist is useful for structural constraints. Lint is better suited to detecting prohibited source patterns. Runtime tests validate behavior that cannot be established by examining source code alone.
The objective is not to add more checks. It is to put each rule in the enforcement layer where it can be checked reliably.
Build the design system around approved tokens
A design system needs a clear source of truth before it can be enforced.
Consider a Compose application with inconsistent spacing:
@Composable
fun ProductDetailsScreen() {
Column(
modifier = Modifier.padding(17.dp),
) {
Text(
text = "Product details",
fontSize = 22.sp,
)
Spacer(modifier = Modifier.height(13.dp))
Button(
onClick = {},
modifier = Modifier.height(47.dp),
) {
Text("Continue")
}
}
}
The problem is not that 17.dp, 22.sp, or 47.dp are inherently incorrect. The problem is that feature code is making independent design decisions instead of consuming the system’s established values.
Start by defining tokens.
Spacing and dimensions
package com.acme.designsystem.tokens
import androidx.compose.ui.unit.dp
object AppSpacing {
val xs = 4.dp
val sm = 8.dp
val md = 16.dp
val lg = 24.dp
val xl = 32.dp
}
object AppDimensions {
val buttonHeight = 48.dp
val iconSmall = 16.dp
val iconMedium = 24.dp
val iconLarge = 32.dp
}
Feature code can now communicate intent:
Column(
modifier = Modifier.padding(AppSpacing.md),
) {
// Screen content
}
The token name expresses the design decision. The actual value remains centrally managed.
If the design system changes its medium spacing from 16.dp to another value, the change can be made in one place rather than scattered across feature modules.
Typography
Material 3’s Typography provides a useful foundation for text roles.
package com.acme.designsystem.tokens
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val AppTypography = Typography(
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
),
)
These values are illustrative. A production design system should define typography roles from its actual design specifications, including font families, weights, line heights, letter spacing, and supported text variants.
The important principle is that feature code should use semantic roles rather than inventing its own typography.
Text(
text = "Product details",
style = MaterialTheme.typography.titleLarge,
)
Colors and shapes
Colors and shapes should follow the same principle. Use Material 3 color roles where they fit the design system:
Text(
text = "Available",
color = MaterialTheme.colorScheme.primary,
)
For custom brand colors, define approved values in a design-system-owned package and expose semantic names to consumers.
Likewise, define approved shapes centrally instead of allowing each feature to choose its own corner radius.
A token system creates the contract’s vocabulary. Automated checks make that vocabulary difficult to bypass.
Use Konsist to protect architectural boundaries
Konsist is a Kotlin architecture testing library. It lets teams write tests against package structure, dependencies, naming conventions, and other source-level architecture rules.
It is particularly useful when the design system is split into public APIs and internal implementation packages.
Imagine this package structure:
com.acme
├── feature
│ ├── home
│ └── product
└── designsystem
├── components
├── tokens
└── internal
└── palette
Feature modules should consume the public component and token APIs. They should not depend directly on implementation details under designsystem.internal.
A Konsist architecture test can express that boundary.
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.architecture.Layer
import org.junit.Test
class DesignSystemArchitectureTest {
@Test
fun `features must not depend on design system internals`() {
Konsist.scopeFromProject()
.assertArchitecture {
val features = Layer(
"Features",
"com.acme.feature..",
)
val designSystemInternal = Layer(
"Design System Internal",
"com.acme.designsystem.internal..",
)
features.doesNotDependOn(designSystemInternal)
}
}
}
The package patterns must match the actual package layout in your repository. The test should run against the intended project scope, and the Konsist version should be pinned and verified against the API used by your project.
The benefit is that architectural decisions become executable.
If a feature starts importing an internal palette or implementation class, the architecture test can fail before the change is merged.
What else can Konsist enforce?
The same approach can be applied to naming and package conventions.
For example, a team might require:
- Public design-system components to live under designsystem.components.
- Token definitions to remain inside the token package.
- Feature-specific UI components to live within their owning feature.
- Internal implementation types to follow an agreed naming convention.
- Architecture layers to maintain their intended dependency direction.
These checks help prevent structural drift.
However, there is an important limitation: a package architecture test does not automatically enforce every usage convention inside a composable. A feature can depend only on approved packages and still construct Color(0xFF1769E0) directly.
That is where Android Lint becomes useful.
Use custom Android Lint rules to catch design-system violations
Android Lint analyzes Android source code and reports potential errors and problems. It also provides APIs for writing custom detectors.
For a design system, custom Lint rules can inspect Kotlin source and flag patterns that violate the team’s conventions.
Examples include:
- Constructing colors from numeric literals in feature code.
- Using raw sp values instead of approved typography roles.
- Using raw dp values for spacing where tokens are required.
- Calling deprecated design-system APIs.
- Using unapproved component implementations.
- Referencing internal design-system symbols from feature packages.
Each rule should have a specific scope and a clear failure message. Broad rules that flag legitimate exceptions will create noise and eventually encourage developers to suppress the checks.
Let’s implement a focused example: detecting numeric color literals in Compose Color constructor calls.
Prohibit raw numeric color construction in feature code
Consider this code:
val accent = Color(0xFF1769E0L)
The color might match the current brand palette, but the feature now owns a design decision that should belong to the design system.
We can create a custom detector that flags this pattern outside approved packages.
The following example targets direct numeric literals passed to constructors of androidx.compose.ui.graphics.Color. It is a focused starter rule, not a comprehensive detector for every possible hardcoded color.
package com.acme.designsystem.lint
import com.android.tools.lint.detector.api.Category
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Implementation
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.JavaContext
import com.android.tools.lint.detector.api.Scope
import com.android.tools.lint.detector.api.Severity
import com.android.tools.lint.detector.api.SourceCodeScanner
import com.intellij.psi.PsiMethod
import org.jetbrains.uast.UCallExpression
import org.jetbrains.uast.ULiteralExpression
class NoHardcodedColorDetector : Detector(), SourceCodeScanner {
override fun getApplicableConstructorTypes(): List<String> =
listOf(COLOR_CLASS)
override fun visitConstructor(
context: JavaContext,
node: UCallExpression,
constructor: PsiMethod,
) {
val packageName = context.uastFile?.packageName.orEmpty()
if (isAllowedPackage(packageName)) return
val hasNumericLiteral = node.valueArguments.any { argument ->
argument is ULiteralExpression &&
argument.value is Number
}
if (!hasNumericLiteral) return
context.report(
ISSUE,
node,
context.getLocation(node),
"Use an approved design-system color or " +
"MaterialTheme.colorScheme instead of " +
"constructing a Color from a numeric literal.",
)
}
private fun isAllowedPackage(packageName: String): Boolean =
ALLOWED_PACKAGES.any { allowed ->
packageName == allowed ||
packageName.startsWith("$allowed.")
}
companion object {
private const val COLOR_CLASS =
"androidx.compose.ui.graphics.Color"
private val ALLOWED_PACKAGES = setOf(
"com.acme.designsystem.tokens",
"com.acme.designsystem.internal.palette",
)
@JvmField
val ISSUE: Issue = Issue.create(
id = "HardcodedDesignColor",
briefDescription = "Hardcoded design-system color",
explanation = """
Feature code must use approved design-system
colors or semantic MaterialTheme color roles.
Raw numeric color definitions belong in
approved design-system packages.
""".trimIndent(),
category = Category.CORRECTNESS,
priority = 8,
severity = Severity.ERROR,
implementation = Implementation(
NoHardcodedColorDetector::class.java,
Scope.JAVA_FILE_SCOPE,
),
)
}
}
There are three important parts to this detector.
First, getApplicableConstructorTypes() limits the calls that the detector needs to inspect to constructors for the Compose Color type.
Second, visitConstructor() checks the arguments for a numeric literal and skips packages explicitly allowed to define colors.
Third, the reported issue has Severity.ERROR. This means a violation is reported as an error by Lint. Whether it blocks a particular CI job depends on how that job runs and configures Lint.
The allowlist is intentional. A design system must be able to define its own palette. The rule prevents arbitrary feature-level definitions without preventing the central token implementation from doing its job.
The exact detector APIs should be verified against the Android Lint version selected for the project. Custom Lint is version-sensitive, so keep lint-api and lint-tests aligned with a compatible Android Gradle Plugin toolchain.
Register the issue
Lint needs an issue registry to discover custom issues.
package com.acme.designsystem.lint
import com.android.tools.lint.client.api.IssueRegistry
import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
class DesignSystemIssueRegistry : IssueRegistry() {
override val issues = listOf(
NoHardcodedColorDetector.ISSUE,
)
override val api: Int = CURRENT_API
override val minApi: Int = 8
override val vendor = Vendor(
vendorName = "Acme Engineering",
feedbackUrl = "https://example.com/engineering/issues",
contact = "mobile-platform@example.com",
)
}
Replace the example vendor information with your organization’s details.
The custom Lint module must also register the issue registry through the service loader. Create the following file:
design-system-lint/
└── src/main/resources/
└── META-INF/services/
└── com.android.tools.lint.client.api.IssueRegistry
The file contents should be the fully qualified registry class name:
com.acme.designsystem.lint.DesignSystemIssueRegistry
This allows Lint to discover the custom issue when it loads the custom rules artifact.
Configure the custom Lint module
A typical JVM-based custom Lint module needs the Lint API for compilation and the Lint test infrastructure for unit tests.
plugins {
kotlin("jvm")
}
dependencies {
compileOnly(
"com.android.tools.lint:lint-api:$lintVersion",
)
testImplementation(
"com.android.tools.lint:lint-tests:$lintVersion",
)
testImplementation("junit:junit:4.13.2")
}
Here, lintVersion represents a version chosen to be compatible with your Android Gradle Plugin and Lint environment. Do not select it independently of the Android build toolchain.
In an Android application module, consume the rules through the lintChecks configuration:
dependencies {
lintChecks(project(":design-system-lint"))
}
Once the module is included in the build, Lint can load the registered detector while analyzing the application.
Test the rule before enforcing it
A custom detector without tests is just another piece of code that can silently stop working.
Lint provides a testing infrastructure that lets you supply source snippets and assert the reported issues.
The following test class illustrates the expected structure for a detector test. Keep the Lint testing dependency aligned with the version used by the project, and verify the snippet against that exact version before adopting it in CI.
package com.acme.designsystem.lint
import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.LintDetectorTest
import org.junit.Test
class NoHardcodedColorDetectorTest : LintDetectorTest() {
override fun getDetector(): Detector =
NoHardcodedColorDetector()
override fun getIssues(): List<Issue> =
listOf(NoHardcodedColorDetector.ISSUE)
private val colorStub = kotlin(
"src/main/java/androidx/compose/ui/graphics/Color.kt",
"""
package androidx.compose.ui.graphics
class Color(val value: Long) {
constructor(value: Int) : this(value.toLong())
}
""".trimIndent(),
)
@Test
fun `flags numeric colors in feature code`() {
lint()
.files(
colorStub,
kotlin(
"src/main/java/com/acme/feature/home/Colors.kt",
"""
package com.acme.feature.home
import androidx.compose.ui.graphics.Color
val accent = Color(0xFF1769E0L)
""".trimIndent(),
),
)
.run()
.expectErrorCount(1)
.expectContains("[HardcodedDesignColor]")
}
@Test
fun `allows numeric colors in token package`() {
lint()
.files(
colorStub,
kotlin(
"src/main/java/com/acme/designsystem/tokens/AppColors.kt",
"""
package com.acme.designsystem.tokens
import androidx.compose.ui.graphics.Color
val primary = Color(0xFF1769E0L)
""".trimIndent(),
),
)
.run()
.expectClean()
}
}
The first test verifies that a numeric color constructor in a feature package produces an error. The second verifies that the same pattern is allowed in an approved token package.
The test stub supplies a minimal Compose Color type for the test environment. The actual detector still needs to be exercised against the Lint version and source configurations used by your project.
These tests also demonstrate a critical principle: test both the violation and the legitimate exception. Otherwise, a rule can appear effective while breaking the code it is supposed to protect.
Understand the detector’s limitations
This starter detector does not catch every way a color can bypass the design system.
For example, it does not comprehensively handle:
Color.Red
or a color stored in a constant, returned from a helper function, loaded from a resource, or passed through an alias.
It also does not prove that a named token is semantically appropriate for the component using it.
A production detector may need to inspect resolved symbols, additional constructor forms, property access, or other expressions. It should include tests for the exact patterns the organization wants to prohibit.
The right approach is to expand the detector incrementally, based on actual violations and the design-system contract — not to claim that a single syntax check guarantees complete color governance.
Extend the same approach to typography, spacing, and dimensions
Once the first custom rule is working, additional rules can enforce other design-system conventions.
Do not try to put every convention into one enormous detector. Give each rule a clear purpose, scope, and test suite.
Typography
A typography rule might flag direct fontSize assignments in feature code when the project requires semantic typography roles.
For example:
Text(
text = "Product title",
fontSize = 19.sp,
)
The preferred version would use a defined role:
Text(
text = "Product title",
style = MaterialTheme.typography.titleLarge,
)
The implementation needs to be precise about what it forbids. A blanket rule against every TextStyle or fontSize expression could flag valid cases, such as custom drawing, specialized editorial content, or internal design-system implementations.
Define whether the contract prohibits all direct text sizing or only certain patterns in designated feature packages.
Spacing and dimensions
A similar rule can detect raw dimension literals:
Modifier.padding(17.dp)
and guide developers toward tokens:
Modifier.padding(AppSpacing.md)
However, not every dp value is spacing. Dimensions can represent a component’s minimum touch target, a border width, an icon size, or a specialized layout constraint.
The detector should distinguish the APIs and contexts that your design system actually governs. If the team wants to enforce spacing tokens, a narrowly scoped rule around known spacing APIs is more useful than a rule that flags every dimension literal across the codebase.
Shapes
Suppose the design system defines approved shapes for cards and buttons. A rule could detect direct construction of shape objects in feature code and require a named design-system shape instead.
The exact detector depends on how the application models shapes and which APIs are allowed. It should also distinguish legitimate custom shapes from accidental duplication.
Naming conventions
Naming rules can often be enforced through Konsist, especially when the requirement concerns package and class structure.
For example, a team could require design-system composables to use a consistent naming convention or restrict implementation classes to internal packages.
The rule should reflect the conventions the team has actually adopted, rather than imposing naming restrictions simply because they are easy to automate.
Enforce component reuse, not just token reuse
Centralized tokens prevent individual values from drifting, but they do not guarantee consistent component behavior.
Consider two features that both use the same spacing and color tokens but implement their own buttons. One handles loading state correctly; the other allows repeated taps. One has the expected minimum height; the other does not. One exposes appropriate accessibility semantics; the other does not.
Both features may pass the token checks while still presenting different user experiences.
That is why component conventions should be part of the contract.
For example, a design system might provide:
@Composable
fun AppPrimaryButton(
text: String,
onClick: () -> Unit,
enabled: Boolean = true,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier.height(AppDimensions.buttonHeight),
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
)
}
}
This example is deliberately small. A real component might also define loading behavior, icons, content padding, semantics, and analytics hooks.
The key is that features consume the public component rather than independently recreating the same behavior.
A Lint rule can flag direct use of a lower-level component where a specific wrapper is mandatory. Konsist can help protect the package and module boundaries around those components.
Be careful with global prohibitions, though. Sometimes a feature genuinely needs a specialized control. Allow explicit exceptions through a reviewed API or narrowly scoped suppression mechanism, and keep the exceptions visible.
Use Compose UI tests for runtime guarantees
Static analysis can identify source patterns. It cannot establish every property of the rendered interface.
For example, a Lint rule might confirm that a feature uses MaterialTheme.typography.bodyLarge, but it cannot by itself prove that the final screen remains readable at large font scales or that the text is not clipped by its parent layout.
Compose UI tests should cover those runtime requirements.
Useful test targets include:
- Accessibility semantics and meaningful labels.
- Layout behavior at supported font scales.
- Enabled, disabled, loading, and error states.
- Minimum touch-target requirements.
- Content overflow and long user-generated text.
- Interaction behavior and state transitions.
This creates a more complete enforcement model.
Konsist checks architectural boundaries. Lint catches prohibited source patterns. Compose UI tests verify rendered behavior. Each layer protects a different part of the contract.
Make violations visible in CI
Rules are only effective when they run consistently.
Run the architecture tests and custom Lint checks as part of the normal verification pipeline. The precise Gradle task paths depend on the names and configuration of your modules, but a typical workflow might include:
./gradlew :design-system-lint:test
./gradlew :app:lint
Run the relevant Konsist test task as part of the project’s unit-test or verification task.
The desired outcome is straightforward: a pull request that introduces a prohibited design-system pattern fails the appropriate check and explains how to fix it.
A useful error message should answer three questions:
- What rule was violated?
- Why does the rule exist?
- What should the developer use instead?
For example:
Hardcoded design-system color detected. Use an approved design-system color or a semantic MaterialTheme color role.
That is more useful than a generic message such as “Invalid color usage.”
Roll out rules without disrupting delivery
If a codebase already contains years of inconsistent UI code, turning every rule into a blocking error on day one may generate a large volume of failures.
Start by running the checks against the existing codebase and understanding the violations. Fix high-value cases first, then introduce rules in stages.
A practical rollout looks like this:
- Establish the token and component APIs.
- Add tests for architectural boundaries.
- Implement and test one custom Lint rule.
- Run it against existing feature modules and assess false positives.
- Fix violations or document legitimate exceptions.
- Make the rule blocking for new changes.
- Add more rules as the design system matures.
Avoid suppressing violations broadly just to make CI green. Exceptions should be specific, justified, and reviewable.
It is also worth tracking how many violations are found, how often rules are suppressed, and which rules generate false positives. Those signals help distinguish a useful design-system contract from a collection of noisy checks.
The design system becomes part of the development workflow
The real value of this approach is not the number of custom rules the team writes.
It is the change in responsibility.
Without automated enforcement, maintaining visual consistency depends heavily on individual developers remembering conventions and reviewers noticing deviations. As the number of teams, modules, and screens grows, that becomes increasingly difficult.
With executable rules, the repository itself communicates which decisions belong to the design system and which are legitimate feature-level choices.
A developer can still build a new screen, but the screen must respect the same architectural boundaries, use approved tokens, and follow the component conventions that the organization has established.
This is especially useful in larger Android codebases, where design-system drift often appears gradually across independent feature teams. The cost of fixing a single inconsistent color or spacing value is small. The cost of maintaining hundreds of slightly different implementations is not.
Konsist and Android Lint do not replace design reviews, UX judgment, or accessibility testing. They remove the repetitive mechanical checks from those processes so reviewers can spend more time on decisions that require human judgment.
The broader principle applies beyond Compose: if a convention is important enough to document, and precise enough to describe, it may be important enough to enforce automatically.
A design system should not rely on everyone remembering the contract. It should make the correct path easy to follow — and make accidental violations visible before they reach production.
References
- Konsist — Kotlin architecture testing
- Android Developers — Improve your code with lint checks
- Android Developers — Custom lint checks
- Material 3 — Theming in Jetpack Compose
Implementation note: the Kotlin examples are intended as a starting point. Verify the Konsist and Android Lint APIs against the versions pinned by your project, and run the detector tests and Gradle checks before adopting these rules in a production CI pipeline.
Your Design System Is a Contract. Enforce It with Konsist and Android Lint was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.