Skip to content
  • Intermediate

Role

An app that follows an architecture definition has files with specific roles (Roles). For example UseCase, Repository, Controller, or ViewModel.

You define what Roles exist inside architecture { }.

architecture {
// Define a Role called UseCase
"UseCase" {
// Write the description, placement, and constraints inside here
}
}

You write a Role’s description, placement, and constraints inside that Role’s block.

  • title, summary, description, etc. … The description of the Role. Used when generating documentation.
  • layout { } … Defines where the files for that Role may be placed.
  • "constraint name".konsist { } … Defines constraints you can apply to that Role’s files.
  • .template { } … Attached to a file declared in layout { }, it defines the template used when that file is newly created.
architecture {
"UseCase" {
title = "Use case"
summary = "A single app-specific behavior that occurs on each screen"
example("GetUserUseCase", "Fetch the user")
// Where it may be placed
layout {
"src/main/kotlin/com/example/useCase" / "*UseCase".ktFile()
}
// What its contents should look like
"has exactly one invoke function".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
}
}

Let’s look at each of these in a bit more detail.

Writing a description

At minimum, all you need is summary. It becomes the one-line entry for the Role in the generated documentation’s list table.

"UseCase" {
summary = "A single app-specific behavior that occurs on each screen"
}

description can hold a longer explanation, and example() a representative example. Calls to example() are kept in the order you call them.

"UseCase" {
title = "Use case"
summary = "A single app-specific behavior that occurs on each screen"
description = """
Call only UseCase from the UI; never touch Repository directly.
### Things not to do
- Depend on Android types
""".trimIndent()
example("GetUserUseCase", "Fetch the user")
example("UpdateUserUseCase", "Update the user")
}
Declaring where files go

This is the case where a Role has just one place to live.

"UseCase" {
layout {
"src/main/kotlin/com/example/useCase" / "*UseCase".ktFile()
}
}

You can write multiple layout { } blocks. This declares that the same kind of file is allowed to appear in more than one place. Files of different kinds go in separate Roles.

"Screen" {
// In the feature-home module
layout {
"feature-home/src/main/kotlin/screen" / "*Screen".ktFile()
}
// And in the feature-settings module
layout {
"feature-settings/src/main/kotlin/screen" / "*Screen".ktFile()
}
}

Placing a file anywhere you haven’t declared is a violation.

src/main/kotlin/com/example/GetUserUseCase.kt ← placed outside useCase/

When you run the test, katachi reports the nearby valid location and how to fix it together in a single block.

Katachi check failed: 1 violation (Unexpected: 1)
[UnexpectedFile] file:///path/to/project/src/main/kotlin/com/example/GetUserUseCase.kt
No role is defined for this file.
Nearby locations:
UseCase file:///path/to/project/src/main/kotlin/com/example/useCase/
How to fix:
- Move it to one of the locations above
- Delete it if it is not needed
- Add a new role for it:
"GetUserUseCase" {
summary = "TODO"
layout {
"src/main/kotlin/com/example" / "GetUserUseCase".ktFile()
}
}
Constraining the contents

You give each constraint a name. That name appears directly in the violation report.

"UseCase" {
layout {
"src/main/kotlin/com/example/useCase" / "*UseCase".ktFile()
}
"has exactly one invoke function".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
}

A constraint applies to the files that matched the Role’s layout { }. You cannot write a constraint on a Role that has no layout { }.

You can also express a prohibition with mustNot and mustBeEmpty().

"RepositoryInterface" {
layout {
"src/main/kotlin/com/example/repository" / "*Repository".ktFile()
}
// Write the required condition as a negation
"must not be internal".konsist {
classes().mustNot { it.hasInternalModifier }
}
// Require that none match
"must not place Impl in the same location".konsist {
classes().filter { it.hasNameEndingWith("Impl") }.mustBeEmpty()
}
}

A class that doesn’t satisfy the constraint is a violation.

// Placed inside useCase/, but has a function other than invoke
class GetUserUseCase(private val repository: UserRepository) {
suspend fun invoke(id: String) = repository.find(id)
fun clearCache() = repository.clear() // ← this one
}

When the test calls assert(FileConstraintCheck()), it shows up in the same report as placement violations. It even tells you which file and which line.

[UnsatisfiedFileConstraint] file:///path/to/project/src/main/kotlin/com/example/useCase/GetUserUseCase.kt
Role: UseCase / FileConstraint: "has exactly one invoke function"
Declaration: GetUserUseCase (line 3)
Declared at: ProjectArchitecture.kt:12

