skip to content

How would you decide between a marker annotation, a single-value annotation, and a multi-element parameterized annotation when designing an API, and what tradeoffs matter for evolution?

level: principalimportance: nice to knowfreq 30%

answer

  1. marker / single-value / multi-element
  2. least-power: simplest shape that fits
  3. new element WITH default = safe; without = breaking
  4. sentinels for 'unset' (null illegal)
  5. Class element = pluggable strategy; @Repeatable for multiples

basics

~20 s

Use a marker (no elements) when presence alone is the signal. Use a single value element when there's one obvious attribute (enables @Anno("x")). Use multiple elements with defaults for richer config. Give every non-primary element a default so you can add more later without breaking callers.

solid answer

~50 s

Pick the shape by the information the annotation must carry. A marker annotation (zero elements) is right when mere presence is the whole message, like a capability flag a processor scans for. A single-element annotation named value lets callers write @Anno("x"); choose this when there's one obviously primary attribute. A multi-element annotation suits structured configuration, and the key evolution rule is: every element except the mandatory primary should have a default. Adding a new element with a default is source-compatible, existing call sites keep compiling, whereas adding a mandatory element breaks every caller. Because null isn't allowed, design sentinels (empty array/string, marker class like Void.class) for 'unset'. Consider @Repeatable when the same annotation may apply multiple times, nested annotation elements for sub-config, and Class elements for pluggable strategies. Also weigh retention: SOURCE for compile-time processors keeps it out of the artifact; RUNTIME couples consumers to reflection. Favor the least powerful shape that expresses intent.

go deeper

for a junior

Knows the three basic shapes and that a marker has no elements.

for a middle

Chooses a shape by the data carried and knows defaults make elements optional and enable the value shorthand.

for a senior

Applies the least-power principle, designs sentinels for 'unset', and uses Class/nested/@Repeatable shapes appropriately.

for a principal

Treats the annotation as evolvable public API: minimal mandatory set, defaulted additive elements, retention/target as deliberate coupling decisions, and reasons about compatibility across releases and module boundaries.

## The three shapes 1. **Marker annotation** — no elements: `@Beta {}`, `@Entity`-style flags, `@Transactional` defaults. The *presence* is the signal; processing code just checks `isAnnotationPresent`. 2. **Single-value annotation** — one element named `value`: `@Role("ADMIN")`. Best when there is exactly one obviously-primary attribute; the `value` name unlocks the bare-value shorthand for clean call sites. 3. **Multi-element parameterized annotation** — several elements, usually one mandatory plus defaults: `@Retry(attempts = 5, backoffMs = 200)`. For structured configuration. ## Choosing among them (least-power principle) Use the **simplest shape that carries the needed information**. Don't add elements 'just in case', every element is permanent surface area. If presence suffices, ship a marker. If one attribute dominates, name it `value`. Reach for multiple elements only when you genuinely have independent knobs. ## Evolution and compatibility — the crucial part Annotations are part of your **public API**; once published, callers depend on the element set. The compatibility rules: - **Adding an element WITH a default** is **source- and binary-compatible**: old call sites omit it and get the default. This is the safe way to grow an annotation. - **Adding an element WITHOUT a default** is a **breaking change**: every existing use site fails to compile (missing mandatory value). Never do this to a released annotation. - **Removing or renaming an element** breaks callers that set it and breaks reflective readers, also breaking. - **Changing an element's type** is breaking. - **Tightening `@Target`** can break existing placements; **broadening** it is safe. Practical rule: declare the **mandatory primary** element (often `value`) and give **every other element a `default`**, so future versions can add knobs freely. Keep the mandatory set minimal. ## The 'unset' / null problem Since `null` is never a legal value or default, model 'not configured' with **sentinels**: - empty array `{}` for list-like elements, - empty string `""`, - a marker `Class` such as `Void.class` (or a private `Default.class`) for 'no strategy chosen', - a dedicated enum constant `Strategy.DEFAULT`. Document the sentinel's meaning, consumers rely on it. ## Advanced shapes - **`Class` elements** for pluggable strategies: `Class<? extends Validator> validatedBy();` lets users plug in behavior; the framework instantiates it reflectively. - **Nested annotation elements** for grouped sub-config: `@Retry retry() default @Retry;`. - **`@Repeatable(Container.class)`** when the same annotation logically applies multiple times (e.g. several `@Role`s); requires an auto-generated container annotation and changes how reflective code reads them (`getAnnotationsByType`). ## Retention/target as design coupling - `RUNTIME` retention couples every consumer to reflection and ships the metadata in the artifact; appropriate for runtime frameworks. - `SOURCE` retention keeps the annotation out of the bytecode, ideal for compile-time processors and zero runtime cost. - `@Target` documents and enforces intent; pick the narrowest set that matches real use sites, but remember tightening it later is breaking. ## A worked design ```java @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Repeatable(Caches.class) public @interface Cache { String value(); // mandatory key -> @Cache("users") long ttlSeconds() default 60; // additive-safe default Class<? extends KeyResolver> keyBy() // pluggable strategy via sentinel default DefaultKeyResolver.class; } ``` This starts minimal (`@Cache("users")`), can grow new defaulted knobs without breaking anyone, uses a Class sentinel for 'default strategy', and is repeatable. ## Pitfalls - Shipping a mandatory non-`value` element you later regret, callers are stuck. - Adding a required element post-release, instant breakage. - Using `null`-thinking instead of sentinels. - Over-parameterizing a marker into a config object that should have been a real class/builder.

  • Why is adding a defaulted element backward-compatible but adding a mandatory one is not?
    Existing call sites omit the new element; with a default they keep compiling and get the default value. A mandatory element makes every existing use site fail to compile for a missing value.
  • How do you let users plug a custom strategy into an annotation?
    Use a Class element bounded to an interface, e.g. Class<? extends Validator> by() default Default.class; the framework reflectively instantiates the supplied class, with a sentinel class meaning 'use the default'.

saying these in an interview costs you the question

  • Adding a mandatory element to a released annotation
  • Using null to mean 'unset'
  • Over-parameterizing when a marker suffices
  • Assuming you can freely rename/remove elements without breaking callers
  • Thinking tightening @Target later is harmless

context