🚉 Metro


NOVEMBER 6th, 2025 NOTE

This document is the original Metro design doc. After discussing with contributors and getting permission from commenters, we’ve made this document publicly viewable.

It was mostly written in December 2024 and shared with a group of people active (most listed here) in the DI space for input, feedback, and advice. Below is the document as it was by roughly end of January 2025. Most of the final docs moved to Metro’s documentation site.

All comment threads have been marked “unresolved” so they’re visible in the html export, which will be available statically on the Metro doc site.



Author: Zac Sweers

Repo: https://github.com/ZacSweers/metro

Formerly known as Lattice

Introduction

Metro is a compile-time dependency injection framework that draws heavy inspiration from Dagger, Anvil, and Kotlin-Inject (KI). It seeks to unify their best features under one, cohesive solution while adding a few new features and benefits.

Why another DI framework?[a][b][c][d][e]

It’s felt for some time like the Kotlin community has been waiting for a library that unifies the best of Dagger, multiplatform, Anvil aggregation, compiler plugin, and Kotlin-first. Different solutions exist for parts of these, but there’s not yet been a cohesive, unified solution that checks all these boxes, leaves behind some of these tools’ limitations, and embraces newer features that native compiler plugins offer.

In short, Metro stands on the shoulders of giants. It doesn’t seek to reinvent the wheel and tries to build on top of what existing solutions do well. The goal is a solution that unifies their best ideas.

I’m aware of the XKCD comic 🙂, I think Metro offers a compelling feature set with interop hooks that make it easy to integrate with an existing codebase.

[f]


Overview

Features

Differences

…from Dagger[i][j][k][l][m]

…from Kotlin-Inject

…from Anvil

…from kotlin-inject-anvil


Installation

Metro is primarily applied via its companion Gradle plugin[p][q][r][s][t].

plugins {
 kotlin(
"multiplatform") // or jvm, android, etc
 id(
"dev.zacsweers.metro")
}

…and that’s it! This will add metro’s runtime dependencies and do all the necessary compiler plugin wiring.

If applying in other build systems, apply it however that build system conventionally applies Kotlin compiler plugins. For example with Bazel:

load("@rules_kotlin//kotlin:core.bzl", "kt_compiler_plugin")
load(
"@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library")

kt_compiler_plugin(
   name =
"metro_plugin",
   compile_phase =
True,
   id =
"dev.zacsweers.metro.compiler",
   options = {
       
"enabled": "true",
       
"debug": "false",
   },
   deps = [
       ​​
"@maven//:dev_zacsweers_metro_compiler",
   ],
)

kt_jvm_library(
   name =
"sample",
   
# The SampleGraph class is annotated with @DependencyGraph
   srcs = [
"SampleGraph.kt"],
   plugins = [
       
":metro_plugin",
   ],
   deps = [
       
"@maven//:dev_zacsweers_metro_runtime_jvm",
   ],
)


Design

Dependency Graphs

The primary entry points in Metro are dependency graphs. These are interfaces annotated with @DependencyGraph[u][v][w] and created with @DependencyGraph.Factory interfaces. Graphs expose types from the object graph via accessor properties or functions[x][y][z][aa][ab][ac][ad][ae][af].

TIP: These are analogous to components and @Component/@Component.Factory in Dagger and kotlin-inject.

TIP: “Accessors” in Metro are synonymous with Dagger’s TODO link provision methods.

Accessors and member injections[ag][ah][ai][aj] act as roots, from which the dependency graph is resolved. Dependencies can be provided via conventional @Provides functions in graphs or their supertypes, constructor-injected classes, or accessed from graph dependencies.

@DependencyGraph
interface AppGraph {
 
val message: String

 
@Provides
 
fun provideMessage[ak][al][am][an][ao](): String = "Hello, world!"[ap][aq][ar][as][at]
}

Note the @Provides function must define an explicit return type[au][av].

Simple graphs like this can be created via the createGraph() intrinsic.

val graph = create[aw][ax]Graph<AppGraph>()

Graphs are relatively cheap and should be used freely.

Inputs

Instance parameters and graph dependencies can be provided via a @DependencyGraph.Factory interface that returns the target graph.

@DependencyGraph
interface AppGraph {
 
val message: String

 
@DependencyGraph.Factory
 
fun interface Factory {
   
fun create(@Provides message: String): AppGraph
 }
}

@DependencyGraph
interface AppGraph {
 
val message: String

 
@DependencyGraph.Factory
 
fun interface Factory {
   
fun create[ay][az][ba][bb][bc][bd](messageGraph: MessageGraph): AppGraph
 }

 
@DependencyGraph interface MessageGraph {
   
val message: String

   
@Provides fun provideMessage(): String = "Hello, world!"
 }
}

Like Dagger, non- @Provides instance dependencies can be any type. Metro will treat accessor candidates of these types as usable dependencies.

@DependencyGraph
interface AppGraph {
 
val message: String

 
@DependencyGraph.Factory
 
fun interface Factory {
   
fun create(messageProvider: MessageProvider): AppGraph
 }

 
interface MessageProvider {
   
val message: String
 }
}

Graph factories can be created with the createGraphFactory() intrinsic.

val messageGraph =

  createGraphFactory<AppGraph.Factory>()

    .create("Hello, world!")

Implementation Notes

Dependency graph code gen is designed to largely match how Dagger components are generated.

@Provides

Providers can be defined in graphs or supertypes that graphs extend. Defining them in supertypes allows for them to be reused across multiple graphs and organize providers into logic groups. This is similar to how modules in Dagger work.[bj][bk][bl]

interface NetworkProviders {
 
@Provides
 
fun provideHttpClient(): HttpClient = HttpClient()
}

@DependencyGraph
interface AppGraph : NetworkProviders

Providers should be private by default.

TIP: It’s recommended to not call providers from each other.

Overrides

It is an error to override providers declarations. While it can be enticing for testing reasons to try to replicate Dagger 1’s module overrides, it quickly becomes difficult to reason about in code gen.[bm]

To the testing end, it is recommended to instead leverage the DependencyGraph.excludes + ContributesTo.replaces APIs in merging.

// Don't do this pattern!
interface NetworkProviders {
 
@Provides
 
fun provideHttpClient(): HttpClient = HttpClient()
}

@DependencyGraph
interface TestAppGraph : NetworkProviders {
 
// This will fail to compile
 
override fun provideHttpClient(): HttpClient = TestHttpClient()
}

Companion Providers

Providers can alternatively be implemented in companion objects for staticization.

interface MessageGraph {
 
val message: String
 
companion object {
   
@Provides
   
private fun provideMessage(): String = "Hello, world!"
 }
}

Implementation Notes

private interface functions are not usually visible to downstream compilations in IR[bn][bo]. To work around this, Metro will use a new API in Kotlin 2.1.20 to add custom metadata to the parent class to denote these private providers’ existence and where to find them.

Injection Types

Metro supports multiple common injection types.

Constructor Injection

Most types should use constructor injection if possible. For this case, you can annotate either a class itself (if it has exactly one, primary constructor) or exactly one specific constructor.

@Inject
class ClassInjected

class SpecificConstructorInjection(val text: String) {
 
@Inject constructor(value: Int) : this(value.toString())
}

Constructor-injected classes can be instantiated+managed entirely by Metro and encourages immutability.

Assisted Injection[bp][bq][br][bs][bt][bu][bv][bw]

For types that require dynamic dependencies at instantiation, assisted injection can be used to supply these inputs. In this case - an injected constructor (or class with one constructor) must be annotated with @[bx][by][bz][ca][cb][cc][cd][ce][cf]Inject, assisted parameters annotated with @Assisted, and a factory interface or abstract class with one single abstract function that accepts these assisted parameters and returns the target class.

