跪拜 Guibai
← Back to the summary

Your Design System Is a Contract: Enforce It with Konsist and Android Lint

This article is translated from "Your Design System Is a Contract. Enforce It with Konsist and Android Lint", original link https://proandroiddev.com/konsist-design-system-validator-jetpack-compose-d5c3814fe462, published by Ravi Rawal on October 21, 2026.

0.png

This article introduces how to enforce color, spacing, typography, shape, and component specifications in Jetpack Compose, and demonstrates how to achieve this with custom Android Lint rules.

The reliability of a design system depends on how well you can prevent teams from bypassing its rules.

Most Android development teams have a design system.

They define fonts, colors, spacing, shapes, sizes, and reusable Compose components.
They write usage guidelines, publish component libraries, and require developers to follow established specifications.

Yet after a few months of development, the same problems start appearing again.

One screen uses 16dp horizontal padding; another uses 18dp. A developer creates a custom text style because the existing fonts don't look quite right.

A feature introduces a hardcoded hex color value. A button's corner radius differs from the rest of the app. Someone creates another card component because the existing one seems awkward to reuse.

Individually, none of these decisions look especially dangerous. But together, they undermine the coherence the design system was supposed to provide.

Code review can catch some violations, but it is not a reliable enforcement mechanism. Reviewers have limited time, specifications are easy to overlook, and the same feedback repeats across different pull requests.

A 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 complement each other. Konsist enforces architectural and structural rules. Android Lint checks source code for prohibited patterns. Compose UI tests verify the final UI's runtime behavior.

Together, they turn design system specifications from documentation into executable rules.

First, clarify what the contract actually means

Before writing tests or code checker, decide what rules your team wants every feature to follow.

Not all design system specifications are amenable to the same enforcement mechanism. Some rules concern module boundaries, some concern source-code expressions, and some concern the rendered UI.

For example:

1.png

This distinction matters because no single tool can enforce every rule equally well.

Konsist is suitable for structural constraints. Lint is better for detecting forbidden source-code patterns. Runtime tests verify behavior that cannot be determined by inspecting source code alone.

The goal is not to add more checks, but to place each rule on the enforcement layer where it can be checked reliably.

Build the design system on approved tokens

A design system needs a clear single source of truth before implementation.

Consider a Compose app 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 isn't that 17.dp, 22.sp, or 47.dp are inherently wrong; the problem is that the feature code makes its own design decisions instead of following the system's established values.

Define tokens first.

Spacing and sizing

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
}

Token names convey design intent, while their actual values remain centrally managed by the design system.

If the design system changes its center spacing from 16.dp to something else, 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 typographic roles based on its actual design specification, including font families, weights, line heights, letter spacing, and supported text variants.

The key principle is that feature code should use semantic roles rather than inventing its own typography formats.

Text(
    text = "Product details",
    style = MaterialTheme.typography.titleLarge,
)

Colors and shapes

Colors and shapes should follow the same principle. In the design system, use Material 3 color roles whenever possible:

Text(
    text = "Available",
    color = MaterialTheme.colorScheme.primary,
)

For custom brand colors, define approved values in a package owned by the design system and expose semantic names to consumers.

Similarly, approved shapes should be centrally defined rather than letting each feature pick its own corner radius.

The token system builds the contract's vocabulary. Automated checks effectively prevent circumvention of these conventions.

Use Konsist to protect architectural boundaries

Konsist is a Kotlin architecture testing library. It allows teams to write tests against package structure, dependencies, naming conventions, and other source-code-level architectural rules.

This is especially useful when the design system is split into public API and internal implementation packages.

Imagine a package structure like this:

com.acme
├── feature
│   ├── home
│   └── product
└── designsystem
    ├── components
    ├── tokens
    └── internal
        └── palette

Feature modules should use the public component and token APIs, but should not depend on implementation details under designsystem.internal.

A Konsist architecture test can express this 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. Tests should run against the expected project scope, and the Konsist version should be locked and validated against the API used by your project.

The benefit is that architectural decisions become enforceable.

If a feature starts importing internal palette or implementation classes, the architecture test can fail before the change is merged.

What else can Konsist enforce?

The same approach applies to naming and package conventions.

For example, a team might require:

These checks help prevent structural drift.

However, there is an important limitation: package architecture tests do not automatically enforce every usage convention inside composable components. A feature can depend only on approved packages and still directly construct Color(0xFF1769E0).

That's where Android Lint comes in.

Use custom Android Lint rules to catch design system violations

Android Lint analyzes Android source code and reports potential errors and issues. It also provides an API for writing custom detectors.

For design systems, custom Lint rules can inspect Kotlin source code and flag patterns that violate team conventions.

For example:

