GeistHaus
log in · sign up

https://zacsweers.dev/rss

rss
45 posts
Polling state
Status active
Last polled May 19, 2026 04:45 UTC
Next poll May 20, 2026 08:14 UTC
Poll interval 86400s
ETag W/"239fd-z1ybNjMvVBJAwFvLJ3in/dA+n88"

Posts

🚇 Metro is Stable
MetroKotlinOpen Source
New here? Metro is a multiplatform, compile-time dependency injection framework for Kotlin implemented as a compiler plugin.

Metro 1.0.0 is out now and stable. This means that its runtime APIs (runtime, MetroX artifacts, Gradle plugin, etc.) are now API-stable unless annotated with an experimental annotation.

This is an

Show full content
New here? Metro is a multiplatform, compile-time dependency injection framework for Kotlin implemented as a compiler plugin.🚇 Metro is Stable

Metro 1.0.0 is out now and stable. This means that its runtime APIs (runtime, MetroX artifacts, Gradle plugin, etc.) are now API-stable unless annotated with an experimental annotation.

This is an exciting milestone! Metro's come a long way since its early days in November of 2024 as a prototype called Lattice. It's the proudest work of my career and I strongly feel this is how dependency injection in Kotlin should be.

Dozens of companies, small and large, have migrated to Metro already and are seeing amazing results in their build times when coming from traditional source generation tools. The improvements are often upwards of 50-80%. These results represent the kind of step-function improvement that dedicated DevXP teams might spend years trying to achieve.

For projects coming from runtime-based service location or manual DI, they're able to achieve all the production safety and developer productivity gains of an automated, true compile-time-validating DI system and without sacrificing build performance.

What's in the box

The headline is build performance, but Metro has grown into a pretty comprehensive framework along the way. A non-exhaustive tour:

Compiler & build performance

  • Pure compiler plugin: FIR for analysis and class header code gen, IR for all other codegen. No KSP/KAPT source generation pass.
  • All-in-one solution: no multiple moving parts (Dagger + Anvil + KAPT/KSP) with shifting compatibility stories. A single library means no breakages or compatibility issues.
  • Native, fine-grained integration with kotlinc's incremental compilation infrastructure.
  • traceDestination emits Perfetto traces of the IR pipeline.

True compile-time safety

  • Full dependency graph validation at compile time, not just reachability.
  • Compile-time cycle detection with the full cycle paths reported. Provider / Lazy are recognized as valid cycle breakers.
  • Compile-time scope correctness validation: unscoped graphs can't consume scoped bindings, scoped graphs must declare matching scopes.
  • Multibindings structurally validated; map keys cannot have duplicates, empty ones are an error by default.
  • Assisted parameters matched by name between constructor and factory.
  • No runtime DI machinery: no reflection, no hashmap lookups, no service locator, no global module registry, no worries.

Aggregation as a first-class citizen

  • @DependencyGraph(scope = ...) merges contributions directly, there are no intermediate merged-component facades.
  • @ContributesTo, @ContributesBinding, @ContributesIntoSet, @ContributesIntoMap, plus contributed binding containers.
  • Generic bound types via binding<T>().
  • @DefaultBinding on supertypes to avoid repeating binding() on subtypes.
  • replaces / excludes merging controls every contribution type
  • @Contributes* implies @Inject by default, eliminating redundant declarations.

Graphs

  • @GraphPrivate confines bindings to their declaring graph.
  • Graph extensions, including contributed extensions that materialize in a parent graph downstream.
  • Optional bindings via native Kotlin default parameters, plus @OptionalBinding for accessors.
  • Nullability is natively supported, String and String? are distinct.
  • @Binds as abstract extension properties, inlined at compile time.
  • Dynamic graphs: createDynamicGraph() for easy binding replacements in tests.

Injection

  • Top-level function injection, preserving @Composable, suspend, and context parameters.
  • Opt-in auto-generated @AssistedFactory types.
  • Assisted parameters match by name.
  • Can inject private members, functions, and constructors, and use private @Provides.
  • Default-value expressions copied into generated factories and work even if they reference private APIs.
  • Lazy is kotlin.Lazy for idiomatic Kotlin.
  • () -> T function providers for idiomatic Kotlin.

Multiplatform

  • Metro is multiplatform-first and targets most major Kotlin multiplatform targets in its runtime artifacts + supports them on in code gen.
  • Cross-module aggregation on every target (as of recent Kotlin versions).

Interop

  • One-liners for Dagger, Anvil, kotlin-inject, kotlin-inject-anvil, and Guice.
  • Runtime Provider / Lazy interop with Dagger, Javax, Jakarta, and Guice types.
  • Reuses existing Dagger-generated factories during migrations.
  • Metro graphs and Dagger/kotlin-inject components can depend on each other via @Includes.
  • Honors Anvil's boundType, rank, ignoreQualifier, and @ContributesMultibinding.

Diagnostics

  • Fully-qualified references in error output; "similar bindings" suggestions on misses.
  • Configurable severity for scoped-public providers, non-public contributions, unused graph inputs, @Inject placement, interop arg style.
  • reportsDestination + analyzeMetroGraph for JSON dumps and HTML visualizations.
  • debug prints pseudo-Kotlin for generated IR.
  • FIR diagnostics in the K2 IDE plugin (behind a registry flag).

Codegen optimizations

  • Unused-binding elimination.
  • Reference-count-based provider fields; DoubleCheck for scoped bindings.
  • Init statement chunking to dodge JVM method size limits.
  • Graph class sharding to dodge JVM class size limits.
  • Opt-in switching providers (deferred class loading).
  • Opt-in generateContributionProviders keeps impl classes internal for better encapsulation and IC granularity.

MetroX

  • metrox-android: constructor-injected Android components via AppComponentFactory.
  • metrox-viewmodel + metrox-viewmodel-compose: ViewModel basic infra.
  • Built-in Circuit codegen (opt-in).
  • Runtime interop artifacts for Dagger, Guice, Javax, and Jakarta.
By the numbers
  • 100k+ lines of Kotlin code
  • 5 different versions of the Kotlin compiler are supported
  • 7 different IDE versions are supported and tested (five Android Studio versions and two IntelliJ versions)
  • 50+ external contributors
  • 1000+ (non-automated) PRs, around ~30% from external contributors
  • 60+ releases
What does stability mean?

Stability, right now, is purely focused on the stable ABI of the runtime artifacts.

What does future compatibility look like?

The supported compiler/IDE versions will always be a moving window, but I'm pretty pleased with how consistently wide that window has been. Metro has an advanced compatibility layer that's scaled extremely well. It also runs a thorough test matrix of different compiler versions and IDE integration tests on every CI build.

Metro's internal metadata, in theory, could be safely versioned and better allow for mixing different versions of Metro-processed code. However, that's not currently a goal. It does not include support for mixing different versions of Metro-processed code at the moment.

What's Next?

The work continues! Maintaining a compiler plugin is an evergreen project (consider sponsoring Metro!), and there are new features I want to continue building. Metro's runtime has been pretty stable the past few months, so now felt like a good time to formalize that.

Thank you to everyone that's contributed and helped this project get to where it's at. Metro wouldn't be where it is without you.

🚇 Metro is Stable
69d1de9502f228000199228f
Extensions
I have two passing tests

which isn't many, but it's two more than I had yesterday

Show full content

which isn't many, but it's two more than I had yesterday

69adcd02b16d7c000130b074
Extensions
Two Pillars of (Engineering) Management

A former colleague once described their view of management as a role measured by two* pillars. As an IC, I really like this framing of it and it's helped me a ton of times in knowing how to best work with different types of EMs over the years.

Show full content

A former colleague once described their view of management as a role measured by two* pillars. As an IC, I really like this framing of it and it's helped me a ton of times in knowing how to best work with different types of EMs over the years. You can kinda think of them on a Y-axis where 100 is great and 0 is awful/non-existent.

Pillar 1: Technical familiarity

This pillar is the manager's technical familiarity with the problem space. It's their ability to not only understand the underlying tech stack but also defend and drive the technical decisions being made by that team.

Pillar 2: Ability to grow people

This pillar is the manager's ability to grow people that report to them. Not just their individual technical ability, but their team's efficacy as a whole.


The left/red side is pillar 1 (technical familiarity), the right/blue side is pillar 2 (people growth.) Different managers are at different levels on these pillars, but the important part is that they need to add up to 100. Pillar 2 also has a minimum.