@Inject
class HttpClient(
 
@Assisted val timeout: Duration,
 
val cache: Cache
) {
 
@AssistedFactory
 
fun interface Factory {
   
fun create(timeout: Duration): HttpClient
 }
}

Then, the @AssistedFactory-annotated type can be accessed from the dependency graph.

@Inject
class ApiClient(val httpClientFactory: HttpClient.Factory) {
 
private val httpClient = httpClientFactory.create(30.seconds)
}

Like Dagger, the @Assisted parameters can take optional value keys to disambiguate matching types.

@Inject
class HttpClient(
 
@Assisted("connect")[cg][ch][ci][cj][ck][cl][cm][cn][co] val connectTimeout: Duration,
 
@Assisted("request") val requestTimeout: Duration,
 
val cache: Cache
) {
 
@AssistedFactory
 
fun interface Factory {
   
fun create(
     
@Assisted("connect") connectTimeout: Duration,
     
@Assisted("request") requestTimeout: Duration,
   ): HttpClient
 }
}

Automatic Assisted Factory Generation

As of this PR, there is a feature (behind a compiler option) to let Metro automatically generate assisted factory interfaces for @Inject-annotated constructors that contain @Assisted-annotated parameters. This should save boilerplate in most cases, but may not be suitable for all cases[cp][cq]. Cases where you may not want this include:

  1. You want assisted factories to conform to a common interface.
  2. Likely in combination with #1, you may want to contribute these factories to multibindings of some sort and thus need to annotate them with @IntoSet, etc.

Member Injection[cr][cs]

Metro supports member injection to inject mutable properties or functions post-construction or into existing class instances.

This can be useful for classes that cannot be constructor-injected, for example Android Activity classes (on older SDK versions) as well as constructor-injected classes that perhaps don’t want or need to expose certain types directly in their constructors.

TIP: Unlike Dagger and kotlin-inject, injected members in Metro can be private.

NOTE: Member function injection does not (currently) support default values.

class ProfileActivity : Activity() {
 
// Property injection
 
@Inject private lateinit var db: UserDatabase

 
@Inject private var notifications: Notifications? = null

 
// Function injection
 
@Inject private fun injectUser(user: User) {
   
// ...
 }
}

Like Dagger, these classes can be injected via multiple avenues.

  1. In constructor-injected types, @Inject-annotated members are injected automatically.

// Injection with constructor injection
@Inject
class ProfileInjector(...) {
 
// Automatically injected during constructor injection
 
@Inject private fun injectUser(value: String)
}

In these cases, Metro will automatically inject these members automatically and immediately after instantiation during constructor injection.

  1. Exposing a fun inject(target: ProfileActivity) function on the graph

// Graph inject() functions
@DependencyGraph
interface AppGraph {
 
// ...

 
fun inject(target: ProfileActivity)
}

// Somewhere else
val graph = createGraph<AppGraph>()
graph.inject(profileActivity)

With this option, you can call graph.inject(target) on the instance with members you wish to inject.

  1. Requesting a MembersInjector instance from the dependency graph.

// Injection with MembersInjector
@Inject
class ProfileInjector(

  private val injector: MembersInjector<ProfileActivity>

) {
 
fun performInjection(activity: ProfileActivity) {
   injector.inject(activity)
 }
}

Like Dagger, option #2 is accomplished via MembersInjector interface at runtime and in code gen. This should be reserved for advanced use cases.

Implementation notes

Top-level Function Injection

Like KI, Metro supports top-level function injection. The primary use case for this is composable functions and standalone applications that run from main functions.

@Inject
fun App(message: String) {
 
// ...
}

To do this, Metro’s FIR plugin will generate a concrete type that acts as a bridge for this function.

@Inject
class AppClass(
 
private val message: Provider<String>
) {
 
operator fun invoke() {
   App(message())
 }
}

Because it’s generated in FIR, this type will be user-visible in the IDE and can then be referenced in a graph.

Note that this feature requires enabling third party FIR plugins in the IDE to fully work. It will compile without it, but generated wrapper classes will be red/missing in the IDE.

NOTE: The generated class is called <function name> + Class because of a limitation in the Kotlin compiler. TODO Link issue?

@DependencyGraph
interface AppGraph {
 
val app: AppClass

 
@DependencyGraph.Factory
 
fun interface Factory {
   
fun create(message: String): AppGraph
 }
}

// Usage
val app = createGraphFactory<AppGraph.Factory>()
 .create(
"Hello, world!")
 .app

// Run the app
app()

To add assisted parameters, use @Assisted on the parameters in the function description. These will be propagated accordingly.

@Inject
fun App(@Assisted message: String) {
 
// ...
}

// Generates...
@Inject
class AppClass {
 
operator fun invoke(message: String) {
   App(message)
 }
}

// Usage
val app = createGraph<AppGraph>()
 .app

// Run the app
app(
"Hello, world!")

This is particularly useful for Compose, and @Composable functions will be copied over accordingly.

@Inject
@Composable
fun App(@Assisted message: String) {
 
// ...
}

// Generates...
@Inject
class AppClass {
 
@Composable
 
operator fun invoke(message: String) {
   App(message)
 }
}

// Usage
val App = createGraph<AppGraph>()
 .app

// Call it in composition
setContent {
 App(
"Hello, world!")
}

Similarly, if the injected function is a suspend function, the suspend keyword will be ported to the generated invoke() function too.

Implementation notes

This is fairly different from kotlin-inject’s typealias approach. This is necessary because Metro doesn’t use higher order function types or typealiases as qualifiers.[ct]

Scopes[cu][cv][cw][cx][cy][cz][da][db][dc][dd][de][df][dg]

Like Dagger and KI[dh][di][dj][dk], Metro supports scopes to limit instances of types on the dependency graph. A scope is any annotation annotated with @Scope, with a convenience @Singleton scope available in Metro’s runtime.

Scopes must be applied to either the injected class or the provider function providing that binding. They must also match the graph that they are used in.

@Singleton[dl][dm]
@DependencyGraph
abstract class AppGraph {
 
private var counter = 0

 
val count: Int

 
@Singleton @Provides fun provideCount() = counter++
}

In the above example, multiple calls to AppGraph.count will always return 0 because the returned value from provideCount() will be cached in the AppGraph instance the first time it’s called.

It is an error for an unscoped graph to access scoped bindings.

@DependencyGraph
interface AppGraph {
 
// This is an error!
 
val exampleClass: ExampleClass
}

@Singleton
@Inject
class ExampleClass

It is also an error for a scoped graph to access scoped bindings whose scope does not match.

@Scope annotation class UserScope

@Singleton
@DependencyGraph
interface AppGraph {
 
// This is an error!
 
val exampleClass: ExampleClass
}

@UserScope
@Inject
class ExampleClass

Like Dagger, graphs can have multiple scopes that they support.

@Scope annotation class SingleIn(scope: KClass<*>)

@Singleton
@SingleIn(AppScope::class)
@DependencyGraph
interface AppGraph {
 
// This is ok
 
val exampleClass: ExampleClass
}

@SingleIn(AppScope::class)
@Inject
class ExampleClass

Metro Intrinsics

Like Dagger, Metro supports injection of bindings wrapped in intrinsic types. Namely - Provider and Lazy. These are useful for deferring creation/initialization of dependencies. These only need to be requested at the injection site, Metro’s code gen will generate all the necessary stitching to fulfill that request.

Provider

Provider is like Dagger’s Provider — it is a simple interface who’s invoke() call returns a new instance every time. If the underlying binding is scoped, then the same (scoped) instance is returned every time invoke() is called.

@Inject
class HttpClient(val cacheProvider: Provider<Cache>) {
 
fun createCache() {
   
val cache = cacheProvider()
 }
}

Lazy

Lazy[dn][do][dp][dq] is Kotlin’s standard library Lazy. It lazily computes a value the first time it’s evaluated and is thread-safe.

