AOP Overview
Aspect-Oriented Programming (AOP) is a paradigm that separates cross-cutting concerns — code that spans multiple modules (logging, authentication, metrics) — from business logic.
The Problem: Cross-Cutting Concerns
Without AOP, repeating concerns appear everywhere:
fun processPayment(amount: Double, userId: String) {
logger.info("processPayment called") // logging
checkAuthenticated(userId) // auth
metrics.increment("payment.calls") // metrics
// --- actual business logic ---
chargeCard(amount, userId)
}
fun refundPayment(paymentId: String) {
logger.info("refundPayment called") // logging (again)
checkAuthenticated(userId) // auth (again)
metrics.increment("refund.calls") // metrics (again)
// --- actual business logic ---
reverseCharge(paymentId)
}
The AspectK Solution
With AspectK, cross-cutting concerns become centralized aspects:
// Business logic — clean
@Logged @Authenticated @Metered
fun processPayment(amount: Double, userId: String) {
chargeCard(amount, userId)
}
@Logged @Authenticated @Metered
fun refundPayment(paymentId: String) {
reverseCharge(paymentId)
}
// Cross-cutting concern — centralized
@Aspect
object LoggingAspect {
@Before(target = [Logged::class])
fun log(jp: JoinPoint) = logger.info("${jp.signature.methodName} called")
}
Design Philosophy
Why not AspectJ?
AspectJ is the most powerful AOP framework for JVM, but its pointcut expression language introduces significant complexity:
// AspectJ — string-based pointcut expression
@Aspect
public class LoggingAspect {
@Before("execution(* com.example.service.*.*(..)) && " +
"@annotation(com.example.Logged) && " +
"within(com.example..*)")
public void log(JoinPoint jp) { ... }
}
Problems with this approach:
- String-based, not type-safe: Pointcut expressions are plain strings — typos compile fine, errors only appear at runtime.
- IDE support is limited: Refactoring a class or method name won't update the pointcut string.
- Complex syntax: The AspectJ expression language (
execution(),within(),args(), etc.) has a steep learning curve. - JVM only: AspectJ is tied to JVM bytecode manipulation and cannot target Kotlin Multiplatform.
AspectK: Type-Safe by Design
AspectK uses KClass references instead of string literals, making pointcuts fully type-safe:
// AspectK — type-safe, no strings, no runtime container
@Aspect
object LoggingAspect {
@Before(target = [Logged::class]) // KClass reference, not a string
fun log(jp: JoinPoint) { ... }
}
| AspectJ | AspectK | |
|---|---|---|
| Pointcut definition | String expression | KClass reference |
| Type-safe? | ❌ No | ✅ Yes |
| Refactor-safe? | ❌ No | ✅ Yes |
| Runtime dependency | ❌ No (weave-time) | ❌ No |
| KMP support | ❌ JVM only | ✅ All targets |
With AspectK, the target parameter takes KClass<out Annotation> references. If you rename
or delete an annotation class, the compiler immediately flags every aspect that references it —
no runtime surprises.
How Weaving Works: Before vs After Compilation
AspectK uses Kotlin's K2 IR (Intermediate Representation) transformation API. After your source code is parsed and type-checked, the compiler plugin intercepts the IR tree and injects advice calls directly into the function body.
Before Compilation (your source code)
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.BINARY)
annotation class Logged
@Aspect
object LoggingAspect {
@Before(target = [Logged::class])
fun log(joinPoint: JoinPoint) {
println("→ ${joinPoint.signature.methodName}(${joinPoint.args.joinToString()})")
}
}
class OrderService {
@Logged
fun placeOrder(orderId: String, amount: Double) {
println("Order placed: $orderId, $amount")
}
}
After IR Transformation (equivalent generated code)
The compiler plugin rewrites placeOrder to inject the advice call at the very top of the
function body, before any original statement:
// This is NOT code you write — it shows what the compiler generates internally.
// Identifiers containing `$` are backtick-quoted in Kotlin; AspectK generates these names.
class OrderService {
// Generated by AspectK: synthetic inner object holding compile-time metadata,
// initialized once per class load (not per call).
private object `$MethodSignatures` {
val `ajc$tjp_0`: MethodSignature = MethodSignature(
methodName = "placeOrder",
annotations = listOf(
AnnotationInfo(Logged::class, "com.example.Logged", emptyList(), emptyList()),
),
parameter = listOf(
// The dispatch receiver (this) is always the first entry
MethodParameter("<this>", OrderService::class, "com.example.OrderService", emptyList(), false),
MethodParameter("orderId", String::class, "kotlin.String", emptyList(), false),
MethodParameter("amount", Double::class, "kotlin.Double", emptyList(), false),
),
returnType = Unit::class,
returnTypeName = "kotlin.Unit",
)
}
fun placeOrder(orderId: String, amount: Double) {
// ↓ Injected by AspectK at compile time
LoggingAspect.log(
DefaultJoinPoint(
target = this,
signature = `$MethodSignatures`.`ajc$tjp_0`,
// args[0] is always the dispatch receiver (this) for member functions,
// followed by the declared value parameters in order
args = listOf(this, orderId, amount),
)
)
// ↓ Original function body — untouched
println("Order placed: $orderId, $amount")
}
}
Key points
- The advice call is the very first statement in the transformed function body. No wrappers, no proxy objects — just a direct function call.
MethodSignatureis initialized once per class load, not once per invocation. AspectK generates a synthetic innerobject $MethodSignaturesinside the containing class, with one property per intercepted function (ajc$tjp_0,ajc$tjp_1, …).JoinPoint.argsalways starts with the dispatch receiver (this) for member functions, followed by the declared value parameters in declaration order. For top-level functions there is no dispatch receiver, soargscontains only the value parameters andtargetisnull.MethodSignature.parametermirrorsJoinPoint.args— the first entry is the<this>receiver parameter for member functions, followed by the declared value parameters.- The original function body is preserved exactly. AspectK only prepends advice calls; it does not rewrite or wrap the original code.
- No runtime framework required. The generated IR is pure Kotlin bytecode — the same as if you had written the call manually.
AspectK vs AspectJ: Full Comparison
| Feature | AspectK | AspectJ |
|---|---|---|
| Language | Kotlin | Java / Kotlin |
| Weaving | Compile-time (K2 IR) | Compile-time (bytecode) / Load-time |
| Pointcut style | Annotation KClass reference |
String expression language |
| Type-safe pointcuts | ✅ Yes | ❌ No |
| Refactor-safe | ✅ Yes (compiler errors) | ❌ No (silent breakage) |
| KMP support | ✅ Full (JVM, JS, WASM, Native) | ❌ JVM only |
| Runtime overhead | ❌ None | ❌ None (compile-time weaving) |
| Reflection at runtime | ❌ No | ❌ No |
| Advice types | @Before |
@Before, @After, @Around, etc. |
| Learning curve | Low (plain Kotlin) | High (expression language) |
Key Terminology
| Term | Meaning in AspectK |
|---|---|
| Aspect | A class/object annotated with @Aspect |
| Advice | A method annotated with @Before inside an aspect |
| Join Point | A function call site where advice is injected |
| Pointcut | An annotation class referenced in @Before(target = [...]) |
| Weaving | The K2 IR transformation that injects advice calls at compile time |