Mike Wacker
06/29/2025, 3:43 AMapi
vs. implementation
, if a Java annotation is applied to a public class or method, is that annotation part of the ABI (application binary interface)? And does the answer depend on the retention policy of the annotation (SOURCE
vs. CLASS
vs. RUNTIME
)? A practical example would be a public "value type" that's annotated with @Value.Immutable
, @JsonSerialize
, and @JsonDeserialize
. Are Immutables and Jackson api
or implementation
deps in that scenario?Thomas Broyer
06/29/2025, 9:33 AMSOURCE
-retention annotations aren't preserved at all in the bytecode so you can use compileOnly
.
For CLASS
or RUNTIME
, annotations should be considered part of the ABI. You should use either compileOnlyApi
or api
.
I would personally go with compileOnlyApi
unless you know they will be needed in all circumstances; otherwise the downstream project will likely bring the dependency at runtime (api
, implementation
or runtimeOnly
) to make the "thing" work. For RUNTIME
-retention annotations, I'd go on the safe side and use api
(unless you have a very good, and documented, reason to use compileOnlyApi
).
IIRC, Immutables' annotations are really a compilation-only thing to trigger the annotation processor, so they could have gone with a SOURCE
retention but are using the default CLASS
retention, so you should use compileOnlyApi
.Vampire
06/29/2025, 11:23 AMThomas Broyer
06/29/2025, 6:25 PMThomas Broyer
06/29/2025, 6:30 PMcompileOnlyApi
and free me from that burden. On the other hand, excluding a dependency that only provides annotations is micro-management and you'd better have real benefits or that's just time wasted.Mike Wacker
06/29/2025, 7:11 PMimplementation
dependencies breaks anything. I created a sample multi-module Gradle project to demonstrate this; this project will use `-Werror`:
The :lib
module has this type (with implementation("org.immutables:value-annotations:2.10.1")
in build.gradle.kts
):
package org.example.lib;
import org.immutables.value.Value;
@Value.Immutable
@Value.Style(visibility = Value.Style.ImplementationVisibility.PACKAGE, overshadowImplementation = true)
public interface MyValue {
static Builder builder() {
return new Builder();
}
String value();
class Builder extends ImmutableMyValue.Builder {
Builder() {}
}
}
The :app
module then consumes `:lib`:
package <http://org.example.app|org.example.app>;
import org.example.lib.MyValue;
public final class Main {
public static void main(String[] args) {
MyValue value = MyValue.builder().value("value").build();
System.out.println(value.value());
}
private Main() {} // static class
}
Now if I run this app with -Werror
, this is the warning I get (which seems to repro the Java bug @Vampire was referring to):
> Task :app:compileJava FAILED
warning: unknown enum constant ImplementationVisibility.PACKAGE
reason: class file for org.immutables.value.Value$Style$ImplementationVisibility not found
error: warnings found and -Werror specified
1 error
1 warning
But if I also set -Xlint:all,-processing,-serial
, I get additional warnings:
> Task :app:compileJava FAILED
/home/mike/IdeaProjects/abi/lib/build/classes/java/main/org/example/lib/MyValue.class: warning: Cannot find annotation method 'visibility()' in type 'Style': class file for org.immutables.value.Value not found
warning: unknown enum constant ImplementationVisibility.PACKAGE
reason: class file for org.immutables.value.Value$Style$ImplementationVisibility not found
/home/mike/IdeaProjects/abi/lib/build/classes/java/main/org/example/lib/MyValue.class: warning: Cannot find annotation method 'overshadowImplementation()' in type 'Style'
error: warnings found and -Werror specified
/home/mike/IdeaProjects/abi/lib/build/classes/java/main/org/example/lib/ImmutableMyValue.class: warning: Cannot find annotation method 'from()' in type 'Generated': class file for org.immutables.value.Generated not found
/home/mike/IdeaProjects/abi/lib/build/classes/java/main/org/example/lib/ImmutableMyValue.class: warning: Cannot find annotation method 'generator()' in type 'Generated'
/home/mike/IdeaProjects/abi/lib/build/classes/java/main/org/example/lib/ImmutableMyValue$Builder.class: warning: Cannot find annotation method 'from()' in type 'Generated'
/home/mike/IdeaProjects/abi/lib/build/classes/java/main/org/example/lib/ImmutableMyValue$Builder.class: warning: Cannot find annotation method 'generator()' in type 'Generated'
1 error
7 warnings
Note that warnings are only generated for annotations with arguments: @Generated
, @Value.Style
. No warnings are generated for @Value.Immutable
. (ImmutableValue.Builder
is the super-class of MyValue.Builder
, and ImmutableMyValue.Builder
is nested in ImmutableMyValue
. ImmutableMyValue
has package-private visibility, however.)Mike Wacker
06/29/2025, 7:28 PMimplementation
dependencies for annotations that are applied to public classes/methods.
2. Exception: If you have an annotation with an enum constant for an argument, make that an api
dependency. Hide the Java bug from your consumers.
3. If you use -Xlint:all
, add -classfile
to that arg.
(The alternative would be to add a lot of api
dependencies for annotations.)Thomas Broyer
06/29/2025, 10:43 PMcompileOnly
rather than implementation
, and compileOnlyApi
rather than api
. There's no need to put the annotations on the runtime classpath if they're not needed there (e.g. the fact that you used Immutables is an implementation detail)
compileOnlyApi
was specifically designed for cases like annotations, so please use it unless you have a very good (and documented) reason not to: https://github.com/gradle/gradle/issues/14299 / https://docs.gradle.org/6.7/release-notes.html#compile-only-apiVampire
06/30/2025, 1:47 AMWhat's wrong with having a (compile-time) dependency on annotations?The same why you have
api
and implementation
.
You should not pollute downstream compile classpaths with unnecessary things.
It makes compilation slower and reduces the chance for up-to-dateness and cache-hits.
What problem are you trying to solve by not having them in the (compile) classpath of downstream projects?Besides what I just described, Mike asked for what is "correct", and that is in my opinion (and most others I talked to about this topic in the past) what I just said. Annotations should always be
compileOnly
. (Unless of course the artifact contains other classes that are needed for implementation
or api
)
(and is it your role and responsibility?)Whom are you referring to wiht "you" here? If you talk about the library, then no, it is not the responsibility of the library to add the annotations to the downstream project as I described, but the responsibility of the consumer of the annotation.
And BTW "handle an annotation" can also be "look at anything named Nullable or Generated", which would mean that as a downstream user I need to know which dependency I need to add that provides the annotation applied by the dependency, and make sure I keep it up-to-date whenever I update that dependency.Well, such tactic is imho a pretty crude work-around for something. But well, yes, in that case there are mainly two possibilities. The consumer following such a tactic should not use the JVM to read the annotations, but some class file reader like
ASM
that does not depend on the class file being present.
If the consumer does not do it, well then it is like an optional or provided dependency that you have to track, yes.
Whereas it could just declare it asNot really. With that argument you would need to add it toand free me from that burden.compileOnlyApi
api
, because "a consumer" might want to do the same at runtime, not only compile time.
On the other hand, excluding a dependency that only provides annotations is micro-management and you'd better have real benefits or that's just time wasted.It is not, or also
api
vs. implementation
is micro-management.
It is just semantically more correct.
It makes downstream compilation faster.
It increases up-to-dateness.
It increases cacheability.
It prevents downstream projects from needing to then exclude the jars which might be necessary if for example some detection is based on those annotations for example to determine which DI framework is used and so on. (This is not made up, but happened in reality)
...
Now if I run this app withYes, exactly. It is this one: https://bugs.openjdk.org/browse/JDK-8305250, this is the warning I get (which seems to repro the Java bug @Vampire was referring to):-Werror
But if I also setThose are probably bugs just like the enum one. Annotations for absent classes should be ignored by the JVM. If they are not, that should be a bug. But if those parameters should indeed produce those warnings if the annotation classes are missing, well, then imho the consumer that sets these options is also responsible to add the missing dependencies. Because without setting those options (including just, I get additional warnings:-Xlint:all,-processing,-serial
-Werror
), the build is not failing.
So it is like an "optional" dependency, that you need to add to the consumer if you use the feature.
So if you want to use those parameters, then you need to add those dependencies.
Not really. As the links you provided describe, it was designed for a very specific subset of annotations. For annotations, that are read by annotation processors that inspect all classes on the classpath and are in use there, not for all annotations that exist. So please don't use it butwas specifically designed for cases like annotations, so please use it unless you have a very good (and documented) reason not tocompileOnlyApi
compileOnly
, unless you have a really good reason to use it.
I would report a bug to every project I discover that has a dependency only because annotations contained in it.
You should imho not try to work-around that JVM-bug for consumers that also only is a problem if the consumer uses specific parameters.
If he wants to use such parameters, he should make sure to either use a JVM that has this bug fixed, or work-around it himself.Mike Wacker
06/30/2025, 6:56 PMcompileOnly
and Annotations
The problem here is that there's no simple rule for annotations. E.g., your code will break at runtime if Dagger is a compileOnly
dependency; that has to be an implementation
dependency. And in general, you can't figure out whether implementation
is needed or not without manually inspecting the annotations or having domain knowledge of the framework you're using. That's why I stick with implementation
for annotations; it's a simple (but slightly inefficient) rule that just works.
JDK-8305250
I'd rather just add an api
dependency than make my downstream consumers (who probably have no knowledge of JDK-8305250) debug an issue that's caused by JDK-8305250. I assume a good portion of them will be using -Werror
. And in practice, third-party annotations often aren't heavyweight dependencies.
Though it's not clear whether the api
dependency I need to add is for the annotation and the enum. My earlier example isn't useful here; both the annotation and enum come from the same dep: Immutables.Vampire
06/30/2025, 10:26 PMThe audience I have in mind for consumers: busy developers who are experts on their app's tech stack, but may not be build experts. Thus, in terms of tradeoffs, I tend to lean towards simplicity and having things "just work" w.r.t. build logic.If they are no build experts, they tend to not use exotic compiler parameters like
-Werror
. Without it, it just works, it only breaks when promoting warnings to errors. If one is build expert enough to do that, he should be build expert enough to add a dependency.
The problem here is that there's no simple rule for annotations.Sure there is. The simple rule is: "use
compileOnly
if you only use it to annotate something".
An annotation for which the class file is not present is treated like it is not present by the jvm.
If someone (like some framework) wants to check whether a class has a specific annotation, they anyway need to add the annotation to the classpath, as they use the annotation in their implementation like getAnnotatiion(Foo.class)
, so there is no point in forcing downstream compile classpaths to contain the class files if it for example is only read at runtime anyway.
Except of course this is the mentioned annotation processor that in downstream compilation checks whole classpaths, then it might be compileOnlyApi
.
E.g., your code will break at runtime if Dagger is a compileOnly dependency; that has to be an implementation dependency.Yes, but this has nothing to do with the annotations. The annotations are irrelevant at runtime afair, they are even irrelevant on downstream project compilation. Dagger is a compile-time DI framework. If dagger would properly split its classes in the dagger jar into two artifacts, one for annotations and one for runtime, the annotations would be
compileOnly
, the runtime api
.
When you build your code with Dagger annotations, you configure an annotation processor that handles those annotations. This annotation processor generates new class files that use classes from the Dagger artifact. These classes are the reason you need to depend on Dagger, not the annotations. And as the Dagger classes are part of your public API (e.g. the generated public ...Factory
classes implement the Dagger Factory
interface) they should probably even be api
, not just implementation
.
So yes, in the Dagger case you should have the dependency, but because you use classes in "your" code (the code you generate by using the annotation processor). For such generated code the same rules apply like for manually written code. If it is part of your API, use api
, if only in implementation use implementation
.
And in general, you can't figure out whether implementation is needed or not without manually inspecting the annotations or having domain knowledge of the framework you're using.Sure, it is simple. Annotations are never needed at runtime unless you want to retrieve them, and the one trying to retrieve the annotation needs to use the class file in its implementation anyway so is responsible to bring the class file to the classpath. If you use an annotation processor to process the annotations, then just look at the generated code or the respective documentation which classes you need that are used in the generated code.
That's why I stick with implementation for annotations; it's a simple (but slightly inefficient) rule that just works.Of course it works, feel free to do so as long as I don't use any of your projects, if I do I will report bugs as appropriate. 😄 But honestly, you can also always just declare anything anywhere as
api
, then it is always available, at compilation, runtime, downstream compilation, downstream runtime, simply everywhere.
This is just a very bad idea. It is semantically wrong and it makes each and every consumer of your projects slower to compile and maybe also slower to run by leaking stuff into classpaths that should not be present in them.
It is not without reason, that Gradle provides you with the power to choose from compileOnly
, compileOnlyApi
, implementation
, api
, and runtimeOnly
.
I assume a good portion of them will be using -Werror.Why? I've actually seen almost no project in my life using
-Werror
in a Java project ever in the last 23 years since I use Java, and I have seen many projects. 🙂
As I said, I consider this a quite exotic and expert option for a Java project and whoever uses it should be savvy enough to deal with the consequences.
And in practice, third-party annotations often aren't heavyweight dependencies.Whether they are heavyweight or not is not the point. It is semantically wrong and we should all strive for a better and cleaner ecosystem. And each Jar slows down stuff, as it needs to be read from disk, uncompressed, searched for stuff, ...... Actually, the smaller the Jar the worse the overhead of just having it in the classpath.
Mike Wacker
06/30/2025, 11:32 PM-Werror
, and -Werror path:gradle
yields 21.8K hits on Github. (Though it's extremely rare for established codebases that didn't set it from the start, only because you have to fix existing warnings first.)
I would slightly amend my rationale: since a common use case for annotations is annotation processors, some deps only have annotations, and other deps also have concrete types that are used in generated code (e.g., Dagger). I'm not aware of a good way to tell which case it is, as you only need compileOnly
for the former but need implementation
for the latter.
It's about tradeoffs. The mental model is simple enough for api
vs. implementation
, and there are definitely strong benefits to that. The incremental benefits of adding compileOnly
, though, are harder to justify. If I have to know the deep internals of Dagger to know whether it's compileOnly
or implementation
, that adds a lot of complexity to the mental model.Vampire
07/01/2025, 8:37 AMandThat's meaningless though, as you don't know whether these are Java projects. There is the same switch for Kotlin compiler and there it is not as uncommon as on Java projects.yields 21.8K hits on Github-Werror path:gradle
I'm not aware of a good way to tell which case it is, as you only needOrfor the former but needcompileOnly
for the latter.implementation
api
.
But the documentation of the annotation processor is the source of information here or should be.
The documentation of the annotation processor should tell you which artifact you need at runtime as it is used in the generated code.
If the documentation of the annotation processor misses the information, that is imho a documentation bug.
If that bug is present, you can still just look at the generated code to see which types are used in the public API and which in the implementation. Where an artifact should go there follows the exact same rules as for your manually written code, so should leverage the same mental model.
Additionally, I can greatly recommend the https://github.com/autonomousapps/dependency-analysis-gradle-plugin.
It analyses your classes and tells you exactly which dependencies you are missing, which should be changed to api
, which should be changed to implementation
, which are unused, ....
Unfortunately, it sometimes gives bad advice regarding annotations currently.
But for non-annotations it is extremely useful, also to keep up with changes you do like starting to use a dependency in the public API which you previously only used in the implementation, ...
The incremental benefits of addingWhy? They are the exact same benefits. Keeping classpaths as small as possible to speed up compilation, to speed up runtime, to increase up-to-dateness, to increase cacheability, ..., though, are harder to justify.compileOnly