@Inject
class HttpClient(val cacheProvider: Lazy<Cache>) {
 
fun createCache() {
   
// The value is computed once and cached after
   
val cache = cacheProvider.value
 }
}

Note that Lazy is different from scoping in that it is confined to the scope of the injected type, rather than the component instance itself. There is functionally no difference between injecting a Provider or Lazy of a scoped binding. A Lazy of a scoped binding can still be useful to defer initialization[dr][ds]. The underlying implementation in Metro’s DoubleCheck prevents double memoization in this case.

> Why doesn’t Provider just use a property like Lazy?

A property is appropriate for Lazy because it fits the definition of being a computed value that is idempotent for repeat calls. Metro opts to make its Provider use an invoke() function because it does not abide by that contract.

Providers of Lazy[dt][du][dv][dw][dx][dy]

Metro supports combining Provider and Lazy to inject Provider<Lazy<T>>. On unscoped bindings this means the provider will return a new deferrable computable value (i.e. a new Lazy). Meanwhile Lazy<Provider<T>> is meaningless and not supported.

Bindings

Qualifiers

Like Dagger and KI, Metro supports qualifier annotations to allow disambiguation of types. These are applied at injection and provision sites. A qualifier annotation is any annotation annotated with @Qualifier. For convenience, there is an included @Named qualifier available in Metro’s runtime that can be used too.

A “type key” in Metro is composed of a concrete type and (if any) qualifier annotation attached to it.

@DependencyGraph
interface AppGraph {
 
val int: Int
 
@Named("named") val namedInt: Int

 
@Provides
 
fun provideInt(): Int = 3

 
@Provides
 
@Named("named")
 
fun provideNamedInt(): Int = 4
}

@Binds

In many cases, a developer may have an implementation type on the graph that they want to expose as just its supertype.

Like Dagger, Metro supports this with @Binds.

For these cases, an abstract provider can be specified with the following conditions.

@DependencyGraph
interface AppGraph {
 
val message: Message


 
// Bind MessageImpl as Message
 
@Binds val MessageImpl.bind: Message

 
@Provides
 
fun provideText(): String = "Hello, world!"
}

@Inject
class MessageImpl(val text: String) : Message

If you want to limit access to these from your API, you can make these declarations private and just return this. Note it’s still important to annotate them with @Binds so that the Metro compiler understands its intent! Otherwise, it’s an error to implement these declarations.

@Binds declarations can also declare multibinding annotations.

@DependencyGraph
interface AppGraph {
 
val messages: Message

 
@Binds @IntoSet val MessageImpl.bind: Message
}

NOTE: In theory, you can implement a provider with a getter that replicates this (similar to how kotlin-inject uses @get:Provider + this), but this will be an error in FIR because Metro can generate more efficient code at compile-time if you use @Binds. This is because Metro can avoid calling the function entirely and just use this information at compile-time to optimize the generated code.

Multibindings

Like Dagger and KI, Metro supports Set and Map multibindings. Multibindings are collections of bindings of a common type. Multibindings are implicitly declared by the existence of providers annotated with @IntoSet, @IntoMap, or @ElementsIntoSet.

@DependencyGraph
interface SetMultibinding {
 
// contains a set of [1, 2, 3, 4]
 
val ints: Set<Int>

 
@Provides @IntoSet fun provideInt1() = 1

  @Provides @IntoSet fun provideInt2() = 2


 
@Provides

  @ElementsIntoSet
 
fun provideInts() = setOf(3, 4)
}

Map multibindings use @IntoMap and require a map key annotation. Map keys are any annotation annotated with @MapKey. Metro’s runtime includes a number of common ones like @ClassKey, @StringKey, @IntKey, @LongKey, and @LazyClassKey*.

@DependencyGraph
interface MapMultibinding {
 
// contains a map of {1:1, 2:2}
 
val ints: Map<Int, Int>

 
@Provides
 
@IntoMap
 
@IntKey(1)
 
fun provideInt1() = 1


 
@Provides
 
@IntoMap 
 
@MapKey(2)
 
fun provideInt2() = 2
}

Alternatively, they can be declared with an @Multibinds-annotated accessor property/function in a component. This member will be implemented by the Metro compiler and is useful for scenarios where the multibinding may be empty.

@DependencyGraph
interface MapMultibinding {
 
@Multibinding
 
val ints: Map<Int, Int>
}

Multibinding collections are immutable at runtime and cannot be defined as mutable at request sites.

Map multibindings support injecting map providers, where the value type can be wrapped in Provider.

@DependencyGraph
interface MapMultibinding {
 
@Multibinding
 
val ints: Map<Int, Provider<Int>>
}

Optional Dependencies

Metro supports optional dependencies by leaning on Kotlin’s native support for default parameter values. These are checked at injection sites and are allowed to be missing from the dependency graph when performing a lookup at validation/code-gen time.[ee][ef][eg][eh]

The below example would, since there is no Int binding provided, provide a message of Count: -1.

@DependencyGraph
interface AppGraph {
 
val message: String

 
@Provides fun provideMessage(count: Int = -1) = "Count: $count"
}

Dagger supports a similar feature via @BindsOptionalOf, but requires a separate declaration of this optional dependency to the graph.

KI supports the same feature.

Implementation notes[ei][ej][ek]

While kotlin-inject can support this by simply invoking functions with omitted arguments, Metro has to support this in generated factories.

To accomplish this, Metro will slightly modify how generated provider/constructor injection factory classes look compared to Dagger. Since we are working in IR, we can copy the default value expressions from the source function/constructor to the factory’s newInstance and create() functions. This in turn allows calling generated graphs to simply omit absent binding arguments from their creation calls. This is a tried and tested pattern used by other first party plugins, namely kotlinx-serialization.

There are a few cases that need to be handled here:

Aggregation (aka Anvil)[el][em][en][eo][ep]

Metro supports Anvil-style aggregation in graphs[eq] via @ContributesTo and @ContributesBinding annotations. As aggregation is a first-class citizen of Metro’s API, there is no @MergeComponent annotation like in Anvil. Instead, @DependencyGraph defines which contribution scope it supports directly.

@DependencyGraph(scope = AppScope::class[er][es][et][eu][ev][ew])
interface AppGraph

When a graph declares a scope, all contributions to that scope are aggregated into the final graph implementation in code gen.

If a graph supports multiple scopes, use additionalScopes.

@DependencyGraph(
 AppScope::class,
 additionalScopes = [LoggedOutScope::class]
)

interface AppGraph

Similar to kotlin-inject-anvil, @DependencyGraph supports excluding contributions by class. This is useful for cases like tests, where you may want to contribute a test/fake implementation that supersedes the “real” graph.

@DependencyGraph(
 scope = AppScope::class,
 
excludes = [RealNetworkProviders::class][ex][ey][ez][fa][fb][fc][fd]
)

interface
TestAppGraph

@ContributesTo(AppScope::class)
interface
TestNetworkProviders {
 
@Provides fun provideHttpClient(): TestHttpClient
}

@ContributesTo

This annotation is used to contribute graph interfaces to the target scope to be merged in at graph-processing time to the final merged graph class as another supertype.

@ContributesTo(AppScope::class)
interface NetworkProviders {
 
@Provides fun provideHttpClient(): HttpClient
}

This annotation is repeatable and can be used to contribute to multiple scopes.

@ContributesTo(AppScope::class)

@ContributesTo(LoggedInScope::class)
interface NetworkProviders {
 
@Provides fun provideHttpClient(): HttpClient
}

@ContributesBinding

This annotation is used to contribute injected classes to a target scope as a given bound type.

The below example will contribute the CacheImpl class as a Cache type to AppScope.

@ContributesBinding[fe][ff](AppScope::class)
@Inject
class CacheImpl(...) : Cache

For simple cases where there is a single typertype, that type is implicitly used as the bound type. If your bound type is qualified, for the implicit case you can put the qualifier on the class.