Also, because Role names are used for things like documentation generation, they must be ASCII only (starting with a letter, followed by letters, digits, _, or -). Put a Japanese (or any other) display name in title.

You can group Roles together using "group name".group { ... }.

architecture {
"domain".group {
"UseCase" {
// ...
}
"RepositoryInterface" {
// ...
}
}
}

If you have to rewrite architecture { } every time you add a feature to the app, that architecture { } definition is poorly designed.

Make good use of wildcards like * and ** so that architecture { } doesn’t end up as a duplicate of your directory structure, requiring double the work to maintain.

Make each Role stand for exactly one kind of file. For example, a Repository interface and its implementation have similar names but are different kinds of file. If you put both in one Role, its description, constraints, and templates split by “which of the two files this is about”, and your konsist { } constraints end up with branches like “if it is the interface…, if it is the implementation…”. With one Role per kind, the description and constraints only deal with that kind of file, and the generated documentation gets one page per kind.

🆖 Two kinds of file in one Role

"Repository" {
summary = "Loads and saves data"
layout {
"repository" / "*Repository".ktFile() // interface
"repository" / "*RepositoryImpl".ktFile() // implementation
}
"Declarations only for the interface, and for the implementation…".konsist {
// the constraint has to branch on which file it is
}
}

🆗 One Role per kind

"RepositoryInterface" {
summary = "The entry point for loading and saving data"
layout { "repository" / "*Repository".ktFile() }
"Put only interfaces here".konsist {
classes().mustBeEmpty()
}
}
"RepositoryImplementation" {
summary = "The implementation of RepositoryInterface"
layout { "repository" / "*RepositoryImpl".ktFile() }
}

The bigger the project, the more the architecture { } definition tends to bloat.

Because architecture { } is a Kotlin DSL, you can factor it out into extension functions just like ordinary Kotlin code.

Here’s the recommended way to split Role definitions.

What tends to bloat is the detail inside each Role. Factor each Role out into its own extension function.

Before

ProjectArchitecture.kt
architecture {
"UseCase" {
layout {
"src/main/kotlin/com/example/project/useCase" / "*UseCase".ktFile()
}
"has exactly one invoke function".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
// ...
}
"RepositoryInterface" {
layout {
"src/main/kotlin/com/example/project/repository" / "*Repository".ktFile()
}
"must not hold a class".konsist {
classes().mustBeEmpty()
}
// ...
}
// ...
}

After

  • ProjectArchitecture.kt
  • Directorydomain/
    • UseCaseRole.kt
    • RepositoryInterfaceRole.kt
ProjectArchitecture.kt
architecture {
useCase()
repositoryInterface()
// ...
}
domain/UseCaseRole.kt
// hint: DeclarationContainerScope is the receiver of architecture { } and "...".group { }.
fun DeclarationContainerScope.useCase() = "UseCase" {
layout {
"src/main/kotlin/com/example/project/useCase" / "*UseCase".ktFile()
}
"has exactly one invoke function".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
// ...
}
domain/RepositoryInterfaceRole.kt
fun DeclarationContainerScope.repositoryInterface() = "RepositoryInterface" {
layout {
"src/main/kotlin/com/example/project/repository" / "*Repository".ktFile()
}
"must not hold a class".konsist {
classes().mustBeEmpty()
}
// ...
}

Within a Role definition, "...".konsist { } in particular tends to bloat as the number of constraints grows or they get more complex.

Don’t hesitate to factor "...".konsist { } out into private extension functions.

Before

fun DeclarationContainerScope.useCase() = "UseCase" {
layout {
"src/main/kotlin/com/example/project/useCase" / "*UseCase".ktFile()
}
"has exactly one invoke function".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
// ...
}

After

fun DeclarationContainerScope.useCase() = "UseCase" {
layout {
"src/main/kotlin/com/example/project/useCase" / "*UseCase".ktFile()
}
onlyInvokeFun()
// ...
}
private fun RoleScope.onlyInvokeFun() =
"has exactly one invoke function".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
  • A Role is a kind of file that’s allowed to appear in the project. It’s independent of packages and modules.
  • A single Role brings together its description (title / summary), placement (layout { }), and constraints (konsist { }).
  • Role definitions bloat easily. To keep architecture { } and Role definitions from bloating, make good use of extension functions to split them appropriately.
architecture {
"domain".group {
"UseCase" {
summary = "A single app-specific behavior that occurs on each screen"
// However many places it is put in, the description stays in this one place
layout {
"core/domain/src/main/kotlin/useCase" / "*UseCase".ktFile()
}
"has exactly one invoke function".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
}
}
}