Each rule should have a clear scope and a clear failure message. Overly broad rules that flag every reasonable exception create noise and eventually lead developers to disable the checks.

Let's implement a concrete example: detecting numeric color literals in Compose Color constructor calls.

Prohibit raw numeric color constructors in feature code

Consider the following code:

val accent = Color(0xFF1769E0L)

The color might match the current brand tint, 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 in unapproved packages.

The following example targets numeric literals passed directly to the androidx.compose.ui.graphics.Color constructor. It is a focused introductory rule, not a comprehensive detection of 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,
            ),
        )
    }
}

This detector has three important parts.

First, getApplicableConstructorTypes() limits the calls the detector examines to constructors of the Compose Color type.

Second, visitConstructor() checks whether any numeric literal exists among the arguments and skips packages explicitly allowed to define colors.

Third, the reported issue has severity ERROR. This means Lint reports the violation as an error. Whether it blocks a specific CI job depends on how that job is run and how Lint is configured.

The allowlist is intentional. The design system must be able to define its own palette. The rule prevents arbitrary feature-level definitions while not interfering with the central token implementation.

Validate the specific detector API against the Android Lint version chosen in your project. Custom Lint is version-sensitive, so ensure lint-api and lint-tests align 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 = "[email protected]",
    )
}

Replace the vendor information in the example with your company's details.

The custom Lint module must also register the issue registry via 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 content should be the fully qualified registry class name:

com.acme.designsystem.lint.DesignSystemIssueRegistry

This lets Lint discover custom issues when loading 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 compatible with your Android Gradle Plugin and Lint environment. Do not pick this version independently of the Android build toolchain.

In the Android app module, consume these rules via the lintChecks configuration:

dependencies {
    lintChecks(project(":design-system-lint"))
}

Once the module is included in the build, Lint can load the registered detectors when analyzing the app.

Test before enforcing

A custom detector without tests is just another piece of code that can silently stop working.

Lint provides a test infrastructure that lets you provide source snippets and assert the issues reported.

The following test class shows the expected structure for detector tests. Ensure that the Lint test dependencies match the version used in your project, and validate code snippets with that version before applying them 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 provides a minimal Compose Color type for the testing environment. The actual detector still needs to be tested against the Lint version and source configuration used in your project.

These tests also demonstrate a key principle: test both violations and legal exceptions. Otherwise, a rule may appear effective but actually break the code it is meant to protect.

Understand detector limitations

This starter detector does not catch all ways colors bypass the design system.

For example, it does not comprehensively handle:

Color.Red

Or colors stored in constants, colors returned from helper functions, colors loaded from resources, or colors passed through aliases.

It also cannot prove that a named token is semantically appropriate for the component using it.

A production detector might need to inspect resolved symbols, additional constructor forms, property access, or other expressions. It should include tests for the specific patterns the organization wants to prohibit.

The right approach is to evolve the detector incrementally based on real violations and the design system contract, rather than claiming that a single syntactic check guarantees complete color control.

Apply the same approach to typography, spacing, and dimensions.

Once the first custom rule is in place, others can enforce other design system conventions.

Don't cram every rule into one giant detector. Give each rule a clear purpose, scope, and test suite.

Typography

When a project requires semantic typography roles, a typography rule might flag direct fontSize assignments in feature code.

For example:

Text(
    text = "Product title",
    fontSize = 19.sp,
)

The preferred approach is to use a defined role:

Text(
    text = "Product title",
    style = MaterialTheme.typography.titleLarge,
)

The implementation needs to specify exactly what is forbidden. Banning all TextStyle or fontSize expressions might mis-flag legitimate cases like custom drawing, special editor content, or internal design system implementations.

Make the contract explicit: is it banning all direct text size adjustments, or only certain patterns in specific feature packages?

Spacing and dimensions

Similar rules can detect raw dimension literals:

Modifier.padding(17.dp)

And guide developers to tokens:

Modifier.padding(AppSpacing.md)

However, not every dp value represents spacing. Dimensions can also represent minimum touch targets, border widths, icon sizes, or specific layout constraints.

A detector should distinguish between the APIs and contexts actually controlled by the design system. If the team wants to enforce spacing tokens, a narrow rule around known spacing APIs is more useful than one that flags every dimension literal in the codebase.

Shapes

Suppose the design system defines approved shapes for cards and buttons. A rule can detect shape objects constructed directly in feature code and require the use of design-system-specified shapes.

The specific detector depends on how the application models shapes and which APIs are allowed. It should also distinguish legitimate custom shapes from accidental duplications.

Naming conventions

Naming rules are often enforceable via Konsist, especially when they concern package and class structure.

For example, a team might require design system composables to use consistent naming conventions, or restrict implementation classes to internal packages.