@Named("cache")
@ContributesBinding(AppScope::class)
@Inject
class CacheImpl(...) : Cache

For classes with multiple supertypes or advanced cases where you want to bind an ancestor type, you can explicitly define this via boundType parameter.

@ContributesBinding(
 scope = AppScope::class,
 boundType = BoundType<Cache>()

)
@Inject
class CacheImpl(...) : Cache, AnotherType

Note that the bound type is defined as the type argument to @ContributesBinding. This allows for the bound type to be generic and is validated in FIR.

Qualifier annotations can be specified on the BoundType type parameter and will take precedence over any qualifiers on the class itself.

@ContributesBinding(
 scope = AppScope::class,
 boundType = BoundType<@Named("cache")
 Cache>()
)
@Inject
class CacheImpl(...) : Cache, AnotherType

This annotation is repeatable and can be used to contribute to multiple scopes.

@ContributesBinding(
 scope = AppScope::class,
 boundType = BoundType<Cache>()

)
@ContributesBinding(
 scope = AppScope::class,
 boundType = BoundType<AnotherType>()

)
@Inject
class CacheImpl(...) : Cache, AnotherType

@ContributesIntoSet/@ContributesIntoMap

To contribute into a multibinding, use the @ContributesIntoSet or @ContributesIntoMap annotations as needed.

@ContributesIntoSet(AppScope::class)
@Inject
class CacheImpl(...) : Cache

Same rules around qualifiers and boundType() apply in this scenario

To contribute into a Map multibinding, the map key annotation must be specified on the class or BoundType type argument.

// Will be contributed into a Map multibinding with ClassKey(
@ContributesIntoMap(AppScope::class)
@StringKey("Networking")
@Inject
class CacheImpl(...) : Cache

// Or if using BoundType
@ContributesIntoMap(
 scope = AppScope::class,
 boundType = BoundType<@StringKey("Networking")
 Cache>()
)
@Inject
class CacheImpl(...) : Cache

This annotation is also repeatable and can be used to contribute to multiple scopes, multiple bound types, and multiple map keys.

@GraphExtension

TODO, but TL;DR works the same a        s kotlin-inject-anvil’s @ContributesSubcomponent.[fg][fh]

Implementation notes

This leans on Kotlin’s ability to put generic type parameters on annotations. That in turn allows for both generic bound types and to contribute map bindings to multiple map keys.

Because it’s a first-party feature, there’s no need for intermediary “merged” components like kotlin-inject-anvil and anvil-ksp do.

Generated contributing interfaces are generated to the metro.hints package and located during graph supertype generation in FIR downstream. Any contributed bindings are implemented as @Binds (± IntoSet/IntoMap/etc) annotated properties.

Multiplatform

Should Just Work™️ but will see when we get there! The runtime and code gen have been implemented to be entirely platform-agnostic so far.

There is one issue in the repo right now where the compiler appears to have a bug with generated FIR declarations where it doesn’t deserialize them correctly on non-JVM targets. Waiting for feedback from JB.

When mixing contributions between common and platform-specific source sets, you must define your final @DependencyGraph in the platform-specific code. This is because a graph defined in commonMain wouldn’t have full visibility of contributions from platform-specific types. A good pattern for this is to define your canonical graph in commonMain without a @DependencyGraph annotation and then a {Platform}{Graph} type in the platform source set that extends it and does have the @DependencyGraph. Metro automatically exposes bindings of the base graph type on the graph for any injections that need it.

// In commonMain
interface AppGraph {
 
val httpClient: HttpClient
}

// In jvmMain
@DependencyGraph
interface JvmAppGraph : AppGraph {
 
@Provides fun provideHttpClient(): HttpClient = HttpClient(Netty)
}

// In androidMain
@DependencyGraph
interface AndroidAppGraph : AppGraph {
 
@Provides fun provideHttpClient(): HttpClient = HttpClient(OkHttp)
}

Validation and Error Reporting

Common programmer/usage errors are implemented in FIR. This should allow errors to appear directly in the IDE, offering the best and fastest feedback loop for developers writing their code.

TODO IDE screenshot example

Dependency graph validation is performed at the per-graph level. Metro seeks to report binding validation errors at least on par with Dagger, if not better.

ExampleGraph.kt:6:1 [Metro/DependencyCycle] Found a dependency cycle:
        kotlin.
Int is injected at
           [test.ExampleGraph] test.ExampleGraph.provideString(..., int)
        kotlin.String
is injected at
           [test.ExampleGraph] test.ExampleGraph.provideDouble(..., string)
        kotlin.
Double is injected at
           [test.ExampleGraph] test.ExampleGraph.provideInt(..., double)
        kotlin.
Int is injected at
           [test.ExampleGraph] test.ExampleGraph.provideString(..., int)

Binding errors take learnings from Dagger and report fully qualified references that IDEs like IntelliJ can usually autolink.

ExampleGraph.kt:6:1 [Metro/GraphDependencyCycle] Dependency graph dependency cycle detected! The below graph depends on itself.
        test.CharSequenceGraph
is requested at
           [test.CharSequenceGraph] test.CharSequenceGraph.Factory.create()

Note that binding graph resolution currently only happens in the compiler IR backend, but maybe someday we can move this to FIR to get errors in the IDE.

Interop

Annotations

Metro supports user-defined annotations for common annotations. This means that a user doesn’t necessarily have to use Metro’s annotations if they’re introducing it to an existing codebase. Support varies depending on the annotation’s use case.

Compile-only annotations are mostly supported. This includes the following:

These are configurable via Metro’s Gradle extension.

metro {
 
customAnnotations[fi][fj] {
   assisted.add(
"dagger/assisted/Assisted")
 }
}

For Dagger and KI specifically, there are convenience helper functions.

metro {
 customAnnotations {
   includeDagger()
   includeKotlinInject()

    includeAnvil()
 }
}

@DependencyGraph is replaceable but your mileage may vary if you use Anvil or modules, since Metro’s annotation unifies Anvil’s @MergeComponent functionality and doesn’t support modules.

Similarly, @ContributesBinding is replaceable but there are not direct analogues for Anvil’s @ContributesMultibinding or kotlin-inject-anvil’s @ContributesBinding(multibinding = …) as these annotations are implemented as @ContributesInto* annotations in Metro. Also - boundType in metro uses a more flexible mechanism to support generics.

Intrinsics like Provider and Lazy are not supported because their semantics are slightly different. However, we could look into this in the future as integration artifacts that offer composite implementations (similar to how Dagger’s internal Provider implements both javax.inject.Provider and jakarta.inject.Provider now).

Components

Metro graphs can interop with components generated by Dagger and Kotlin-Inject. These work exclusively through their public accessors and can be depended on like any other graph dependency.

@DependencyGraph
interface MetroGraph {
 
val message: String

 
@DependencyGraph.Factory
 
fun interface Factory {
   
fun create(
     daggerComponent:
DaggerComponent
   ): MetroGraph
 }
}

@dagger.Component
interface DaggerComponent {
 
val message: String

 
@dagger.Component.Factory
 
fun interface Factory {
   
fun create(@Provides message: String): DaggerComponent
 }
}

Conversely, kotlin-inject and Dagger components can also depend on Metro graphs.

@DependencyGraph
interface MessageGraph {
 
val message: String

 
// ...
}

// Dagger
@Component(dependencies = [MetroGraph::class])
interface DaggerComponent {
 
val message: String

 
@Component.Factory
 
fun interface Factory {
   
fun create(messageGraph: MessageGraph): DaggerComponent
 }
}

// kotlin-inject
@Component
Abstract
class KotlinInjectComponent(
 
@Component val messageGraph: MessageGraph
) {
 
val message: String
}

Generating Metro code

