Skip to content

Code Generation

Circuit offers a KSP-based code gen solution to ease boilerplate around generating factories for several dependency injection tools.

Installation

plugins {
  id("com.google.devtools.ksp")
}

dependencies {
  api("com.slack.circuit:circuit-codegen-annotations:<version>")
  ksp("com.slack.circuit:circuit-codegen:<version>")
}

Supported DI modes are:

Dagger+Anvil remains the default mode for compatibility. New projects should use Metro, Hilt, or kotlin-inject-anvil.

If you are using another mode, you must specify the mode as a KSP arg.

ksp {
  arg("circuit.codegen.mode", "metro") // or "hilt", "kotlin_inject_anvil"
}

If using Kotlin multiplatform with typealias annotations for Dagger annotations (i.e. expect annotations in common with actual typealias declarations in JVM source sets), you can match on just annotation short names alone to support this case via circuit.codegen.lenient mode.

ksp {
  arg("circuit.codegen.lenient", "true")
}

If you use anvil-ksp or kotlin-inject-anvil, configure @CircuitInject and @CircuitSerializable as contributing annotations.

ksp {
  val circuitContributingAnnotations =
    listOf(
      "com.slack.circuit.codegen.annotations.CircuitInject",
      "com.slack.circuit.serialization.CircuitSerializable",
    ).joinToString(":")

  // Anvil-KSP
  arg("anvil-ksp-extraContributingAnnotations", circuitContributingAnnotations)
  // kotlin-inject-anvil (requires 0.0.3+)
  arg("kotlin-inject-anvil-contributing-annotations", circuitContributingAnnotations)
}

Usage

The primary entry point is the CircuitInject annotation.

This annotation is used to mark a UI or presenter class or function for code generation. When annotated, the type’s corresponding factory will be generated and keyed with the defined screen.

The generated factories are contributed to the selected DI framework and scoped with the provided scope key. Metro uses @ContributesIntoSet. The other modes generate equivalent multibindings.

Serialization registrations

circuit-codegen can register serializable Screen and PopResult types for SerializableCircuitSaver. Add the circuit-serialization runtime. Then annotate each saved type with its DI scope:

@CircuitSerializable(AppScope::class)
data class HomeScreen(val userId: Long) : Screen

In a multiplatform project using Metro or kotlin-inject-anvil, add circuit-codegen to kspCommonMainMetadata. Also add it to each target-specific KSP configuration that compiles the annotated declarations. Anvil and Hilt support code generation only for JVM and Android targets.

@CircuitSerializable supplies the default kotlinx serializer. The serialization processor in circuit-codegen generates a registration and contributes it through the selected DI mode. Metro mode generates code equivalent to:

@Inject
@ContributesIntoSet(AppScope::class)
public class HomeScreenCircuitSerializerRegistration :
  CircuitSerializerRegistration {
  override fun register(
    builder: PolymorphicModuleBuilder<CircuitSaveable>,
  ) {
    builder.subclass(subclass = HomeScreen::class, serializer = HomeScreen.serializer())
  }
}

The other modes contribute the same registration through Hilt, kotlin-inject-anvil, or Anvil. For example, a Metro graph can declare the registration set and provide the saver like this:

@Multibinds
fun circuitSerializerRegistrations(): Set<CircuitSerializerRegistration>

@Provides
fun provideCircuitSaver(
  registrations: Set<CircuitSerializerRegistration>,
): CircuitSaver = SerializableCircuitSaver(registrations)

Each supported type contributes one registrar to the DI set. The application graph collects generated contributions from the application module and its dependency modules in the injected Set<CircuitSerializerRegistration>. SerializableCircuitSaver invokes each registrar when it builds its serializers module. The processor supports accessible, concrete, non-generic Screen and PopResult classes and objects, including nested declarations.

For an expect/actual type, annotate the expect declaration and every actual declaration with @CircuitSerializable using the same scope. The annotation supplies the default serializer in every compilation. The processor generates one registration from the expect declaration.

An expect class with constructor state must declare the property in its body and expose a secondary constructor:

@CircuitSerializable(AppScope::class)
expect class OpenUrlScreen : Screen {
  val url: String

  constructor(url: String)
}

@CircuitSerializable(AppScope::class)
actual data class OpenUrlScreen actual constructor(
  actual val url: String,
) : Screen

To use a custom serializer for a screen or result, keep @CircuitSerializable for registration and add @Serializable(with = ...):

@CircuitSerializable(AppScope::class)
@Serializable(with = LegacyScreenSerializer::class)
data class LegacyScreen(val value: String) : Screen

Apps without a supported DI framework can build a SerializersModule manually. On JVM and Android, they can use ReflectiveSerializableCircuitSaver instead.

Classes

Presenter and Ui classes can be annotated and have their corresponding Presenter.Factory or Ui.Factory classes generated for them. The annotated class must be injectable — either annotate the class itself with @Inject (for kotlin-inject and Metro) or annotate a constructor with @Inject (Dagger/Anvil/Hilt). Otherwise, the processor will fail with an error.

Presenter

@CircuitInject(HomeScreen::class, AppScope::class)
class HomePresenter @Inject constructor(...) : Presenter<HomeState> { ... }