Now let's look at six different combinations of this.

  1. This is the standard EM. They are fine at both the technical and the human side. They will probably get to middle management and park there for the rest of their career and that's wholly ok. They're reliable and versatile.
  2. This is your infrastructure manager, or a manager that oversees something like an SDK. Teams like this demand a higher level of technical familiarity than a typical product team and are often working on the frontier of a specific stack or whose concerns are purely engineering (vs design/product/etc). They speak your language, but need your help with career planning and may not do well with junior-heavy teams.
    1. Not all infra managers are like this, but if you have a manager with this balance, this is a safer bet.
  3. This is your product team EM, or a manager that oversees a vertically stacked team. They have reports in multiple tech stacks they don't need to know the nitty gritty of, and they are more valuable making sure everyone's rowing the right direction anyway. They'll delegate often, good with junior ICs, but cannot offer as much direct mentorship and lean on their senior ICs more for that.
    1. Again, not all product team managers are like this, but if you have a manager with this balance this will get the best out of them.
  4. This is an EM that should probably be a tech lead. Or they're an IC that maybe felt forced into management, either out of necessity or because our industry is generally bad at promoting ICs past senior.
    1. They can grow into this, but if they're still at this ratio after a while that's bad for everyone.
  5. This is a bad manager. Recognize this early, because they can do tremendous damage to the team or wider org. They can fly under the radar for a while if they are carried by a productive wider org or a strong team, but it won't hold forever. Every team-wide exodus I've ever been on or seen has been under a manager like this.
    1. If you're an IC under a manager like this, transfer ASAP. I have never seen a bad manager fired before their reports.
  6. This is a unicorn. They are usually recognized early and get fast-tracked to becoming a director. These are the managers people follow to other companies, and companies that don't recognize and support them are going to lose both them and the outsized impact they can have when enabled.

There are obviously many other types! Honorable mention in here is probably one with really high people skill and really low technical familiarity. I don't think this is common at the EM level, but I do think directors and up tend to naturally trend this way as they become more removed from IC work.

Here's the final, labeled chart. Hope this post is helpful!

*there are obviously other pillars, these being the primary

69a5ffe73bb13b0001cae4f9
Extensions
Metro 0.11.0 and MEEPs!

Metro 0.11.0 is out now and it's a fun milestone in Metro's development. Up to this point most of the work in Metro has been focused around the foundation, interop, and frankly feature parity. It's in a really good place on that

Show full content

Metro 0.11.0 is out now and it's a fun milestone in Metro's development. Up to this point most of the work in Metro has been focused around the foundation, interop, and frankly feature parity. It's in a really good place on that front now!

Now, Metro will try to push the envelope and explore more advanced and new features. It's done a little of this prior to now, like dynamic graphs.

To broaden input on these proposals, I've set up a dedicated section in the Metro repo's discussions for what I'm calling MEEPs. It's a tongue-and-cheek reference to Kotlin's KEEPs, but notably the P stands for proposal rather than process. There is no formal process here, just trying to create good avenues for soliciting community feedback and ideas 🫡. MEEPs can only be authored or promoted by myself or future maintainers, as they are planned features and not feature requests.

The first three (!!) MEEPs are implemented in 0.11.0 too!

There's also the whole host of the usual bugfixes/improvements, as well as some promoted features and behavior changes!

Release 0.11.0 · ZacSweers/metroNew Metro now has an informal proposal system inspired by Kotlin KEEPs called MEEPs! Importantly, the P in MEEP stands for proposal, not process. It’s an informal system for myself and future maint…GitHubZacSweers
699bed26fd09470001b32ebb
Extensions
Re: Dependency Injection vs. Service Locators
Dependency InjectionMetro
This is a port of a write-up I did in the Kotlin Lang slack here in response to the question "Dagger vs. Hilt vs. Koin vs. Metro vs. <what comes next>. Serious question: Are there any compelling reasons to switch from Koin to Metro in a Compose
Show full content
This is a port of a write-up I did in the Kotlin Lang slack here in response to the question "Dagger vs. Hilt vs. Koin vs. Metro vs. <what comes next>. Serious question: Are there any compelling reasons to switch from Koin to Metro in a Compose Kotlin multiplatform project?"

I think it's largely better to think of the frameworks [OP] listed as two categories that solve different problems in different ways.

Category 1: Dependency Injection
This includes Metro, Dagger, Hilt, Anvil, and kotlin-inject. They are all descendants of giants before them like Dagger 1, Guice (ish, API inspiration but guice was runtime), and the original JSR 330 spec. Anvil and Hilt are really just extensions to Dagger and kotlin-inject, like dagger-android before them. Manual DI (i.e. just constructors and whatnot) also falls in this category, the tools above are just generating all that wiring you'd otherwise handwrite.

These libraries are built with a laser focus on being true and pure dependency injectors. This means true inversion of control with a focus on ease of testing, simple constructor injection, and a generally low-touch runtime API that allows you to use the types they manage without the framework. This is what makes them great for testing and isolation.

Metro, Dagger, and kotlin-inject also do true compile-time dependency graph validation. If it compiles, it works and will never fail at runtime. That is a safety guarantee that is extremely valuable, especially in large teams and modularized codebases. If you add mobile to that consideration, it becomes even more important because mobile developers cannot immediately deploy fixes to production at the speed that backend or web developers can.

Lastly, the static validation these perform unlock two extra benefits

  1. They can generate extremely performant code because they know the exact shape of the graph at build-time. Metro and Dagger will generate significantly different code for the same binding depending on how the bindings in the graph are actually used in your consuming code.
  2. They are inherently analyzable because of this in-memory, compile time model. Dagger allows introspection of this via SPI, Metro does via reports and tracing.

They're not without their drawbacks though.

  • In exchange for this added safety and performance, they ask you to be more intentional and explicit with your code. Some people find this irritating or tedious or hard to understand.
    • Annotations are the easiest way for this in Kotlin and Java, though Metro as a compiler plugin is branching out of these since it can transform IR directly and already has some features that are not annotation based like dynamic graphs.
  • That build-time validation is also not free, though it's rarely the framework running slow and rather kapt/ksp's overhead that's incurring the real amortizing cost.