Java annotation processing and KSP both support multiple rounds of processing, allowing custom processors to generate new code with injection annotations that can be processed in later rounds. Anvil supported custom CodeGenerator implementations in K1 and anvil-ksp and kotlin-inject-anvil support specifying custom contributing annotations to allow them to intelligently defer processing to later rounds.

Since Metro is implemented as a compiler plugin, asking users to write compiler plugins to interact with it would be a bit unwieldy. However, KSP processors that generate metro-annotated code work out of the box with it since they run before Metro’s plugin does.[fk][fl]

If you have an existing KSP processor for a different framework, you could leverage it + custom annotations interop support described above to make them work out of the box with Metro.


Debugging

One major downside to generating IR directly is that developers cannot step into generated source code with the debugger. This is an accepted trade-off with Metro (or any other compiler plugin).

Metro does offer a debug option in its plugin options/Gradle extension that will print verbose Kotlin pseudocode for all generated IR classes. This can be further tuned to print just certain classes.

metro {
 debug.
set(true)
}

In the future, we could possibly explore including information in IR to synthesize call stack information similar to coroutines, but will save that for if/when it’s asked for.


Open Questions

Questions from design doc discussion

Design Doc Changelog

List of decisions/changes made based on design doc feedback

[a]i think this may be a common thought. suggest moving it up below the overview

[b]Fair!

[c]Moving up

[d]_Marked as resolved_

[e]_Re-opened_

[f]Note to self - wanna reword this better. Want to make it clear the goal is unification of best ideas and explicitly builds off of what they do well + borrows generously.

[g]how does this affect the maintenance story? kotlin updates are already tough enough w/ compose, ksp, & other core kotlin libraries

[h]Same as basically any other compiler plugin unfortunately. Some releases will need need companion releases of Lattice to work with them. That said, I have a pretty good track record with existing plugins and could add a explicit commitment around releasing with EAP releases of Kotlin.

1 total reaction

James Barr reacted with 👍 at 2024-12-30 20:12 PM

[i]@Binds? @BindsInstance?

[j]Lattice has both! This is just differences

[k]I was hoping it wouldn't have them. Discussed below that I find them redundant. I'll resolve this conversation.

[l]_Marked as resolved_

[m]_Re-opened_

[n]not that important but this is a holdover from the KAPT implementation, do plan to get rid of it

[o]ahh good to know

[p]not saying you need to support this first party, but please consider that any core logic is pushed down into a core module not tied to gradle so other build systems can hook this up, too

[q]yep it works fine with other plugins, the gradle plugin is a thin shim just for how plugins are normally applied. Added a mention for other build systems below it when I saw you highlight this :)

1 total reaction

James Barr reacted with 😆 at 2024-12-30 20:10 PM

[r]Added a bazel sample snippet!

[s]_Marked as resolved_

[t]_Re-opened_

[u]Curious why you landed on this name? Find it super interesting you are moving away from 'Component'

[v]I've always found dagger's names for some things to be a bit vague and sometimes too overloaded, so one of the goals is to use more obvious names where it made sense.

"Component" and "Module" in particular really run up against it if you're also working in a gradle project, or heck even with Module you conflict with java.lang's own Module type 😅.

This name floated to the top of a few ideas. When this was still called lattice, `@Lattice` was an option. `@Graph`, `@Dependencies`, `@Scope`, `@BindingGraph`, and some others were in the picture. This felt the most natural and also lends itself well to naming annotated types with it (i.e. "AppGraph"). It also lent itself well to the later `@GraphExtension` concept later as a replacement for `@ContributesSubcomponent`/`@Subcomponent`.

[w]👍🏻

[x]I'd like to have a solution where `@Provides` functions aren't manually callable and you can only use properties to extract objects from the graph.

1 total reaction

Daniel Santiago reacted with ➕ at 2024-12-17 19:43 PM

[y]Something I've been thinking about after working on optional bindings is that now that lattice generates factories as nested classes of their parent interface directly, it also means they can call private functions and we can just mark providers as all private. With FIR we can even require it. How does that sound?

[z]Confirmed and implemented this in the FIR checker now. Plan to make this a configurable warning (i.e. so migrations can be done incrementally)

[aa]Using `FirStatusTransformerExtension`, you could make this conversion to private automatic if you would like. Sort of like all-open but in reverse. See https://github.com/JetBrains/kotlin/blob/master/plugins/plugin-sandbox/src/org/jetbrains/kotlin/plugin/sandbox/fir/AllOpenStatusTransformer.kt as an example.

[ab]Nice! Do you know how that would appear in the IDE by chance? Filed this to track https://github.com/ZacSweers/lattice/issues/53

[ac]Piecemeal does this on constructors: https://github.com/bnorm/piecemeal/blob/main/piecemeal-plugin/src/main/kotlin/dev/bnorm/piecemeal/plugin/fir/PiecemealFirStatusTransformerExtension.kt

Shows up as an INVISIBLE_REFERENCE error in the IDE with K2 mode enabled .

1 total reaction

Zac Sweers reacted with 🚀 at 2024-12-19 07:20 AM

[ad]That's cool!

[ae]Something Dany Santiago pointed out is this may have incremental compilation repercussions. I wonder if relying solely on a status transformer can help us work around that by making it still participate in IC but private in all the relevant ways. Another option could be to annotate them with `@PublishedApi`, though I'm not sure if that explicitly requires them to be `internal` in source too.

[af]The more I think about it, I think this is actually fine wrt IC. Adding a new private function alone wouldn't trigger recompilation, but it _should_ transitively trigger one because a new provider factory class would be generated. Bigger question might be whether or not the new class declaration needs to be generated in FIR first or we need to add metadata to mark it as visible, which we can do

[ag]I like the fact that kotlin-inject has no member injection. Is this really needed?

[ah]Deduping this into https://docs.google.com/document/d/1B1Soh0rrVr1BX-Nn2JrZwST5R9afsqBxAS2XNJZFEuE/edit?disco=AAABafDxhkE

[ai]_Marked as resolved_

[aj]_Re-opened_

[ak]If this provider was scoped, will the impl override it to use a scoped provider?

By being the component it is both a binding provider and a binding getter.

[al]Good point, I guess we should require them to be consistent

[am]Conclusion for now has been to disallow provider overrides

[an]_Marked as resolved_

[ao]_Re-opened_

[ap]Is it OK for a provider to use other property / providers in the component interface? Maybe not good... I guess people can already do that with Dagger modules

(Trying to think on the choice of no modules and combining classes that define provisions, with the component to retrieve provisions)

[aq]I think we can advise against it but in general it's probably not something we should try to write analysis for. We have a lint check in github.com/slackhq/slack-lints that checks for calling providers outside of generated code as an example of checking this elsewhere

1 total reaction

Daniel Santiago reacted with 👍 at 2024-12-17 15:40 PM

[ar]Added a note advising against it in the providers section

[as]_Marked as resolved_

[at]_Re-opened_

[au]💯 on top of the obvious of being explicit what types you are actually providing, ran into a fun issue in kotlin-inject where if you left off the type and called a java function It'd return a platform type which didn't match with anything and left a very confusing error message.

[av]yep. Another technical reason for this is that otherwise the implicit return type is not actually resolved when we generate the declaration in FIR

[aw]I'm thinking with FIR we could eventually move this to be

ExampleComponent.create()

// Or for factories

ExampleComponent.create(...)

1 total reaction

Brian Norman reacted with ➕ at 2024-12-18 20:04 PM

[ax]This is essentially implemented on main now, but pending figuring out why FIR-generated declarations are not appearing when I test with the K2 plugin

[ay]Is only one component allowed? (No multiple parent?)

[az]Could you unpack that a bit more for me?

[ba]Sorry, should have been more clear.

I see here AppComponent depends on MessageComponent, would it be possible for the create() factory to depend on more than one component?

Or to ask differently, is this 'subcomponents' or 'component dependencies' (in terms o Dagger)?

It looks like component dependencies.