Rules should reflect the conventions the team actually uses, not impose naming restrictions merely because they are easy to automate.

Enforce component reuse, not just token usage

Centrally managed tokens prevent numeric drift, but they do not guarantee consistency of component behavior.

Consider two features that both use the same spacing and color tokens, but each implements a different button. One feature handles loading states correctly; the other allows double taps. One feature has the expected minimum height; the other does not. One feature provides proper accessibility semantics; the other does not.

Both features pass token checks, yet they still present different user experiences.

Component conventions should therefore 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 simple. A real component might also define loading behavior, icons, content padding, semantics, and analytics hooks.

The key is that features use the public component instead of independently recreating the same behavior.

Lint rules can flag direct use of underlying components where a specific wrapper is required. Konsist can help protect the package and module boundaries around these components.

However, be careful with global bans. Sometimes a feature genuinely needs specialized control. Allow explicit exceptions through audited APIs or narrowly scoped suppression mechanisms, and keep those exceptions visible.

Use Compose UI tests to ensure runtime correctness

Static analysis can identify source-code patterns but cannot determine all properties of the rendered UI.

For example, a Lint rule might confirm that a feature uses MaterialTheme.typography.bodyLarge, but it cannot itself prove that the final screen remains readable at large font scales, or that text is not clipped by its parent layout.

Compose UI tests should cover these runtime requirements.

Useful test targets include:

This creates a more complete enforcement pattern.

Konsist checks architectural boundaries. Lint detects forbidden source-code patterns. Compose UI tests verify rendered behavior. Each layer protects a different part of the contract.

Surface violations in CI

Rules are only effective if they are enforced consistently.

Run architecture tests and custom Lint checks as part of your regular verification process. The specific Gradle task paths depend on your module names and configuration, but a typical process might include:

./gradlew :design-system-lint:test
./gradlew :app:lint

Run the relevant Konsist test task as part of your project's unit tests or verification task.

The expected outcome is clear: a pull request introducing a prohibited design system pattern fails the corresponding check, along with an explanation of how to fix it.

A useful error message should answer three questions:

  1. Which rule was violated?
  2. Why does the rule exist?
  3. What alternative should the developer use?

For example:

Detected a hardcoded design system color. Use an approved design system color or a semantic MaterialTheme color role.

That is more useful than a generic message like "Invalid color usage".

Roll out rules smoothly without slowing delivery

If a codebase already contains years of inconsistent UI code, turning every rule into a blocking error on day one can cause a flood of failures.

First, audit the existing codebase to understand the violations. Prioritize fixing high-value cases, then introduce rules in phases.

A practical rollout process:

  1. Establish token and component APIs.
  2. Add architecture boundary tests.
  3. Implement and test one custom Lint rule.
  4. Run the rule against existing feature modules and evaluate false positives.
  5. Fix violations or document reasonable exceptions.
  6. Set the rule to block new changes.
  7. Add more rules as the design system matures.

Avoid arbitrarily suppressing violations just to get CI to pass. Exceptions should be specific, reasonable, and reviewable.

Also track the number of violations, how often rules are suppressed, and which rules produce false positives. This data helps distinguish a valid design system specification from a pile of meaningless checks.

The design system becomes part of the development workflow

The real value of this approach is not how many custom rules a team writes.

It is the shift of responsibility.

Without automated enforcement, maintaining visual consistency relies heavily on developers remembering the specs and reviewers spotting deviations. This grows harder as teams, modules, and screens increase.

With executable rules, the repository itself communicates which decisions belong to the design system and which are legitimate feature-level choices.

Developers can still build new screens, but the screens must respect the same architectural boundaries, use approved tokens, and follow the component conventions the organization has established.

This is especially useful in large Android codebases, where design system deviations typically emerge gradually across independent feature teams. The cost of fixing a single inconsistent color or spacing value is small, but the cost of maintaining hundreds of slightly different implementations is not.

Konsist and Android Lint do not replace design reviews, UX evaluations, or accessibility testing. They simply remove the repetitive mechanical checks from these processes so reviewers can spend more time on decisions that require human judgment.

This principle applies beyond Compose systems: If a convention is important enough to document and precise enough to describe, it is probably important enough to enforce automatically.

A design system should not depend on everyone remembering the conventions. It should make the right path easy to follow and surface accidental violations before they reach production.

References

Implementation note: The Kotlin examples are illustrative. Before applying these rules to a production CI pipeline, validate Konsist and Android Lint APIs against the versions specified in your project, and run detector tests and Gradle checks.

For more high-quality articles, search and follow the WeChat public account "稀有猿诉" (Rare Ape Podcast)!

Protect originality, do not reprint!