skip to content

When designing an annotation API, how do the parameter-type restrictions shape your choices (e.g., referencing a strategy class, bounded class literals, avoiding non-constant config)? What patterns work around the limits?

level: principalimportance: nice to knowfreq 20%

answer

  1. Behavior -> bounded KClass<out Strategy>, framework instantiates
  2. Closed options -> enum, not String
  3. Structured config -> nested annotation
  4. Dynamic value -> key/class, resolve at runtime
  5. Defaults -> central const object; processors read statically

basics

~20 s

Because annotations only hold fixed compile-time values, you reference behavior by class (KClass) and let the framework create it, keep config in const values, and push anything dynamic out of the annotation into runtime code.

solid answer

~50 s

The constant-only rule forces deliberate design. To attach *behavior*, you reference a strategy/handler **class** via a bounded `KClass<out Strategy>` rather than trying to embed a lambda (impossible) — the framework instantiates it. To express **enumerated options** you use enums, not strings, for type-safety and IDE help. **Defaults** live in a central `const`/`object` so they inline consistently. For values that genuinely cannot be constants (runtime URLs, secrets), you keep them OUT of the annotation: the annotation carries a *key* or *class* and the real value resolves at runtime (property lookup, DI). Multi-value config uses arrays/`vararg`; nested structured config uses **nested annotation** parameters instead of a fake string DSL. You also weigh Java interop: `KClass` becomes `Class`, arrays become Java arrays, enums stay enums — so the contract reads cleanly from both languages and from annotation processors that evaluate everything statically.

code

kotlin · 14 lines
kotlin
import kotlin.reflect.KClass

interface RateLimiter
class TokenBucket : RateLimiter
enum class Scope { GLOBAL, PER_USER }

annotation class Throttle(
    val limiter: KClass<out RateLimiter> = TokenBucket::class,
    val scope: Scope = Scope.PER_USER,
    val permitsPerSecond: Int = 100
)

@Throttle(limiter = TokenBucket::class, scope = Scope.GLOBAL, permitsPerSecond = 50)
class ApiEndpoint

go deeper

for a junior

Recognizes that annotations hold only fixed values and that behavior is referenced by class.

for a middle

Chooses enums over strings and arrays/vararg for multiplicity, and knows dynamic values can't be annotation params.

for a senior

Applies bounded KClass strategies, nested annotations, and central const defaults; keeps runtime values out via keys/classes.

for a principal

Designs a coherent annotation contract across Kotlin/Java and processors (KAPT/KSP), trading off compile-time safety, interop, and how statically-evaluated values constrain dynamic resolution.

## The constraint that drives design Annotation parameters can only be **compile-time constants**: primitives, `String`, enums, `KClass` literals, nested annotations, and one-dimensional arrays of those. No objects, no collections, no lambdas, no runtime values. Good annotation APIs embrace this rather than fight it. ## Pattern 1 — reference behavior by class, not by lambda You cannot put a function in an annotation, so to make behavior pluggable you name a **class** and let the framework instantiate it: ```kotlin interface Serializer class JsonSerializer : Serializer annotation class Serialize(val using: KClass<out Serializer>) @Serialize(using = JsonSerializer::class) class Payload ``` The **bound** `KClass<out Serializer>` makes the compiler reject unrelated classes — a compile-time contract. The runtime (DI container, processor) reflectively constructs the instance. This is the idiomatic replacement for "pass a callback". ## Pattern 2 — enums for closed option sets For a fixed set of modes use an enum, not free-form strings: ```kotlin enum class Cache { NONE, READ_ONLY, READ_WRITE } annotation class Cached(val mode: Cache = Cache.READ_ONLY) ``` This yields type-safety, autocompletion, and exhaustiveness — strings give none of those. ## Pattern 3 — nested annotations for structured config Instead of encoding structure in a string, nest annotations: ```kotlin annotation class Retry(val maxAttempts: Int, val backoff: Backoff) annotation class Backoff(val initialMs: Long, val multiplier: Double) @Retry(maxAttempts = 3, backoff = Backoff(initialMs = 100, multiplier = 2.0)) class Task ``` ## Pattern 4 — keep non-constant values out of the annotation Runtime data (a URL from config, a secret, a per-environment timeout) **cannot** be a constant. Two clean approaches: - Carry a **key** in the annotation and resolve at runtime: `@Value("service.url")` where the value comes from a property source. - Carry a **class** and let runtime supply the value. Never try to smuggle dynamic data through annotations; it simply will not compile, and forcing it (e.g. stringly-typed expressions) is a smell. ## Pattern 5 — central const defaults Put defaults in one `object` of `const val`s so they inline consistently and are reusable: ```kotlin object Defaults { const val TIMEOUT_MS = 30_000L } annotation class Timeout(val ms: Long = Defaults.TIMEOUT_MS) ``` ## Cross-language and tooling considerations - `KClass` -> `java.lang.Class`, arrays -> Java arrays, enums stay enums: design so the contract reads cleanly from Java and from annotation processors (KAPT/KSP) that evaluate all arguments **statically**. - Because processors read constants at build time, anything you'd want dynamic must be resolved later by generated/runtime code, not by the annotation itself. ## Summary Let the type rules guide you: classes for behavior (bounded), enums for options, nested annotations for structure, arrays/vararg for multiplicity, const objects for defaults, and runtime keys/classes for anything dynamic.

  • You need a per-environment timeout in an annotation-driven config. How do you handle it?
    Don't put the value in the annotation; carry a key (or class) and resolve the real value at runtime from a property source or DI, since annotations only hold constants.
  • Why prefer KClass<out Strategy> over passing the strategy as a String name?
    The bounded KClass gives compile-time type-safety and IDE refactoring support; a string loses both and fails late.

An annotation is a stamped order slip: you can name catalog items (classes/enums) and quantities (arrays), but anything custom must be fulfilled later by the warehouse (runtime).

saying these in an interview costs you the question

  • Trying to embed lambdas/closures in annotations
  • Encoding structured config in strings instead of nested annotations or enums
  • Attempting to put runtime/dynamic values directly into annotation parameters
  • Using unbounded KClass<*> when a bound would enforce the contract
  • Scattering default values as literals instead of central const declarations

context