[bb]ah yeah this is component dependencies. Can have multiple!

[bc]_Marked as resolved_

[bd]_Re-opened_

[be]Why are they needed and what's their value? Can the functions be called inline? I didn't miss them in kotlin-inject.

[bf]kotlin-inject still generates them, it just generates it in each component. This avoids that duplication

[bg]Resolving as answered and adding a bit more context. Feel free to reopen if needed though!

[bh]_Marked as resolved_

[bi]_Re-opened_

[bj]Is this used a lot in practice? I can see value in this only for widely shared libraries, but for nothing else. If any dependency is scoped, then the production components needs the same scope. 

I liked with Anvil a lot that a everything you provide is added to a specific scope.

[bk]fair point, something to think about with the other topic of whether or not to allow overrides. This could just be reserved for contributed interfaces

[bl](Decided separately to not allow provider overrides)

[bm]This is honestly a very good idea. I think I have an example of this in the kotlin-inject docs somewhere should probably get rid of it.

[bn]It can be risky to use private functions because as you noted here they tend to not be visible across compilations and I think that is due to incremental compilation.

Somewhat similar bit us in Room because we analyze private fields and when those are coming from classes that are part of another compilation where the processor does not run it caused inconsistent build failures during incremental compilations.

Maybe the metadata API takes that into account assuming it now exposes some information publicly (in the metadata annotation) of something that was private. But I would be careful if taking strategies that 'wouldn't compile normally'.

[bo]Yeah hoping that the combination of that API + using a status transformer to hide access can thread this needle, but will need to test it well. CC Brian Norman

[bp]How much, in general, do you want to rely on FIR to expose generated code? It seems like a lot of the boiler plate here could be avoided if the factory interface was generated in FIR automatically.

[bq]I'm open to it if there's a means to still declare custom factories when needed. We often use base classes with common extensions for factories. That's a good point that FIR could solve a lot of these

[br]What kind of extensions? Like a `NeedsDurationFactory<T>` interface that has the boilerplate of defining the create function?

But it should be pretty easy to see if a @AssistedFactory interface already exists as a nested class and not automatically generate one in that case.

[bs]Some of it is boilerplate but some of it is common extensions to factories. For example with Android fragments - you could imagine something like a `create(intent: Intent)` regular creator but then extensions to do `Factory.create(id: String)` that converts that into an intent that passes a user ID in.

The other tricky thing is that n-number of factories can be generated, they don't have to be a nested class. Any AssistedFactory-annotated SAM type that returns the target type is valid. But maybe given that we can just always generate a default one and people can define n extras if they want?

1 total reaction

Brian Norman reacted with 🤔 at 2024-12-18 20:40 PM

[bt]Partially implemented in https://github.com/ZacSweers/lattice/pull/60, but behind a flag right now unless there's a fix for making these generated types appear in the IDE

[bu]Added a note here: https://docs.google.com/document/d/1B1Soh0rrVr1BX-Nn2JrZwST5R9afsqBxAS2XNJZFEuE/edit?pli=1&tab=t.0#heading=h.m7kzefhdvber

Details the new option but also cases where that option wouldn't be suitable. Open to suggestions on how to bridge those gaps though! Gonna close this one out in the mean time as partially implemented!

[bv]_Marked as resolved_

[bw]_Re-opened_

[bx]May be my lack of experience building DI tooling, but I'm failing to see why `@Inject` could just be reused here. Or is there some reason to have a separate annotation?

[by]Fair point. I suspect dagger does this because it's technically not JSR-330 compliant on its own, but maybe Dany Santiago can better say

[bz]In Dagger the @AssistedFactory is not required to be a nested class. 

So if you had an @Inject constructor and one day decide to create an @AssistedFactory in some other file, does it break consumers of the @Inject type (requiring them to go through the factory)? To avoid such issues Dagger went with a new annotation.

[ca]interesting, does this mean dagger supports having both a regular and assisted inject annotation on a class?

[cb]No, Dagger only allows either @Inject or @AssistedInject in the constructor.

1 total reaction

Zac Sweers reacted with 👍 at 2024-12-25 22:04 PM

[cc]Thanks for the clarification. Going to prototype supporting just `@Inject` and see how far we can get

[cd]Removed! Just using @Inject now

[ce]_Marked as resolved_

[cf]_Re-opened_

[cg]This seems like it should be a qualifier? Probably super annoying if you have to create the factory yourself. But if it's done automatically with FIR...

[ch]I think they're different than qualifiers and it's probably better to not mix them? I like the explicitness of it and it indicates to the component processor that these dependencies are runtime-provided. This is relevant because the factory gen for this happens independently of the assisted factory impl generation

1 total reaction

Brian Norman reacted with 👍 at 2024-12-18 20:39 PM

[ci]_Marked as resolved_

[cj]_Re-opened_

Sorry to re-open this, but I wanted to make clear that my qualifier comment was about the parameter of the `@Assisted` annotation rather than the annotation itself. So instead of `@Assisted("connect")` this should maybe be `@Assisted @Named("connect")` to keep the ideas separate. But I can definitely see how including an option in `@Assisted` make it easier to use.

[ck]ohhhh I gotcha now. Yeah that's an interesting idea. I'll add a note/issue for that, the nice thing with this approach is that we could always add that support easily later

[cl]Plus one to Brian's note. This is an edge case which could be handled for all scenarios with a qualifier; do I always need the assisted annotation in the factory? should i give all my assisted annotations names? what if i put a qualifier in the constructor, but not in the factory, and I get the assisted names right? Vice versa? Lots of thinking that is unnecessary with the existing API in dagger2's assistedinject.

[cm]In kotlin-inject decided to go with making them positional instead of only relying on type/key. The reasoning is they work more like regular function args than something being injected. https://github.com/evant/kotlin-inject/issues/164

[cn]If you auto-generate the factory then I don't think there can be an ambiguity.

[co]yah that'll hopefully eventually be the standard case, though until IDE support for generated FIR declarations improves I think we can't rely on it yet

[cp]If this happens, then the name parameters are fantastic.

A good compromise could maybe be something like this:

```

class MyClass @AssistedInject constructor(

  @Assisted("p1") p1: String,

  @Assisted("p2") p2: Int,

) {

  @AssistedInject.Factory

  interface MyFactory

}

```

Which would preserve an annotation site for the generated case, and an extension site for the interface extension case.

Not sure if that's technically viable, though.

1 total reaction

Brian Norman reacted with ➕ at 2025-01-12 04:52 AM

[cq]That's an interesting angle. I think it may be possible, will add a note to the issue tracking it

[cr]My hot take is member injection is always an anti-pattern which is why kotlin-inject doesn't implement it. 🌶️

[cs]haha that's fair. Sometimes it's unavoidable, I do plan to emphasize in the final doc that it should be avoided and there's an FIR warning as well that will flag if you try to have injected members in a constructor-injected class

[ct]So I'm not really a fan of kotlin-inject's function injection api. This solves a lot of problems with it. The reason I didn't go with something like this is it means generated code at user call-sites which is awkward with KSP.

[cu]I want to say that 98% of our classes are singletons. In Square's codebase it was similar. Why not flip this and make types singletons by default and have an annotation like `@NoSingleton` or `@NewInstance` to opt-out of this behavior?

[cv]hmm I don't think we're the same. Probably still a majority, but 98%. My gut feeling is that the lesser evil is to accidentally create more instances than you intend rather than persisting ones longer than you'd expect, but I'd be curious for Jesse and Dany Santiago's thoughts on this too

[cw]I'm on the opinion that scopes are for correctness more than performance and thus require some reasoning and intent of their life so adding the scope makes that clear, the default being no scope.

[cx]Jesse Wilson

[cy]Making this mandatory is interesting.