// Generates
@ContributesMultibinding(AppScope::class)
class HomePresenterFactory @Inject constructor() : Presenter.Factory { ... }

UI

@CircuitInject(HomeScreen::class, AppScope::class)
class HomeUi @Inject constructor(...) : Ui<HomeState> { ... }

// Generates
@ContributesMultibinding(AppScope::class)
class HomeUiFactory @Inject constructor() : Ui.Factory { ... }

Functions

Simple functions can be annotated and have a corresponding Presenter.Factory generated. This is primarily useful for simple cases where a class is just technical tedium.

Requirements - Presenter function names must end in Presenter, otherwise they will be treated as UI functions. - Presenter functions must return a CircuitUiState type. - UI functions can optionally accept a CircuitUiState type as a parameter, but it is not required. - UI functions must return Unit. - Both presenter and UI functions must be Composable.

Presenter

@CircuitInject(HomeScreen::class, AppScope::class)
@Composable
fun HomePresenter(): HomeState { ... }

// Generates
@ContributesMultibinding(AppScope::class)
class HomePresenterFactory @Inject constructor() : Presenter.Factory { ... }

UI

@CircuitInject(HomeScreen::class, AppScope::class)
@Composable
fun Home(state: HomeState) { ... }
*
// Generates
@ContributesMultibinding(AppScope::class)
class HomeUiFactory @Inject constructor() : Ui.Factory { ... }

Assisted injection

Any type that is offered in Presenter.Factory and Ui.Factory can be offered as an assisted injection to types using Dagger AssistedInject. For these cases, the AssistedFactory -annotated interface should be annotated with CircuitInject instead of the enclosing class.

Types available for assisted injection are:

  • Screen – the screen key used to create the Presenter or Ui.
  • Navigator – (presenters only)
  • Circuit

Each should only be defined at-most once.

Examples

// Function example
@CircuitInject(HomeScreen::class, AppScope::class)
@Composable
fun HomePresenter(screen: Screen, navigator: Navigator): HomeState { ... }

// Class example
class HomePresenter @AssistedInject constructor(
  @Assisted screen: Screen,
  @Assisted navigator: Navigator,
  ...
) : Presenter<HomeState> {
  // ...
  @CircuitInject(HomeScreen::class, AppScope::class)
  @AssistedFactory
  fun interface Factory {
    fun create(screen: Screen, navigator: Navigator, context: CircuitContext): HomePresenter
  }
}

kotlin-inject

Assisted injection in kotlin-inject works slightly differently for classes. Since there is no @AssistedFactory, you can continue to just annotate the injected class directly.

@Inject
@CircuitInject(HomeScreen::class, AppScope::class)
class HomePresenter(
  @Assisted screen: Screen,
  @Assisted navigator: Navigator,
  ...
) : Presenter<HomeState>

Qualifier propagation

Qualifier annotations (any annotation meta-annotated with @Qualifier like javax.inject.Qualifier, dev.zacsweers.metro.Qualifier, etc.) are propagated from the @CircuitInject-annotated declaration to the generated factory class.

@Named("home")
@Inject
@CircuitInject(HomeScreen::class, AppScope::class)
class HomePresenter(...) : Presenter<HomeState>

// Generates
@Inject
@ContributesIntoSet(AppScope::class)
@Named("home")
class HomePresenterFactory(...) : Presenter.Factory { ... }

Function-based injected dependencies

Function-based presenters and UIs can accept any injected dependency directly as a parameter. Any parameter type that isn’t one of the circuit-provided types (see Assisted injection) is treated as a regular injected dependency and hoisted: the generated factory accepts it as a provider (Provider<T> for Dagger/Anvil/Hilt, () -> T for kotlin-inject and Metro) and invokes it once at create() time outside the presenterOf { }/ui { } block (so the provider isn’t re-invoked on every recomposition).

Parameters that are already an indirect reference to a dependency (Provider<T> (any flavor) or Lazy<T> (Dagger or Kotlin)) are passed through to the factory constructor as-is rather than being re-wrapped in another provider. In metro and kotlin_inject_anvil modes, () -> T is also treated as a provider and passed through; in Dagger/Anvil/Hilt modes it is treated as a regular dependency and wrapped in Provider<() -> T> like any other type.

@CircuitInject(HomeScreen::class, AppScope::class)
@Inject
@Composable
fun HomePresenter(
  navigator: Navigator,       // circuit-provided
  repository: UserRepository, // injected — not recognized as circuit-provided, so treated as a dependency
): HomeState { ... }

// Generates (metro mode shown)
@Inject
@ContributesIntoSet(AppScope::class)
class HomePresenterFactory(
  private val repository: () -> UserRepository,
) : Presenter.Factory {
  override fun create(
    screen: Screen,
    navigator: Navigator,
    context: CircuitContext,
  ): Presenter<*>? = when (screen) {
    HomeScreen -> {
      val repository = repository()
      presenterOf { HomePresenter(navigator = navigator, repository = repository) }
    }
    else -> null
  }
}

Class-based presenters and UIs don’t need this special handling, since constructor parameters there are already unambiguously injected dependencies.