For the purpose of deciding what's `api` vs. `impl...
# community-support
m
For the purpose of deciding what's
api
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?
t
SOURCE
-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
.
v
I disagree. Annotations are never part of the ABI. If the class file of an annotation is not present, the annotation is treated like it would not be present by the JVM and compiler. If a consumer wants to handle an annotation, it is their responsibility to provide the class file. A good example for example is, if you have some library classes that are compatible with several annotation driven dependency injection frameworks and also useable standalone. You don't want to pollut the downstream classpath with totally irrelevant and unneeded dependencies and forcing the consumer to use excludes is very bad. Another example are any of the nullable annotation frameworks. Why should you put those to the classpaths of consumers? They might even use a different nullable annotation set in their code and by your lib adding it's used one, makes their life harder, especially if the annotations are same- or similar-named. The design of how annotations are handled strongly suggests, that you should never propagate them to consumers automatically. The only exception I'm aware of is a bug in Java. If you use some enum constant in the arguments to an annotation, this is currently not ignored if absent. But even there no error happens, but only a warning. So even then I would never propagate that dependency, but let the customer work-around the Java bug by providing the dependency or configuring to ignore the warning if they are concerned.
t
What's wrong with having a (compile-time) dependency on annotations? What problem are you trying to solve by not having them in the (compile) classpath of downstream projects? (and is it your role and responsibility?)
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. Whereas it could just declare it as
compileOnlyApi
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.
m
There are weird nuances on whether specifying annotations as
implementation
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
):
Copy code
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`:
Copy code
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):
Copy code
> 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:
Copy code
> 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.)
This feels like a reasonable set of recommendations to make things "just work" with `-Werror`: 1. Default to
implementation
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.)
t
Unless the annotations are required at runtime (which is definitely not the case for Immutable), use
compileOnly
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-api
v
What'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 as
compileOnlyApi
and free me from that burden.
Not really. With that argument you would need to add it to
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 with
-Werror
, this is the warning I get (which seems to repro the Java bug @Vampire was referring to):
Yes, exactly. It is this one: https://bugs.openjdk.org/browse/JDK-8305250
But if I also set
-Xlint:all,-processing,-serial
, I get additional warnings:
Those 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
-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.
compileOnlyApi
was specifically designed for cases like annotations, so please use it unless you have a very good (and documented) reason not to
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 but
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.
m
The 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.
compileOnly
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.
v
The 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.
m
I've been on a team or two that uses
-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.
v
and
-Werror path:gradle
yields 21.8K hits on Github
That'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.
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.
Or
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 adding
compileOnly
, though, are harder to justify.
Why? 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, ...