One thing that dagger supports that I think is of mixed utility is injections that, because they are not bound to a particular graph, are accessible from anywhere. Mandatory scopes would really improve the legibility of the graph here, even if they are a little more boilerplate. And that is a good thing - DI suffers from being too magical and mysterious

[cz]To my point, scoping would still be explicit. But instead of

@SingleIn(AppScope::class)

@ContributesBinding(AppScope::class)

@Inject

class Abc : Def

I'd like to write

@ContributesBinding(AppScope::class)

class Abc : Def

The scope is still clear, but everything is a singleton by default. I wouldn't even go further and say singletons are the ONLY option. If you need your object graph to create a new instance every time, then add a factory and make it explicit (similar to assisted injection without an assisted parameter).

We had memory leaks in our app and solved this through the Factory and making this behavior explicit.

[da]What happens to classes with a bare @Inject and no binding?

[db]Wait, are we still talking about the same thing? This thread started with making all instances in a component singletons by default. If you use a type in a component that only has an @Inject, then the component still has to instantiate it and can make it a singleton.

But if you ask about associating a type with a scope explicitly, then one solution could be to write a provider method similar to what you'd need to do with types that you don't own and that don't have a @Inject constructor.

[dc]> If you use a type in a component that only has an @Inject, then the component still has to instantiate it and can make it a singleton.

Which is why I bring it up. Which component does that object live in?

Having it be a global singleton is an option, but....not one I like. Having it be scoped to wherever it happens to be referenced is scary, because it could be referenced from multiple scopes. And those references can change over time.

Another option is to have bare @Inject do nothing: an explicit component is required somehow.

[dd]I prefer the latter. That is similar to how it works today for classes you don't own and don't have an @Inject.

[de]Then @Inject can safely be omitted?

[df]If the intent is clear through another annotation and there's only a single constructor, then yes in my opinion. I don't now if Zac wants to go in this direction, though.

[dg]Ofc, absolutely.

In my view it's worth considering what actually gets made redundant if we make Anvil style hookup a first class citizen. There may be other considerations ofc, but if you're gonna make a fresh tool...

(I personally am less sold on scoped by default than I am on mandatory scoping. Mainly because I am in memory leak head space this past year.)

[dh]KI only supports 0..1 scopes per component, Dagger supports 0..N. This is a big difference and very inconvenient in KI. Worth calling out for Lattice.

[di]I'll update this to specify it matches Dagger 👍

[dj]_Marked as resolved_

[dk]_Re-opened_

[dl]Kill this special case and go with the @SingleIn(AppScope) api!

Not even sure if the notion of "singleton" can even be supported if we don't distinguish subcomponents from components

[dm]I think you and Ralf Wondratschek have sold me on at least the idea of moving this to an optional/compat annotations lib that includes this 👍

[dn]Just checking but I suppose only LazyThreadSafetyMode will be used

[do]I actually have a hybrid implementation, will send it to ya on slack. It essentially matches dagger's DoubleCheck impl with some kotlin semantics from its synchronized lazy impl

1 total reaction

Daniel Santiago reacted with 👍 at 2024-12-17 15:51 PM

[dp]_Marked as resolved_

[dq]_Re-opened_

[dr]How come? I would imagine that a Provider of a scoped binding won't also instantiate the binding (or call the provider) until the first Provider.get(), so I do think for scoped bindings Lazy and Provider are equivalent.

[ds]Mostly just explicit semantics. The caller may not (need to) know if the binding is scoped or not but does want to be mindful of it at its use-site.

[dt]Why is Lazy needed? Whether an instance should be cached can and should be decided on the provider side. At Square Lazy was more like an anti-pattern and I still agree with that.

[du]are you talking about Lazy<Provider<T>> or Lazy in general? Your comment reads like the latter but the comment is on specifically the former

[dv]Lazy in general, but I added the comment here because then you'd avoid this weird things like Lazy<Provider<Abc>> and Provider<Lazy<Abc>>.

[dw]This is mostly just for parity with Dagger, I've never used it but it's easy enough to just leave in. I def disagree on Lazy :). It's pretty crucial to how internal scoped providers work anyway so 98% of the work to support it as an intrinsic is already there anyway

[dx]Internally you could replace this Lazy with Kotlin's built-in imlementation.

[dy]I meant more DoubleCheck. The impl in lattice's DoubleCheck is a bit of a hybrid impl of both dagger's and kotlin's lazy that uses the best of both (multiplatform-friendly concurrency, dagger's reentrancy checks, avoiding double-wrapping). Basically kotlin's impl + dagger niceties

Re: Lazy - I think it's valuable enough to keep around. You could move it off to consumers, but you could also argue a lot of DI features could be handled by the consumer and I think these affordances for common use cases add up to a smoother experience :). It'd essentially be this case...

class Example(foo: Provider<Foo>) {

private val foo by lazy(foo)

}

vs

class Example(foo: Lazy<Foo>)

...multiplied by every time it's used.

Another factor is that when we support lazy directly, we can skip an intermediary provider for scoped types (which the caller doesn't need to know about as it's a decision higher up in the graph). Under the hood a DoubleCheck is used and we can just pass that instance on directly, whereas if we only supported provider it would get re-wrapped by the consumer in a new lazy. Not only that but since DoubleCheck is an internal API, they wouldn't be able to use it and its "is this delegate already a DoubleCheck?" double-wrapping avoidance.

ProviderOfLazy I'm less fussed about, but because it's trivial to support I'm inclined to leave it in as it's one more bit of feature parity.

I can dump a bunch of this into the implementation notes for posterity, how does that sound? Basically make it clear why it's there, and if individual developers don't want to use that pattern then they can just not use it?

[dz]Why have this also be @Provides if the api is different? Why not annotate with @Binds instead?

[ea]I think I'm going to add `@Binds` back for this reason and https://docs.google.com/document/d/1B1Soh0rrVr1BX-Nn2JrZwST5R9afsqBxAS2XNJZFEuE/edit?pli=1&disco=AAABadmcMUY

[eb]Resolving, Binds is officially back

[ec]_Marked as resolved_

[ed]_Re-opened_

[ee]I can see this being a foot gun. Could a strict mode be included? I don't have a good sense for the api utility, so...not sure what direction to push this

[ef]I thought about maybe requiring an `@OptionalDependency`-esque annotation that could be a configurable mode. Would that satisfy what you're thinking?

[eg]Sure! I just don't want folks adding default params for the unit tests, and then getting surprised when they forget to plug in the live impl

[eh]Ahh good point. Offers two extra thoughts

- Such an annotation could be made to only be enforced on classes. Providers already have an implicit expectation that you shouldn't call them directly

- I wonder if there's an alternative to that mode where you specify this at the constructor level. i.e. we treat inject constructors like we do providers - don't call them from source. If you want your own, make a test-only one.

[ei]After talking with compiler folks, I'm planning to rework this to actually port default value expressions to factories directly and allow components to selectively omit providers as well. That'll be more compatible with later lowering and avoid guessing target functions

[ej]_Marked as resolved_

[ek]_Re-opened_

[el]Is this optional? The recommendation? The different kind of scopes for aggregation and scoping is confusing. Anvil had no choice given that it was an extension.

For example, you provide `@Singleton` by default, why not `@SingleIn(scope)`?

[em]Currently it's optional, but usable without an extra annotation or intermediary. I'd be open to making it the recommendation. Did you have something in mind for unifying `@Scope` and anvil scopes in some way?

[en]Mostly strongly encouraging these annotations: https://github.com/amzn/kotlin-inject-anvil/tree/main/runtime-optional/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil Maybe not enforce, but provide them by default and use them in all samples. Don't provide `@Singleton`.

[eo]I don't have strong feelings other than the familiarity aspect. Jesse Wilson Dany Santiago thoughts?

[ep]I already said it above, but plus one to ralf. The whole notion of singleton is relative anyway.

[eq]My biggest concern on the API surface for contributions is answering all the issues that the Anvil haters I spoke with had.