Importantly, the currency of these costs are measured in developer productivity, and those can always be improved. Build toolchains get faster, Metro's biggest appeal isn't that its API is so much better (it isn't) or that it supports KMP (so does kotlin-inject), it's that it's so damn fast as a compiler plugin. Customers/users/etc are never paying this cost, and if you're a company that's exceedingly important. It's why tools like Dagger, regardless of people's papercuts with it over the past decade+, it remains the industry standard that people trust and use.

Category one automates construction of the graph. It's an O(1) act at runtime because the code gen pushes the dependencies down and fails only at compile-time.

Category 2: Service Locators
This includes Koin, Kodein, Guice-ish, Spring DI, Compose composition locals, Application.getInstance(), etc. Some of these support JSR-330-esque behaviors for convenience. They are sometimes nameless conventions in a codebase or framework. IntelliJ platform has getInstance() all over the place, when I worked at Flipboard we had a magic FlipboardManager.instance, etc etc. They arise naturally if you're not using an intentional DI system because we as programmers generally try to organize our code 🙂.

Their focus and value prop is on ease of use to developers. Application.getInstance(), by inject(), etc will always be easier reaches. The fact that they are runtime-only by default means there is no build costs and your builds are always faster! Your code may fail at runtime if you're missing a dependency but at least it won't quietly do the wrong thing or fail with some obscure NPE.

You don't really have to think about how that dependency got here, there's an implicit trust system. That trust system can work really well in a tight code base with serious alignment across the contributors to it. That is alignment that naturally degrades with codebase and team size, no matter how well intentioned. Jake mentioned this in a panel we did with JB a few months back - those large team graph explosions creep up on you when you're no longer seeing every PR that comes through.

But, if you're a backend team that can very quickly deploy a fix to prod? That runtime cost risk significantly lower, your clients can see your errors and gracefully degrade. If you're running on a beefy AWS instance, you aren't nearly as concerned about runtime reflection performance as an obfuscated mobile app running on a battery. And hey, sometimes we just generally agree that the developer-cost to doing it the IoC way is just too high to write code the way we want, and using a little bit of de-risked service locating unlocks powerful declarative code patterns (i.e. Compose UI and composition locals).

There are other factors worth considering, but in my experience they're somewhat secondary to the above concerns.

  • Testing in isolation is often much harder and requires using the framework's test harness. Or, as is the case in frankly most SL shops I've heard of, you just write significantly less or no tests
  • Reflection allows you to do powerful, dynamic replacement strategies or framework integrations. At the same time, it is slower at runtime and nigh-impossible to use safely with a code optimizer or obfuscator.

Category two automates retrieval of the graph. It's a runtime O(n) lookup of dependencies, fails at runtime, and you have to request the dependencies.


Where the endless debates about DI vs SL break down is a mix of legit and contrived issues.

The Legit

  • Sometimes a SL framework's risks are totally acceptable for a given team. Sometimes it's not. Different teams estimate these risks differently too, based on personal experience/preference/bias/etc
  • The build costs of compile-time DI are real, but also changing rapidly
  • Multiplatform support is a real value prop
  • Migration costs are real
  • There is a difference between the underlying patterns
  • The developer productivity bill comes due in different ways. Whether it's test harness boilerplate, when/where you fix DI issues (build time or prod), runtime performance, etc.
    • IMO, the biggest bill in category 1 comes in the form of an extra few seconds of build time, and the biggest bill in category 2 might come in a 2am pagerduty alert.

The Contrived

  • ___ is better because ___ is worse. This comes up a lot with Koin vs Dagger tbh, and isn't really a high-signal signal for anyone evaluating. It's weirdly political/tribal in a space that should value measurable impact (build times, production hotfixes, etc) over vibes.
  • ____ is DI because it deals with dependencies! This comes up a lot in SL vs DI debates. I mean, a SL is strictly not DI. But this also isn't the hill to die on. If you're finding yourself trapped on the nomenclature taking issue with someone pointing out that SL is not DI, you're missing the point that the person pointing it out is trying to emphasize on why that distinction matters (i.e. everything I wrote above!)

There are other little things I haven't touched on because this is already long, like IDE experience/kotlin compatibility across versions, etc. But I think the above highlights are the most important ones.

A heavy nit about what "compile-time validation" means
One thing I do wanna nit about is the recurring, conflated usages of "compile-time validation". I mentioned this in my higher up message, but I think it's an important distinction. When someone says "Koin is switching from KSP to become a compiler plugin, like Metro, I see less reasons to switch than before", it's a fundamental misunderstanding of what is happening at compile time and implies that they're different tools accomplishing the same thing. Koin's KSP/compiler plugin do a few things, namely

  • Validation of certain APIs' correctness
  • Cross-module aggregation (similar to hilt/anvil/metro aggregation for large projects)
  • Optimize some IR expressions

That first one gets tossed around a lot but it's worth being specific that it's more like a linter. Metro, Dagger, Anvil, etc also do all this in the form of just usage checks, but they are fundamentally not the same thing as compile-time dependency injection. Koin's docs have explicitly said that it's planned in the future, but not what it currently does and it's important enough to be worth not conflating.

Community
Most of us in this space know each other and talk often. I've known the Anvil, Dagger, kotlin-inject, and Guice people in the industry for years. I've contributed to them and they've advised or contributed or both to Metro. I met Arnaud in person finally at Droidcon London last year and he's a nice guy, I think it's super cool that Koin is exploring the compiler plugin space because I think it's a powerful tool for developers to leverage. We've been on a couple calls and docs about ideas of how we (DI people in the ecosystem) can make it all work better for the community too.

These frameworks are also still regularly borrowing things that work well from others. Koin clearly felt the value of some degree of compile-time validation and added its KSP (and now compiler plugin) to do some of this. I haven't seen much angst from the Koin community about adding back a build system here, because it was obviously valuable. Similarly, Dagger/Anvil/kotlin-inject clearly saw value in better accommodating kotlin-first approaches, building infra on top of KSP and native support of kotlin language features like Koin and Kodein did. That's healthy.


My 2c for IoC

True DI is just an IoC pattern people can adopt. Like any other pattern, there's an initial learning curve and then it becomes automatic. Compose, coroutines, FP, FRP, Spring, etc are all no different. Some have friction points with scale or build systems or both, and we generally treat those as solvable, engineering problems. This is how software ecosystems go.

  • Guice wanted to be an implementation of JSR330
  • Dagger 1 wanted to be Guice but without the runtime risk or reflection performance cost
  • Dagger 2 wanted to be Dagger 1 but with zero reflection and tighter semantics around object graphs (i.e. components)
  • dagger-android was an extension to Dagger 2 to make it easier to use in Android framework types at the cost of being a little magic
  • dagger-hilt, motif, and anvil were parallel extensions to Dagger 2 to make it easier to work in large, multi-module codebases.
    • Anvil also was the first to really scratch the build time itch here with its factory-gen-only mode as an alternative to Dagger
  • kotlin-inject was a greenfield, multiplatform-friendly kapt/ksp implementation of DI that intentionally tried new kotlin-first APIs. kotlin-inject-anvil  ported anvil's aggregation features to it
  • Metro was a greenfield, multiplatform-friendly, compiler plugin implementation of DI that took heavy inspo from kotlin-inject's API and dagger's code gen, and opted to make Anvil's features a first party API

Metro is almost certainly not the last new DI framework for Kotlin 🙂

Every iteration here has moved the needle a bit in different ways, but arguably the underlying IoC pattern here is ~80% unchanged over nearly two decades. Anvil's aggregation, multiplatform, kotlin-inject's kotlin-first semantics, etc were all natural evolutions and we were more than overdue for something that brought them all under one roof. I think Metro's success has been less about any of its own technical value and more that the community that valued all these things were clearly itching for something like it to come into existence. If the values I described above are what's important to your team, then it's the best type of tool for your team. If the tradeoff is too high, or the risk cost of runtime validation not significant enough, then service locators are probably fine for you. The best DI framework is the one you have. You don't migrate because another one is better, you migrate because the one you're using is not satisfying your requirements.

When I write a new simple project, I almost never use a DI framework out the gate. But I do write manual DI still. Then after a certain level of complexity I find myself annoyed with the wiring and adopt a DI framework. I did this with my Field Spottr app last year. The underlying pattern is the same.

6997eb26fd09470001b32e97
Extensions
Where are all the staff+ Android product engineers?
AndroidDevMobileAndroid

Over the years I've heard (and experienced) both sides of a rather odd coin in the Android developer world.

  • Side 1: Companies, particularly startups and small teams, want staff+ Android product engineers and struggle to find them.
  • Side 2: Staff+ Android engineers struggle to find jobs if they
Show full content
Where are all the staff+ Android product engineers?

Over the years I've heard (and experienced) both sides of a rather odd coin in the Android developer world.

  • Side 1: Companies, particularly startups and small teams, want staff+ Android product engineers and struggle to find them.
  • Side 2: Staff+ Android engineers struggle to find jobs if they involve product work.

Companies/non-Android folks in general are surprised by the apparent lack of high level Android product engineers out there mostly because their perception is shaped by their experience with (and, let's be frank, primary focus on) iOS.

Two things are true here:

  1. Android developers often end up in more infrastructure work with experience. The most influential Android developers out there tend to be quite oriented toward infrastructure, you can probably count the influential Android product engineers on one (maybe two if you're well-connected) hands.
  2. iOS developers have a more even distribution of product and infrastructure work with experience. The influential iOS developers out there are often quite oriented toward product.

This isn't some random or bespoke asymmetry between the ecosystems though, I claim these actually happen for the same reason. There's multiple factors obviously, but at the end of the day it rolls up to one core principle:

Smart people want to solve interesting problems.

The reason this diverges is because iOS remains prioritized at most tech companies. iOS apps get design, product, and executive leadership attention. By extension, they get more headroom and resourcing to pursue interesting product problems.

Android users, even in 2026, are still second-class citizens in the average US tech company. It's not that surprising; it mirrors their design, product, executive, and even broader user base, so there is natural gravitation toward iOS first. I can count the number of mobile designers I've met in 15 years that use Android as their daily driver on one hand. US-based tech companies aren't usually pursuing interesting Android product problems.

The natural result of this is that Android developers often gravitate toward the spaces where they can work on interesting problems, which usually ends up being infrastructure. At the very least, if they want to level up beyond senior, that's often the only space to get that cross-functional visibility and "high impact" that management likes to see on a promo packet. It's not because they're bad at product, it's because no one wants to do a boring job.

Lastly, I claim that this cycle is perpetuated by side 1 of that coin. Companies, you want a smart engineer that wants to solve interesting problems. Hire an infrastructure engineer that wants to do product and you'll get a good engineer that can do both. Mobile developers are, by nature, full stack developers that can work on a broad technical space. Staff+ engineers get to that level because they care deeply about quality and like a challenge. Give them one and they won't let you down.

691f90a8e2a9000001c5e2be
Extensions
Sponsoring Metro
MetroKotlinOpen Source

Metro is the proudest work of my career. Ever since starting it on vacation in November 2024, building it has been an incredible journey and the community reception has been nothing short of wonderful. It sits at the intersection of several fields I've come to specialize in, ranging

Show full content
Sponsoring Metro

Metro is the proudest work of my career. Ever since starting it on vacation in November 2024, building it has been an incredible journey and the community reception has been nothing short of wonderful. It sits at the intersection of several fields I've come to specialize in, ranging from metaprogramming to compilers to static analysis to dependency injection itself. I've learned a lot along the way as it's allowed me to really dig into compilers and algorithms subjects I haven't touched since university.

As of version 0.10.0, Metro supports most of its initial goals. It's stable for use, production-ready, and delivering real-world results. Now, I’m opening up GitHub sponsors for the project.

Metro's Value Prop

Metro is ultimately a developer productivity multiplier. It's consistently delivering double-digit percentage build performance gains simply by swapping out your dependency injection framework. Entire developer experience (DevXP) teams are staffed to achieve goals below that.

Metro’s own public build benchmarks consistently show it outperforming source-generation tools (like Dagger) by ~50-80% while maintaining runtime performance parity.

On top of this, Metro has an extensive compatibility system built in. This allows it to support a much wider range of Kotlin and Kotlin IDE versions than a typical (third party) compiler plugin, and use of it doesn't require specific 1:1 version locking.

Real World Impact

With its extensive interop, large teams have been able to adopt it with minimal code changes and minimal work. Metro is already used in production by engineering teams at Block, OpenAI, Dropbox, and more.

Napkin Math

Let’s use the ROI using Cash App's real-world timestamps on a hypothetical mid-sized Android team. Even on a highly optimized code base, the savings add up fast.

  • Team: 15 Android Engineers
  • Volume: 40 builds per engineer/day
  • Savings per build: ~17 seconds (29s down to 12s)

Translates to...

  • 17s saved × 40 builds = ~11.3 mins saved per dev/day
  • 11.3 mins × 15 devs = ~3 engineering hours saved every single day
  • ~786 developer hours per year.

If we take the average cost of a mobile engineer in the US at ~$100/hr, that's ~$75,000 in recovered productivity per year.

Cash App's baseline (29s) is already fast for a large app. For large teams with typical 1-2 minute build times, these savings could be significantly higher.

Beyond the Spreadsheet

DevXP isn't purely measured in raw minutes saved.

  • Context Switching/Flow State: A 30-second pause is just enough time to check a notification and lose focus. A 12-second build keeps you in the editor and in the flow state.
  • CI Time: Faster builds mean faster feedback loops on PRs, allowing engineers to iterate and merge code more frequently as well as reduce CI compute costs.
  • IDE Integration: Metro integrates with the Kotlin compiler directly, meaning errors can be reported inline as you type in the IDE (just like standard Kotlin code) rather than waiting for a failed build task.
  • Better Error Messaging: Metro's compiler goes to great lengths to give meaningful error messages that not only explain what went wrong, but also:
    • Why it's an issue
    • What you can do about it
    • (If possible) What you probably intended instead
What does sponsorship mean?

Sponsorship doesn't mean it's becoming a paid product. Metro will always be free and open source. This new sponsorship setup is purely a support and gratitude avenue.

I am currently funemployed (left Slack in August 2025 after nearly 6 years) and enjoying the freedom to work on open source full-time. In the near term, I just want to just support my extended break and continue building out Metro toward a 1.0.0 release. Feature-complete does not mean maintenance-free, though. While many projects have the luxury of eventually reaching "done", Metro integrates deeply with the constantly-evolving Kotlin compiler and language. Supporting new language features and compiler versions requires ongoing, complex work. Metro has a built-in compatibility system specifically designed to shield you from this churn, but maintaining that is an evergreen project. It's work I'm committed to because I think it's valuable, and I hope you'll agree.

In the long term, eventually I will return to a full-time role. I'd love to reach a sponsorship level that allows me to potentially work part time and continue dedicating this much time to Metro and my other OSS projects.

Regardless of what the future holds, Metro and my attention to it isn't going anywhere. On top of my own maintenance, there's already a healthy and growing community behind it ranging from regular contributors to technical content about it like talks, blogs, etc.

Tiers
  • Individual ($5/10 mo): A personal gratitude sponsorship. You're an individual using this in your project and wanna sponsor a coffee or two/month as thanks.
  • Company ($250/500 mo): For medium to large size companies that are getting a lot of value out of Metro and want to support the project.
    • If you're that 15 person team saving nearly $100k/year, $250/month is just $3k/year going back to the OSS community.

There are also one-off donations if you just wanna buy me a coffee!

Expectation-setting

Sponsorship is to generally support continued development and maintenance of Metro and my other open source work. It does not come with private support or prioritization. I am casually open to consulting or contracting (feel free to contact me!), but that is separate from sponsorship.

Thank you to everyone who has used, tested, shared, given feedback (positive or negative!), and contributed to Metro so far.

👉 Sponsor Metro on GitHub
(I'm also set up on thanks.dev if that's easier!)

696d4a9001b18b0001f5e503
Extensions
Metro's Design Doc

Back in December 2024, I was around a month into working on Metro (at the time still called Lattice) and realized it crossed the threshold from "is this anything" to "oh shit this has wheels". I'd been talking informally with a few folks in

Show full content

Back in December 2024, I was around a month into working on Metro (at the time still called Lattice) and realized it crossed the threshold from "is this anything" to "oh shit this has wheels". I'd been talking informally with a few folks in the community at the time, but it was ready for a proper design review to get more feedback.

So, like anyone, I spent the holidays writing a 30-something page design doc. Shared it early January with a group of around 10 people, and more or less finished it up by end of January. Most of this doc is what eventually made it on to the Metro doc site.

After a few requests and clearing with all the original commenters, I'm happy to share the original design doc for anyone that's interested, including discussions. This was great and the open source community at its best.

The published google doc is below (which doesn't allow including read-only comments unfortunately).

Metro (née Lattice) Design Doc

For a version with comments, I've published an exported html dump to the metro doc site here: https://zacsweers.github.io/metro/latest/designdoc.html. Comments are annotated footnotes.

690d2c586d9d5a0001c01a7b
Extensions
Forklifts Require Training
TechAIMetaMentorship
Sleepwalking into labor collapse
Show full content

A lot gets covered in today's discourse about AI in software development. Most of it is noise, ranging from nihilism that we're all writing mediocre code anyway so why does it matter to endless wannabe AI influencers doing engagement bait on Twitter. Every new model release gets a bunch of threadicles 👇 amounting to the 2025 version of "Safari feels snappier". Some of it is useful, mostly crafty developers in the community sharing novel ways they're using it to solve hard problems or draw inspiration. A lot of it is slop, 80% of it is marketing.

There's something pretty important missing in this discourse though: the downward pressure it and out-of-touch executives at tech companies are having on junior developers in our industry. It's not a trend that started with AI, but it's certainly being accelerated by it.

What follows is my own perception of recent events. I am not an economist, I am not a CEO, my views are often shaped by what I see in the mobile engineering world and living in the US. I do feel confident in my read here though, even if some of finer details might be off.

COVID -> Inflation -> Layoffs

It's been going on ever since the red-hot labor market tech had during COVID. Nothing stirs large companies into action more than hot labor markets, the last thing they want to do is relinquish power to employees or have to meaningfully compete for talent. It's a recurring theme, the tech industry runs into labor suppression issues over and over and over and over. It's a core value that's always lurked behind the veneer of free lunches and ping pong tables. Tech employees, despite their progressive leanings, seem largely allergic to unionizing for reasons that mostly seem to boil down to "not enough people in SF/SEA/NYC are worried this will some day come for them."

Rising interest rates during inflation in the US saw companies tighten budgets and reign in spending. At the same time, this gave them an out to collectively reclaim the labor market power they'd been ceding, pointing vaguely at "macroeconomic conditions" in their all-hands to explain their layoffs and hiring freezes. This was also accelerated by Musk's mass layoff of thousands of employees at Twitter, creating a sort of model other companies eagerly followed. If you can do it while everyone else is doing it, your negative press might fly under the radar in the noise.

The market has changed and improved since then, inflation has cooled, but you can bet that leash has continued to tighten and will continue to for as long as companies can get away with it. Junior engineers are particularly affected by this: in a world where money's no longer free, companies start treating cultivation of junior talent as a risky investment rather than standard practice. The job market for junior engineers is bleak.

Shrinking Mentorship Vectors

Other factors came into play around the same time, particularly in the form of loss of mentorship avenues.

Twitter is Dead
Much of the tech community was active on Twitter prior to Musk's acquisition of it and much of the tech community rightfully departed it after. It never really landed anywhere though. Far from perfect, but Twitter was often a source of knowledge sharing, cultivation of ideas, and gave junior developers a general place to follow and be inspired by influential developers.

There are pockets on Bluesky and Mastodon, but a shell of what existed before. There are some Slacks and Discords that service well, but they are often private or overwhelming or both. There are a handful of good folks left on (now) X, remaining for different reasons that mostly boil down to not wanting to let go of the reach they had. What we had before isn't coming back though.

The "influential" developers left on X today are mostly a hot mess of weird right wingers/manosphere/clout chasers that contribute virtually nothing of value to open source, technical writing, or speaking. Because no team will hire them, they also work alone and offer little in terms of collaboration experience, instead spending their time spitting hot takes for attention. Unfortunately, this works. For the same reason it works in YouTube, TikTok and politics: humans are tribal to a fault, we want to believe confidence is competence.

In the absence of a true successor, these confidence men are now the most prominently visible people to junior developers entering the industry.

How do junior developers level-up to get into private Slack spaces? Where do they learn from if Stackoverflow is hostile, Reddit is hollowed out, and social squares are empty? They're eager and will reach for what they can see, but if the only thing they see are what's left on X, they'll rightfully feel like they don't belong here (or worse, be conditioned to think what happens there is normal.)

Remote Work
The other big change, and the one that's surely going to ruffle feathers to point out, is the seismic industry shift to embrace remote work.

Now, I can hear your keyboards clacking already so hear me out. I work remote*. I think remote work is great. I think everyone that wants to do it should be able to and I think that everyone should be able to get paid their worth regardless of where they choose to work remote from.

With that out of the way, I also think this has largely come to the detriment of junior developers. Not all, but a lot. In a healthy working environment, junior developers learn a million things by osmosis. Every "quick question" or "hey wanna see how this works?" or "can you help me with ___" or unplanned pairing or lunch or coffee break saturates those early years to the keen developer.

Then, the industry moved abruptly during COVID to a remote-first environment where all of these vectors were suddenly gone. I mean it when I say gone, because putting all those small moments behind the inertia of a zoom call means they never happen. Some teams and some companies have gone to great lengths to address this, but no one could look around at the industry today and reasonably conclude that we've figured that out as the standard.

There's no easy answer to this. Jesse Wilson had a line I liked that goes "junior developers should be in the office getting mentored by senior developers, and senior developers should be in the office mentoring junior developers". I don't go all the way to that, but some days in person/office getting mentored sounds good? Or at the very least highly encouraged? And to do that you need some seniors in the office to mentor them. Where it makes sense! Blanket RTO mandates certainly aren't the solution, nor are any of the companies imposing them genuinely doing so with this in mind.

I'm not perfect in this either. I went in to Slack/Salesforce's (*my previous employer) NYC office once a week despite Salesforce having no clue how to make a productive office environment, solely to be around for a new grad on our team. It's not enough but it's better than the current industry default of just pretending this isn't a thing.

I know there are millions of thoughts and feelings and exceptions and counterexamples to this. I look forward to your letters, they're also not the point of this post.
AI

Finally, we arrive in 2025. The age of AI, the age of agents, the age of ChatGPT and Cursor and Copilot and Junie and Claude Code and tech CEOs just making shit up when talking about how their companies are adopting it.

Companies are putting blanket, undirected pressure on all engineers to use AI tools in all their day to day without having any real idea of what that looks like. Whether it's a fear of missing the boat, looking good to investors, or just CEOs copying what other CEOs say they're doing, there is a rush to adopt AI-driven tooling. The cheese is absolutely moving.

This is hurting junior engineers, but not in the way I think is being talked about in tech or tech media circles. I don't think it's going to take their jobs, we've already established those have largely run dry. Rather, they are now pressed to use these tools they don’t understand, deploy generated code they don’t have the experience to safely evaluate/modify, and miss huge swaths of early career development experience that people did before AI.

Education doesn't stop after graduation, most companies have a whole career ladder from associate/L1 to senior/L5 that they expect junior developers to climb within a certain number of years. The rate of growth in this period is the highest it will ever be in their career. What happens to that in the age of "just ask AI to generate it"? Because now you're just managing an agent. How do we extract and measure competence from that?

Kevin Roose had a good line on the Hard Fork podcast that I liked about how to use AI tools pragmatically: it's like weight lifting vs forklifting. There is a time and place for both, and it is on you to recognize those moments. Sometimes you need to be lifting weights because it makes you stronger and healthier, and sometimes you use a forklift to quickly get through a bunch of stuff that isn't valuable for you to do yourself or at a scale that you can't do yourself.

There's no way we can expect new grads to know that nuance. Their incentives are increasingly to throw everything at the forklift and then we continue to measure them on how much they bench. What's worse is the forklift does stupid shit half the time. It wants to stack bricks on eggs, doesn't learn from its mistakes, and will confidently lie that bricks are supposed to stack on eggs. AI code gen does a lot but no amount of code gen will compensate for AI's utter lack of taste. That is something that is learned through experience.

All these kids that never learned how to find the cheese are now expected to just magically keep up, and I think the industry’s gonna churn a lot of them out. Many of them feel added pressure to just put up with it because the ones that did get jobs out of school suffer clinical survivor’s guilt in a horrendous junior labor market.

I again don't have good answers here. I have an answer, that will again surely get angry letters in my inbox, but I'll say it anyway:

🌶️I think junior developers shouldn't generate production code with AI

Other AI tools are fair game ("explain this code", "generate docs", "generate tests", etc.) Those inherently help them learn and understand what they're working with, and they have to ingest what it says back for it to have been worth asking at all. But generating code they can blindly commit does not benefit them and, in my opinion, hurts everyone in the long term.

  1. They lose the opportunity to learn, not just why that solution is good but why the other solutions they would've tried along the way were bad.
  2. They lose the opportunity to learn that in practice and in code review. Code reviewers either blindly trust it too (bigger problems, but not new with AI) or then have to try to ask a developer to defend something they didn't write.
  3. The organization is now shipping less-understood code, generated by a machine it knows hallucinates, reviewed by someone that by definition does not have much experience to know bad AI code gen when they see it.

If nothing else, we require training to operate forklifts and AI code gen is (almost always) a forklift. We already have that training pipeline too, it's called experience and it happens while leveling up from L1 to L5. Maybe it doesn't need to wait till L5, but no company would reasonably have someone operating a heavy machinery without prior training. Those early days are supposed to be about creating a space for them to learn and grow and safely make mistakes.

That's just forklifts, here's some other examples:

But Zac: people used paper maps before google maps.

Google maps replaced the lookup, not the actual driving or decision-making.

But Zac: people sewed by hand before machines

Machines only sped up the physical act. They didn't decide what you should wear, what colors to match, what patterns to cut.

But Zac: new grads are coming in just knowing how to use AI tools now!

Sure, and have you asked educators about how that's going for fundamentals?


AI tools can be a fantastic accelerant for ideation (even suggested some of the examples I just listed!), speedups of repetitive/tedious tasks, and lookups. I use them all the time! AI is not, however, a replacement for thinking.

A Call to Action

We know AI disrupts education because we already see it happening in education. It's no different in our field. But, unlike the teachers, tech companies have largely forgotten that part of their job is to teach. Pile that on top of all the other headwinds junior developers are facing in terms of employment and mentorship, I worry we are headed for an avoidable labor collapse in our industry.

Mentor your junior colleagues, give them the space to learn and make mistakes. Share your work/learnings in the community through open source, writing, and speaking. And lastly, be an advocate for them in the face of whatever "macroeconomic conditions" your workplace imposes on them.

If you're in a distributed team, try to get your company to invest in good remote pairing software. Sorry to say but general video conferencing tools like Zoom and Huddles are far inferior to dedicated ones like Tuple. Consider making explicit "office hours" where you're online and ringable if anyone wants to chat. Don't waste offsites/onsites on endless meetings that could be better served getting junior ICs direct mentorship time and team building.

If you're a junior developer reading this, remember that our field is first and foremost a craft. That doesn't mean you need side projects or overtime or open source (although side projects and open source, if you enjoy them, are great avenues for learning and mentorship). 9-5 is normal, what matters is that you take an intellectual curiosity in what you do from 9-5. Take every opportunity to learn something new, to sharpen yourself, or improve the things you build. You should take pride in your work.

If nothing else, try to be mindful of when it's time to weightlift vs forklift, because the first programming jobs AI actually automates will be the forklifters.

683f0813abefe7000152e30f
Extensions
Don't use Type-safe Project Accessors with Kotlin Gradle DSL
KotlinGradle
Another Gradle footgun
Show full content
Don't use Type-safe Project Accessors with Kotlin Gradle DSL

Gradle 7.0 introduced the type-safe project accessors feature for IDE support of referencing projects in dependencies. This allowed you to replace this:

dependencies {
  implementation(project(":lib1"))
}

with this

dependencies {
  implementation(projects.lib1)
}

This is great! Improved IDE support is always a good thing for developer experience and it's super convenient to have autocomplete like this.

The Problem

There's a hidden but serious cost here.

To make this work, Gradle generates these accessors in Java, and it's based on the included projects in your settings. i.e.

// settings.gradle
include(":lib1")

This generates a file like this

package org.gradle.accessors.dm;

// imports...

@NonNullApi
public class RootProjectAccessor extends TypeSafeProjectDependencyFactory {


    @Inject
    public RootProjectAccessor(DefaultProjectDependencyFactory factory, ProjectFinder finder) {
        super(factory, finder);
    }

    /**
     * Creates a project dependency on the project at path ":lib1"
     */
    public Lib1ProjectDependency getLib1() { return new Lib1ProjectDependency(getFactory(), create(":lib1")); }

}

When Kotlin incremental compilation kicks in, it looks at the ABI of its depedencies. In this case, the accessors dependencies produce a certain ABI of all its public getters, and this is an input to all of your project's build files. The API surface area of these getters is more or less equal to the set of included projects.

Now, what happens if you change the set of included projects? The ABI changes, and those kotlin DSL build files all have to recompile now. That sucks! Especially in a larger codebase.

What's worse is that it's quite easy for your project's buildscript classpath to become an input to your project's compilation tasks (sadly I've yet to track down how or why this happens, but I do observe it in practice), and as a result your compilation tasks will also then be invalidated.

So, just removing an unused project from settings can cause a cascade of cache failures: buildscript file recompilation -> configuration cache miss -> possible compilation task cache miss.


This becomes especially rough if you use tools like Spotlight (which you should!) or Focus, as they are designed to minimize the set of included projects for a given build.

I'd actually go as far as saying that type-safe project accessors shouldn't be compatible with Kotlin Gradle DSL until there's a better IC solution or alternative IDE support, and highly recommend disabling them if you use them today. I put together a little script to do this if you want to borrow it here, though note it assumes a Spotlight all-projects.txt file (you can modify this as needed to read from elsewhere, such as settings.gradle.kts).

Other notes
  • This also affects version catalogs, but these are less invasive with tools like the ones I mentioned above and most changes to them are value changes (non-ABI changing) rather than changed dependencies.
  • This doesn't seem to affect Groovy buildscripts, but I don't understand enough about how those are compiled/Groovy IC works to say why. That said, Groovy is very much on its way out and this post is in no way advice to use Groovy.
  • This behavior is the same in Gradle <9.0.0's custom IC implementation and kotlinc's native IC implementation.
686481bd6bcfb8000175ae45
Extensions
Introducing Metro
MetroKotlinDependency InjectionAnvilDagger
I'm excited to share something new I've been working on the past few months!
Show full content
Introducing Metro

Metro is a compile-time dependency injection framework that draws heavy inspiration from Dagger, Anvil, and Kotlin-Inject. It seeks to unify their best features in one, cohesive solution while adding a few new ones and implemented as a compiler plugin.

For some time, it's felt like the Kotlin community has wanted for a library at the intersection of these different tools and features. Different tools exist for parts of these, but there’s not yet been a unified solution that ties them all together, leaves behind some of their limitations, and embraces newer features that compiler plugins offer. Metro tries to be that answer. It doesn’t try to reinvent the wheel, it does try to make those wheels work better together. In short, Metro stands on the shoulders of giants.

Installation

Metro 0.1.1 is available today. Installation is simple!

plugins {
  id("dev.zacsweers.metro") version "0.1.1"
}

Apply the Gradle plugin

Doc site: https://zacsweers.github.io/metro/
API docs: https://zacsweers.github.io/metro/latest/api/
Repo: https://github.com/zacsweers/metro

Features

If you've ever worked with Dagger or kotlin-inject, you'll feel right at home with Metro.

@DependencyGraph
interface AppGraph {
  val httpClient: HttpClient
}

val graph = createGraph<AppGraph>()

Graphs are interfaces or abstract classes annotated with @DependencyGraph.

@DependencyGraph
interface AppGraph {
  val httpClient: HttpClient

  @Provides
  private fun provideFileSystem(): FileSystem = FileSystem.SYSTEM
}

@Inject
class HttpClient(private val fileSystem: FileSystem)

Provide dependencies with JSR-330-style constructor injection or providers directly in your graphs.

@DependencyGraph
interface AppGraph {
  val cacheFactory: Cache.Factory

  @Provides
  private fun provideFileSystem(): FileSystem = FileSystem.SYSTEM
}

@Inject
class Cache(@Assisted size: Long, fs: FileSystem) {
  @AssistedFactory
  interface Factory {
    fun create(size: Long): Cache
  }
}

Perform assisted injection with @Assisted and @AssistedFactory.

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

Contribute and aggregate bindings like Anvil.

@Inject
class Cache(fs: FileSystem = FileSystem.SYSTEM)

Optional dependencies. If the dependency doesn't exist on the injecting graph, the default parameter value is used.

@Inject
@Composable
fun App(circuit: Circuit) {
  ProvideCircuitCompositionLocals(circuit) {
    CircuitContent(HomeScreen)
  }
}

Top-level function injection.

Introducing Metro
Detailed-yet-readable error messages and diagnostics.

And much more!

Highlights

  • Compile-time dependency graph validation
  • Compile-time FIR/IR code gen
  • Dagger-esque code gen and runtime
  • Kotlin-Inject-esque API
  • Anvil-esque aggregation
  • Multiplatform
  • IDE Integration
  • Advanced interop
  • Private providers and private member injection
  • Optional dependencies
  • Top-level function injection
  • Detailed-yet-readable error messages and diagnostics

Head over to the Features section of the project site to get a full overview and the Usage section for full documentation of all the APIs available.

Build Performance

Being a compiler plugin, Metro runs significantly faster. When benchmarking my CatchUp app, build performance improved remarkably.

- Mutators are changing a low-level library.
- Project has ~35 modules, was previously using a combination of anvil-ksp and K2 kapt for dagger-compiler.
- Still uses some KSP for Circuit code gen in a couple modules (including the large monolithic app module at the top).

ABI: ABI breaking change.
No-ABI: Non-ABI breaking change, allowing compilation avoidance to kick in.
IC: Incremental compilation

Average improvements

  • ABI – 47% faster
  • ABI w/ no IC – 28% faster
  • No-ABI – 56% faster
  • No-ABI w/ no IC – 25.5% faster
Introducing MetroIntroducing MetroIntroducing MetroIntroducing Metro
Future Work

Metro's still in active development. This is just the first release, there will be bugs and there are a few major features I want to build out next. Nullable bindings, @ContributesGraphExtension, and reporting unused bindings are just a few of these. Check out the issue tracker and discussions on the repo for more details.

FAQ
Is your KSP fork of anvil still going to be maintained?

It's in maintenance mode and I'm happy to look at bug reports or cut new releases as needed to keep up with the ecosystem, but I'm unlikely to add new features to it.

Compiler plugins are an unstable API, is this safe to use?

I maintain a few compiler plugins already and have a good routine of this. The most likely scenario is that Metro follows a pattern of doing companion releases for each Kotlin compiler version (as needed), while separately developing new features on top of them. New features are not likely to be backported to older versions, but I'm happy to reconsider if there's a strong community need.

Is this affiliated with Slack?

Nope! Metro is solely my project.

Don't you think that "Circuit", a name about wiring, would've been a better name for this? And Circuit, ostensibly a navigation library, would've been better off named "Metro"?

Thank you for your question.

6750bfceefe795000160aecf
Extensions
One Last View of Mom

We said goodbye to mom in May after a five year battle with cancer. She left on her own terms, under the care of the wonderful University of Vermont hospice network and Act 39. The day mom passed, my dad and sister asked me to stay with her after she

Show full content
One Last View of Mom

We said goodbye to mom in May after a five year battle with cancer. She left on her own terms, under the care of the wonderful University of Vermont hospice network and Act 39. The day mom passed, my dad and sister asked me to stay with her after she went to sleep. Everyone has their own role to play in these moments. After all the work they’d done and endured living with her in the years leading up to that moment, they didn’t want have to watch her slip away. Someone had to walk her to the gate, and I stayed with her on the couch until her final breath an hour later. It’s seared into my memory today, something I talk about now and again with my therapist and friends, learning in time how to remember the moment without reliving it.

We spread her ashes near O'Rourke's Bench on Mt. Tam in California, a spot she, ever the planner, planned well in advance. It sits atop the Pacific-facing side of the mountain, a golden patch overlooking the ocean just north of the Golden Gate Bridge. Mom loved hiking in Marin. I stayed behind after the group departed the spot, wanting a few moments alone to feel that last moment of goodbye, perhaps once again feeling like I was with her in the end but this time with relief and closure. After I started walking back, at some point I turned around and this is what I saw. I think in that moment I felt her finally at peace. “One Last View of Mom”


Act 39 is Vermont's Medical Aid in Dying (MAID) law. Also known as death with dignity, PAD, etc. Vermont's implementation is small but mighty. After years of experiencing many of the lows of the American healthcare system, they were the opposite. Warm, respectful, and most importantly treated mom with a genuine tenderness and caring she deserved. It's just one man who prepares this medication for every patient that goes through this in Vermont, and he hand-delivers it to each of them. They also connected mom with a wonderful death doula that helped her navigate much of the final months. If this was in any way moving to you, consider donating to your local Wayfinder or MAID support organizations. They do amazing work.

677302c525eeca000118bc94
Extensions
Gradle Footguns: Don't add potentially-empty providers to collection properties
Gradle

After pairing with Tony Robalik on a recent weird behavior I was seeing in Gradle, we encountered a deliciously evil bug in Gradle. It's documented a little in Gradle 8.7's release notes (see Better API for updating collection properties), but the basic premise is

Show full content
Gradle Footguns: Don't add potentially-empty providers to collection properties

After pairing with Tony Robalik on a recent weird behavior I was seeing in Gradle, we encountered a deliciously evil bug in Gradle. It's documented a little in Gradle 8.7's release notes (see Better API for updating collection properties), but the basic premise is below.

abstract class MyTask : DefaultTask() {
  @get:Input
  abstract val listProperty: ListProperty<String>
}

listProperty.add("one")
listProperty.add(providers.gradleProperty("hi"))

listProperty will be "empty" if there is no "hi" Gradle property, clearing the previously added elements.Where this gets worse is that Gradle's docs kinda regularly conflate empty and absent when dealing with collections, so my understanding was that it just cleared the elements. Turns out, it actually clears the value entirely.

So - doing this...

listProperty
  .add("one")
  .add(providers.gradleProperty("hi"))
  .get()

... actually throws a MissingValueException!

Even better, it also completely destroys your conventions

listProperty
  .convention(emptyList())
  .add("one")
  .add(providers.gradleProperty("hi"))
  .get()

... also throws an exception!

Finally, if you hide it behind a non-managed-property type like the Kotlin Gradle plugin does in its legacy API, you get something like this later.

kotlinOptions.freeCompilerArgs += listOf("hi")

This quietly becomes an inert action later if the underlying provider was added to with an absent provider, and the property is rendered unusable unless you call set() on it again with a fresh collection.

TL;DR don't add providers to collections without also using orElse()

listProperty
  .convention(emptyList())
  .add("one")
  .add(
    providers.gradleProperty("hi")
      .map { listOf(it) }
      .orElse(emptyList())
  )
  .get()
66e1d17a2e89aa00018e71ae
Extensions
Writing a Kotlin Multiplatform App from Start to Store
Open SourceKotlinCircuitMultiplatformiOSAndroidMobile
Notes from writing a new toy Kotlin Multiplatform app this summer.
Show full content
Writing a Kotlin Multiplatform App from Start to Store

I recently wrote a little toy app for my pickup soccer group to check field permit statuses. In New York City most public parks' fields can be reserved or have recurring permits, so we have to check if fields are going to be in use before we try to organize games. The city parks website has this information, but it's a little awkward to use. They also have all permit information available in downloadable CSV files, which got me thinking I could just write my own little app. The rest of this post is notes about the process, things I used, things I learned, and hopefully some helpful references for myself and anyone else doing this in the future.

FieldSpottr

I wrote the app! It's called Field Spottr, and it's open source. I wrote it with Kotlin Multiplatform, Compose, and Circuit.

You can also download it on the App Store or Play Store. Note that this is really only useful for myself and the friends I play soccer with 😄.

0:00 /0:08 1×

Video walk through of the app

The app technically supports Desktop too, but I'm omitting those details for brevity in this post and focusing on iOS and Android. At a high level, it's pretty simple.

  • Fetches CSVs from the city parks site for the relevant areas we play in using Ktor.
  • Reads them with Okio into a local database using SqlDelight. This is refreshed either manually or after one week.
  • Presents a simple calendar-esque Compose UI with a date picker, "group" picker (one park can have multiple fields), and permit events for that date and group.

The UI is material3 on Android and mostly Cupertino on iOS. There are a few compose widgets that look out of place on iOS, but for a toy app that very few people will use this is a fine trade off.

Versioning
Android has some baked-in patterns for versioning with BuildConfig, but to make this multiplatform friendly I use a 3rd party gradle-build-config Gradle plugin. This supports generating in KMP projects, generating Kotlin, and other more advanced uses.

  • Version code — the app version number. Incremented for every release.
  • Version name — the semantic version name, i.e. 1.0.0. Honestly? More just here for show, version code is what really matters.
  • IS_RELEASE — a boolean indicating if this is a release build or not. Essentially just to gate crash reporting or debug tools.

One important note is that the plugin needs to be configured to generate public symbols, as its default of internal will prevent them from being visible from Swift. This is important later in the iOS section.

Platform-specific Components
There are a few platform-specific components in the app.

  • FSAppDirs — a simple abstraction API on top of Okio that exposes directories for common locations like user cache, user data, etc.
    • ContextFSAppDirs is an Android implementation that derives these locations from Context.
    • NSFileManagerFSAppDirs is an iOS implementation that derives these locations from NSFileManager.
  • SqlDriverFactory — A factory abstraction over creating SqlDelight SqlDriver instances.
    • AndroidSqlDriverFactory is an Android implementation that works on top of Context and returns AndroidSqliteDriver instances.
    • NativeSqlDriverFactory is a native implementation that works on top of SQLiter and returns NativeSqliteDriver instances.
  • Ktor — the networking layer. There's no expect/actual needed for this case however, as just adding the appropriate engine dependencies (i.e. OkHttp for Android and Darwin for iOS) is enough for it to automatically init.
  • FSTheme — the expect/actual theme entry point. This is only expect/actual'd because Android supports dynamic theming and needs the extra indirection.

All of these live in a hand-written FSComponent that acts as a dependency injection component. It's hand-written for now because it's simple. Platform-specific implementations live in an encapsulated SharedPlatformFSComponent that each supply.

Compose as an App
The primary entry point of the app itself is FieldSpottrApp.kt, which is a composable entry point. Unlike your typical Compose samples though, this isn't just UI! This is actually a Circuit app, which means the whole app (including presentation business logic) is also using the compose runtime. This allows for encapsulation of the entire app within a single composable entry point.

@Composable
fun FieldSpottrApp(component: FSComponent, onRootPop: () -> Unit) {
  FSTheme {
    Surface(color = MaterialTheme.colorScheme.background) {
      val backStack = rememberSaveableBackStack(HomeScreen)
      val navigator = rememberCircuitNavigator(backStack) { onRootPop() }
      CircuitCompositionLocals(component.circuit) {
        ContentWithOverlays {
          NavigableCircuitContent(navigator = navigator, backStack = backStack)
        }
      }
    }
  }
}

This in turn is called into at each platform's canonical entry-point. Each platform is responsible for creating the FSComponent before-hand.

// Android
class MainActivity : AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    // ...
    val component = (application as FieldSpottrApplication).fsComponent
    setContent { FieldSpottrApp(component, onRootPop = ::finish) }
  }
}
// Kotlin helper in src/iosMain/kotlin
// fun makeUiViewController(component: FSComponent): UIViewController = ComposeUIViewController {
//   FieldSpottrApp(component, onRootPop = {})
// }

struct ContentView: View {
    private let component: FSComponent

    init() {
        self.component = FSComponent(shared: IosSharedPlatformFSComponent())
    }

    var body: some View {
        ComposeView(component: self.component)
            .ignoresSafeArea(.all, edges: .all)
    }
}

struct ComposeView: UIViewControllerRepresentable {
    private let component: FSComponent

    init(component: FSComponent) {
        self.component = component
    }

    func makeUIViewController(context _: Context) -> UIViewController {
        return FSUiViewControllerKt.makeUiViewController(component: component)
    }
}

Crash Reporting
I've always used Bugsnag in side projects. Big fan, lots of drop-in SDKs. They have SDKs for Android and iOS too. You can create one "Other Mobile" type project and publish events from any platform to it, no need for separate projects unless you want to.

Privacy Policy
The Play Store requires this. I generated one using https://app-privacy-policy-generator.firebaseapp.com/ and modifying it as needed. IANAL.

Publishing

Now the actual hard part — publishing. Honestly, most of the reason this blog post exists is for my own reference in the future if I ever have to do this again.

Android

The developer tooling side of this process isn't too complicated.

Signing

  1. Create a signing key
  2. Encrypt it with gpg. You can borrow from the scripts under the release directory in the project, which in turn are based on Chris Banes' scripts in Tivi.
  3. Check in the encrypted signing key to the repo. Decrypt it as-necessary for releases.
  4. Wire this key into your signing and release configuration.
signingConfigs {
  if (rootProject.file("release/app-release.jks").exists()) {
    create("release") {
      storeFile = rootProject.file("release/app-release.jks")
      storePassword = providers.gradleProperty("fs_release_keystore_pwd").orNull
      keyAlias = ...
      keyPassword = providers.gradleProperty("fs_release_key_pwd").orNull
    }
  }
}

buildTypes {
  maybeCreate("release").apply {
    // ...
    signingConfig = signingConfigs.findByName("release") ?: signingConfigs["debug"]
  }
}

Packaging
Enable app bundles by adding bundle {} to your android configuration block. Surprisingly this isn't enabled by default.

Crash Reporting
The Bugsnag Android SDK only works in Android, so you have to configure it manually in androidMain code. In this case - in the app's Application class.

Their Gradle plugin (important for uploading R8 mapping files, etc) is easy enough to drop in, but I recommend setting it up to be disabled unless you're cutting a release build. It adds UUIDs to every build that invalidate certain packaging tasks, and the plugin itself appears to be in maintenance mode while they build a new plugin.

Play Store
This is the worst part of the process. The play store's publishing docs are all over the place. Some are several years old, some are buried, some are clearly written by Google APIs people, some are clearly written by Play Store product managers. The console page is overwhelming at best, littered with product up-sells. But, in short, the path looked like this.

  • Make a separate Google account for this. Don't use your personal account, or any other account you care about losing if Google strikes ya.
  • Set up your new app project and go through its preliminary onboarding flows + anything you need to do in the "Publishing overview" section.
  • Start a testing track, you can start publishing to this immediately. Under Testing > Internal Testing. There you can create a new release and manually upload .aab files to it.

Eventually, you probably want to automate this step with something like Fastlane or the play-publisher Gradle plugin. Here's a helpful link for setting up API access to do so (just skip the parts that involve connecting to PushPay).

iOS

iOS is a fairly new space to me. I've known basic swift and xcode use for awhile, but never gone seriously through things like KMP apps (not from a shared library like all the KMP docs focus around), crash reporting, publishing, signing, etc.

Swift Interop
I've found this area of KMP to be surprisingly limited. You can hit platform APIs from Kotlin sources and you can call Kotlin code from Swift, but anything that isn't covered by those two is essentially a dead end.

I'm hopeful that Circuit can, at some point, offer APIs that make it easy to use SwiftUI views with shared Circuit presenters. We have a basic sample that does this but it currently requires SwiftUI views to manually instantiate Circuit presenters, sort of breaking the convenience of Circuit's more automatic infra. The lack of bidirectional Swift interop support in KMP at the moment makes doing anything beyond this pretty challenging.

Star this: https://youtrack.jetbrains.com/issue/KT-49521

Crash Reporting
Once again, Bugsnag comes in here. However, there's an added spin for KMP.

Note: I was actually unable to get their iOS SDK working with SPM, so YMMV. The below is what I attempted to do.

The short answer is to use CrashKiOS from Touchlab, which nicely papers over all this with tools to help. Their docs are a good runbook for integration with Bugsnag. My configuration ended up like this:

// build.gradle.kts
plugins {
  // ...
  alias(libs.plugins.crashKiosBugsnag)
}

kotlin {
  listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
    it.binaries.framework {
      baseName = "FieldSpottrKt"
      // crashKios -> "co.touchlab.crashkios:bugsnag" dependency
      // Important for it to be visible in Swift
      export(libs.crashKios)
    }
  }
}
// in FieldSpottrApp.swift
import SwiftUI
import Bugsnag
import FieldSpottrKt

@main
struct FieldSpottrApp: App {
    init() {
        // Gate init on our build config
        if BuildConfig.shared.IS_RELEASE {
            if let key = BuildConfig.shared.BUGSNAG_NOTIFIER_KEY {
                // Create a bugsnag config from Bugsnag's framework
                let config = BugsnagConfiguration(_: key)

                // Plug it into CrashKiOS's Bugsnag wrapper. This
                // will start bugsnag under the hood too.
                BugsnagConfigKt.startBugsnag(config: config)
                // This is, surprisingly, also necessary and not 
                // implicitly done by the start call above.
                BugsnagKotlinKt.enableBugsnag()
            }
        }
    }
    // ...
}

Building
Building in regular development is usually done through Xcode. As long as you do the usual setup from the KMP docs, you should be set up. It is a fairly opaque system though, so debugging build issues can be tedious. Especially as Xcode seems fairly reluctant to make this button actually do anything.

Writing a Kotlin Multiplatform App from Start to Store

Compose Multiplatform UI
To make the iOS app look a little more native, I opted to use the compose-cupertino project to adaptively render UIs per-platform and Calf to bridge to native components like bottom sheets as needed. They work well enough for a simple app like this, though I'm not sure they're mature enough yet to recommend for a serious project as they has no tests. The calf maintainer is very responsive though, the compose-cupertino issue tracker sees acknowledgement though and multiple components are broken. My hope is that JetBrains tries to fill this space long term with first party APIs.

Writing a Kotlin Multiplatform App from Start to StoreWriting a Kotlin Multiplatform App from Start to StoreWriting a Kotlin Multiplatform App from Start to StoreWriting a Kotlin Multiplatform App from Start to Store

Native DatePicker and bottom sheet components on each platform (Android left, iOS right)

In some cases, Skiko components that came with Compose UI on iOS were just bad and unstable for use. Namely — modals like dialogs or bottom sheets were inconsistent at best and crashed at worst. For these cases, I found myself opting for just simple navigation instead (Circuit lends itself well to this!), but I'd love to see more attention in Compose UI to making these components' inner UIs more reusable without the cruft of the popup/window/dialog system.

Publishing
Just use Fastlane + match. An interesting pattern I noticed when talking to iOS friends is that they always mention adding things to Info.plist, a file that is no longer generated in newer Xcode projects and appears to act similarly to AndroidManifest.xml.

Set up match. This helps set up all your certificates and signing.

Note when using GitHub for storage, it appears to hardcode the branch to master and you should handle this.
How to setup Fastlane and Match to release iOS apps automatically on CI/CD serverIn this article I’ll be telling how this workflow should work both locally and on a CD server and what variables you should keep secureWriting a Kotlin Multiplatform App from Start to Storetech.reveloDouglas IacovelliWriting a Kotlin Multiplatform App from Start to Store
How to setup Fastlane and Match to release iOS apps automatically on CI/CD serverIn this article I’ll be telling how this workflow should work both locally and on a CD server and what variables you should keep secureWriting a Kotlin Multiplatform App from Start to Storetech.reveloDouglas IacovelliWriting a Kotlin Multiplatform App from Start to Store
match - fastlane docsWriting a Kotlin Multiplatform App from Start to Storefastlane docsfastlane teamWriting a Kotlin Multiplatform App from Start to Store

Big thank you to Ben Pious and Alan Zeino for humoring a million questions about Xcode. Big thanks also to Chris Banes for helping me with all the Fastlane/match/iOS publishing madness.

667f096e8206560001836d35
Extensions
Introducing: Anvil-KSP
Open SourceDaggerKSPAnvil
After more than a year of work and contributions from a number of developers in the community, I'm pleased to share a functionally-complete KSP implementation of Anvil code gen.
Show full content
GitHub - ZacSweers/anvil: A Kotlin compiler plugin to make dependency injection with Dagger 2 easier.A Kotlin compiler plugin to make dependency injection with Dagger 2 easier. - ZacSweers/anvilIntroducing: Anvil-KSPGitHubZacSweersIntroducing: Anvil-KSP
Introducing: Anvil-KSP

Usage is easy and you can find instructions here: https://github.com/ZacSweers/anvil/blob/main/FORK.md

Why a fork?

Firstly, it's important to acknowledge the elephant in the room: it's a fork!

Much of the phase 1 work for KSP was implemented in the upstream square/anvil, but contribution merging wasn't implemented yet. As the folks at Square are focused on the existing Anvil implementation for the time being and in the interest of finishing this work to make it available to folks that want it, I continued in this fork. This is not "the" Anvil KSP or Anvil K2 implementation, just "an" implementation.

Motivations

At the point of divergence (~2.0.0-beta09), square/anvil ("upstream") had some notable caveats:

  • It only supports Kotlin 1.x.
    • Its code gen is implemented in K1 compiler plugin APIs and its IR merging mechanism is no longer supported in K2.
    • This means that Anvil users today must force Kotlin 1.9.
    • Once Kotlin 2.1 is out, this will no longer be possible as Kotlin only supports n+1 forward compatibility.
    • Heavily mixing K1 and K2 compilers in the same build causes higher pressure on JVM code cache that results in both performing slower.
    • This is the primary motivation for making this fork's implementation available now, as 2.1 is only a few months away at the time of writing.
  • It doesn't support Dagger-KSP.
    • This means KAPT or Java annotation processing is always imposed somewhere in the build pipeline. Given the performance costs of KAPT, this isn't ideal. You can try to optimize this by extracting a separate Java-only project, but this is at the cost of yet another Gradle subproject.
    • Dagger-KSP performance isn't there yet either, but it's clear that this is a long term focus for the Dagger team.
  • It has a long history of issues with incremental compilation
    • This causes extensive build flakes and lost time due to needing to rebuild with --rerun-tasks.
    • Exacerbated with the introduction of compilation avoidance introduced in later versions of Kotlin 1.x
    • A lot of valuable work has gone in to attempting to patch this. But, even with the latest fixes in the latest betas, incremental compilation must still be disabled in the expensive KAPT stub generation task in order for IR merging to work correctly. And, to reiterate the above, these fixes are only useful for K1.
KSP Benefits

A KSP implementation functionally addresses all of the above.

  • KSP will natively support K2 via KSP2 (currently KSP2 is in beta).
  • This Anvil-KSP implementation, aside from obviously running in KSP, changes generated merged component code in a source-compatible way that supports Dagger-KSP.
    • If Dagger-KSP isn't performant enough yet, Anvil-KSP also allows for continuing to run dagger in KAPT/Java APT. This is what we are doing in Slack for now.
  • KSP has native incremental processing support and none of the incremental compilation issues.
    • I've tested this implementation with multiple different IC issue repro cases across my projects and the community and all of them work with KSP.
KSP Costs

As with any solution, KSP isn't perfect.

  • It is not and cannot be as fast as running as an embedded compiler plugin.
  • KSP2, as mentioned above, is in beta.
  • Dagger-KSP is in beta and has known performance issues with larger projects, more so when used in KSP2. This issue appears to be on the dagger-side rather than KSP itself though.
  • This does require (only a couple!) source changes to work.

I've tried to documented all the known rough edges here.

Long Term

I plan to maintain this for the foreseeable future, until Anvil either supports K2 or upstreams this implementation.

Stats
TL;DR At the time of writing, the optimal scenario is to use KSP contribution merging merging + KAPT for dagger-compiler in large project. dagger-ksp performance may be fine for your needs in a smaller project. You should measure!

I've tested with three primary modes

  1. KAPT merging - KSP contribution gen, KAPT for everything else (IR contribution merging, dagger-compiler)
  2. Hybrid - KSP contribution gen and merging, KAPT for dagger-compiler
  3. KSP only - KSP for contribution gen, merging, and dagger-compiler

In the Slack app on Kotlin 1.9.25 + Dagger 2.52, I measured around a ~12% improvement switching from mode 1 (our starting baseline) to mode 2. Mode 3 was a non-starter as it appears that dagger-ksp struggles in larger projects and runs significantly slower (possibly some pathological case with a large number of modules).

Introducing: Anvil-KSP

Slack Profiling Notes

  • Surprisingly, Anvil's IR merging appears to have a statistically significant impact and is actually the biggest mover going from KAPT -> KSP.
  • Our project's "app-di" subproject is extremely thin, only has components and one module. Four classes.
    • This means that stub generation doesn't really change much even when run non-incrementally, so it's not captured well in these benchmarks. In a larger module, I would expect this to be exacerbated.

Both configurations 2 and 3 enable component merging in their build like so.

anvil {
  useKsp(
    contributesAndFactoryGeneration = true,
    componentMerging = true,
  )
}

Configurations examples for the rest are below

Hybrid Mode

// Connect KSP outputs to KAPT inputs
afterEvaluate {
  // Example config for a "release" build variant in an android project
  val buildType = "Release
  val kspTaskName = "ksp${buildType}Kotlin"
  val useKSP2 = providers.gradleProperty("ksp.useKSP2").getOrElse("false").toBoolean()
  val generatedKspKotlinFiles =
    if (useKSP2) {
      val kspReleaseTask = tasks.named<KspAATask>(kspTaskName)
      kspReleaseTask.flatMap { it.kspConfig.kotlinOutputDir }
    } else {
      val kspReleaseTask = tasks.named<KspTaskJvm>(kspTaskName)
      kspReleaseTask.flatMap { it.destination }
    }
  tasks.named<KotlinCompile>("kaptGenerateStubs${buildType}Kotlin${target}").configure {
    source(generatedKspKotlinFiles)
  }
}

dependencies {
  kapt(libs.dagger.compiler)
}

KSP-only Mode

dependencies {
  ksp(libs.dagger.compiler)
}
66affc09e78c0d00017c08aa
Extensions