I suspect that you win here by having vastly improved error feedback, but would love to have a word with them again to make sure.

[er]Maybe weird comment: what about multiple scopes?

We use anvil contributions to juggle different build variants, and being unable to support multiple scopes here is a pain. E.g. AppScope plus DebugInternalAppScope

[es]I suppose it makes sense since graphs can support multiple scope annotations too. Might do something more like this though to keep the simple case simple

```

@DependencyGraph(AppScope::class)

@DependencyGraph(

scope = AppScope::class,

additionalScopes = [...]

)

```

CC Ralf Wondratschek

[et]@MergeComponent is repeatable for that purpose https://github.com/square/anvil/blob/main/annotations/src/main/java/com/squareup/anvil/annotations/MergeComponent.kt

[eu]TIL

[ev]Right, but we can't make `@DependencyGraph` repeatable I don't think

[ew]Solved by adding an `additionalScopes` property to DependencyGraph. Doing this separation so that the common case (one scope) is still the same but the toe-hold is there for more

[ex]If excluding interfaces with providers is a first-class API then maybe disallow provider overrides in general and avoid solving the questions you have for it.

[ey]That's an interesting point. Will see what ralf thinks, I haven't used kotlin-inject-anvil in a large enough codebase to know how often people use one or the other

[ez]Ralf Wondratschek ^^

[fa]Mentioned this above as well. I think `replaces` and `exclude` are better mechanisms than overriding functions.

[fb]Agreed, going to prevent provider overrides for now 👍

[fc]_Marked as resolved_

[fd]_Re-opened_

[fe]How does @ContributesBinding interact with scopes and qualifiers?

For qualifiers, maybe a type annotation:

@ContributesBinding<@Named("TacoCache") Cache>(AppScope::class)

For scope, maybe similar to qualifier, it goes in the type param. 

It seems possible to scope the binding but not the impl, in the example of where you want to have two distinct CacheImpl, for two different scoped and qualified Cache bindings.

[ff]We could allow them to be on the generic type. In Anvil they just defer to the scope/qualifier annotation on the type itself. Ralf Wondratschek?

[fg]I think there is room for improvement here. The subcomponent/component dependency api is weird, it's a frequent point of noob confusion, the direction of reference is inverted across the two for reasons that make no sense as a user, and it is also a case where I'd argue that having distinct annotations doesn't help - you just end up having a bunch of duplicate annotations that mean the same thing.

[fh]Have you looked at kotlin-inject-anvil's implementation? It's more or less an automatic wiring of a component with a dependency, rather than really like dagger's subcomponents

[fi]1. Interested to see more detail on what the custom annotations API looks like

2. Warnings? A migration path would be great

[fj]This is essentially it. The idea would be that if you have a codebase that currently uses a bunch of existing annotations, you can just tell lattice to use those. Do you have suggestions on what else you'd wanna see?

[fk]That defeats some of the benefits of Lattice, in particular build time.

[fl]Some yes, this is more of a note for people worried about adoption friction. If there's a desire for writing custom transformers directly we can discuss in the repo 👍

[fm]+1

[fn]String and String? are related but different types. I can assign a String to a String?, but not the other way around. I don't want to call it polymorphism though.

[fo]I'm thinking since it is a key part of the language, it should behave like the language, String can satisfy String? but not the other way around. i.e. a special form of optional binding declared by the type nullability.

[fp]Some questions that come to mind:

- What's the difference between that and, say, having a String available on the graph but requesting a CharSequence?

- Would we enable this by just adding an implicit nullable binds for every type on the graph? Or just when a nullable type is requested, we should check for both the nullable and fall back to the non-null type?

[fq]I would scope it to nullability only, say 'nullness assignability' and not assignability in general (like the String to CharSequence).

My concern is more on the ramifications of having a non-null binding that satisfies nullable requests and one day user adds a nullable provider of same type but totally different value and now all their nullable requests have changed.

That might be solvable with having the two types considered the same in terms of keys and would cause a multiple binding error.

Similarly requesting a non-null when nullable provider was is not a 'missing binding' but more of a 'unable to satisfy binding request due to nullability'

So I'm think this means 'fallback' if 'nullness assignability' permits it.

[fr]I was leaning towards "just have nullable and non nullable be distinct dep points," but:

> That might be solvable with having the two types considered the same in terms of keys and would cause a multiple binding error.

I am having a hard time rallying any enthusiasm for supporting the distinction between nullable and non nullable in any dep graph. What good can come of it?

If you want both, just add a qualifier for christ's sake!

[fs]yeah I'm fairly on the fence at the moment and inclined to lean toward initially disallowing nullable types on the graph to start and gathering more community feedback. Easier to add it later than to try to claw it back

[ft]String is a subtype of String? If you don't match other subtypes then it feels weird to me to special-case those.

[fu]My alternative design idea for this is to generate an implicit delegating class of the same name as the function in FIR and make that available to users.

[fv]_Marked as resolved_

[fw]_Re-opened_

[fx]Some JB compiler folks have pointed me at ways to possibly achieve this

1 total reaction

Brian Norman reacted with 👋 at 2024-12-18 21:05 PM

[fy]No

[fz]Jesse had some ideas around this that this is a reference to

[ga]Jesse Wilson

[gb]Our solution to this problem is here: https://speakerdeck.com/vrallev/managing-state-beyond-viewmodels-and-hilt-updated?slide=60 It's very similar to what Square has. Our Scope abstraction hosts DI components, it hosts a CoroutineScope, but it's neither the Di component nor CoroutineScope itself. It allows objects to be notified when a scope gets created or destroyed. This is also what allowed us to migrate from Dagger 2 to kotlin-inject a lot easier.

[gc]The big concept that CoroutineScope could use is scope specific visibility. E.g. I want to be able to inject unqualified CoroutineScope, and have that resolve to the binding for my exact scope, and nothing else. And vice versa, I want my CoroutineScope binding to not be visible to any graph extensions.

So: graph private visibility, essentially

[gd]To play devil's advocate on that - could that be solved by something like a `@ConfineToGraph` API on the provider that instructs the compiler to disallow exposing it as a graph accessor?

[ge]Yep, something exactly like that. And it should also prevent leakage to graph extensions.

[gf]Neat, filed this to track https://github.com/ZacSweers/lattice/issues/77

[gg]For CoroutineScope we use the @ForScope qualifier again and each component has its own CoroutineScope. You can still inject the CoroutineScope from the parent graph, which has a longer lifecycle, but at least it's explicit.

[gh]Yep, same as what we do. We augment with an SPI plugin that prevents injecting the wrong scope.

[gi]Nice! We check in a KSP plugin, which has the same effect.

[gj]I had to roll my own for our Anvil validation. Needed something in terms of source files.

Providing a sample SPI plugin might suffice if that is supported

[gk]+1

[gl]It's a power tool for sure, but custom validation is clutch.

[gm]Dany Santiago Dagger requires both but curious if it’s necessary or if there’s ever a case where you have one without the other

[gn]You always need a key with @IntoMap, but having separate allows different types of keys, IntKey, LongKey, ClassKey, StringKey, etc. Also keeps it consistent in terms of the other multibinding data structure annotations, IntoSet, ElementsIntoSet

[go]Hm, if we just considered the presence of a MapKey to be an implicit `@IntoMap`, do you see anything functionally missing in that?

[gp]I don't see any functionality missing, but some new naming might be required for the key annotations: @IntoMapWithClassKey, @IntoMapWithStringKey, etc

[gq]I don't know Dagger/Kotlin-Inject well enough to know if this would be something useful in more areas then just components. Thought I would present the idea in case others had thoughts.

[gr]Thanks! To add another example from our slack convos - it would be neat if we could change the status of an abstract interface member and implement the body (i.e. val Impl.bind: Instance can become private and managed by the code gen