Backstory

Case #1

Meet John.

John works at a big, big company. This company’s products are used daily all over the world. Millions of lines of code have been written, and millions more are yet to come.

John dystopically writes React components. Lots of components. A whole lot of components, and then some. Just today alone, John shipped no fewer than fifty widgets.

Burned-out coder John. Drank 3 liters of coffeeBurned-out coder John. Drank 3 liters of coffee

And this is Tom.

Tom also works at the same big, big company. But unlike John, Tom works in the analytics department.

Tom loves collecting all sorts of metrics from apps and digging through them. Just this year alone, Tom has analyzed CTR on a hundred services and has no plans to stop.

Tom is also a very good friend of John’s. At least, was — until today’s extraordinary incident…

Creative analyst Tom. Wearing a tieCreative analyst Tom. Wearing a tie

One scorching summer day, Tom walked up to John’s desk and asked him to add a metric to his components — how much time a user spends on each one.

John looked at Tom despondently, then at his IDE.

«193 components» — John thought, and felt a deep sense of betrayal from Tom. Miserable and exhausted, he couldn’t take it anymore and wrote his resignation letter.

What happened next is lost to history, but rumor has it John quit programming, moved to the forest, and now lives happily in a cabin at the edge of the woods, living off chanterelles and wild potatoes roasted over a campfire.

Case #2

Let’s move over to the backend department. This is Oliver. Oliver is a good programmer. He writes the API for the frontend in TS.

Oliver is still a junior and doesn’t quite grasp which practices are good and which aren’t.

It’s hard to imagine code without logging. But not for Oliver — only after 7 sprints and 15,000 lines of code did he realize that debugging a project without logs is the future reason for his firing.

Backend dev Oliver. Recently googled pictures of grassBackend dev Oliver. Recently googled pictures of grass

And this is Thomas.

Thomas is Oliver’s team lead and mentor. He values clean code and the SOLID principles.

When Thomas saw Oliver’s code, he realized the young man needed a lesson. He made him add logging to every single component.

Team lead Thomas. Values clean codeTeam lead Thomas. Values clean code

So miserable Oliver started adding the new code. The modules bloated, and so did Oliver’s fingers. At some point he decided to completely rethink his life. And quit. Now he lives in the forest, together with John.

The moral

Sadly (or maybe fortunately), John and Oliver didn’t know that instead of modifying and wiring features straight into their components, they could have simply not cluttered them with extra code at all. For that, they’d have needed to remember aspect-oriented programming.

Introduction

So imagine John is you. You’re working on a large project where logging, authorization, and error handling are scattered across hundreds of methods and classes. The code becomes bulky, duplication keeps growing, and maintenance turns into an absolute nightmare. Sound familiar? That’s exactly where AOP comes to the rescue!

Aspect-oriented programming is a pattern often used to conveniently inject cross-cutting functionality into a project. It helps keep code cleaner and more modular.

In this talk we’ll take a closer look at what AOP actually is, its pros and cons, and then implement it in TypeScript.

The core concepts of AOP

Let’s start with the most basic terms. As mentioned earlier, AOP focuses on separating cross-cutting functionality from business logic. But what exactly is cross-cutting functionality?

Cross-cutting concerns are functionality whose implementation touches many different parts of a program and doesn’t fit neatly into a single class or module under ordinary programming paradigms. Instead, it’s «scattered» across the whole codebase, which makes it confusing and hard to maintain without dedicated tooling.

A cross-cutting concern, illustrated A cross-cutting concern, illustrated

There’s no shortage of examples: logging, caching, access control, tracing, transaction management, auditing, change history, exception handling — the list goes on. All of these tasks are handled fairly inefficiently, code-wise, in plain OOP.

Noticing this, a group of engineers at Xerox PARC led by Gregor Kiczales proposed the AOP pattern. The same team also built AspectJ, the first aspect-oriented extension for Java.

It’s important to understand that this paradigm isn’t used on its own. It integrates neatly with both functional and object-oriented programs.

Next, let’s go over the three core concepts of AOP.

Aspects

Aspects are the core unit of the AOP methodology. An aspect is some behavior a developer intends to inject into the target code. In TypeScript terms, we’re talking about functions that encapsulate additional behavior.

Aspects answer the question «what?»

Aspects, schematically Aspects, schematically

Besides the logic itself, an aspect also carries a description of when and where that functionality should run. Next, we’ll look at concrete implementations for John’s problem.

Pointcuts and join points

Pointcuts and join points both point to a place in the code where we want to inject an aspect.

A join point explicitly names the target method, while a pointcut uses a pattern/expression to capture a whole set of methods at once.

Pointcut vs. join point, illustrated Pointcut vs. join point, illustrated

And although at first glance the difference might seem minor, pointcuts and join points actually do quite different jobs. A pointcut selects methods — it’s where we declare which methods we’re watching. A join point is the specific method the event was actually fired from.

Pointcuts and join points answer the question «where?»

Advice

Advice is the code that runs the aspect under certain circumstances (an event firing in a given context). On the frontend, advice is very often wired into a component’s lifecycle methods.

The main kinds of advice:

  • Before — runs before the method call
  • After — runs after the method call
  • After returning — runs after the function returns a value
  • After throwing — runs after an exception inside the function
  • Around — wraps the method with both pre- and post-processing

Advice usually takes some context or join point as an argument (on the frontend, this can be implemented via higher-order components), plus auxiliary arguments, and in some cases (say, for after-advice) the method’s own return value.

Implementing aspects in Java

Since AOP was first applied in Java, it’s worth briefly looking at an example using AspectJ.

@Component
@Aspect
public class LoggingAspect {
  private Logger logger = Logger.getLogger(
    LoggingAspect.class.getName()
  );

  @Pointcut("@target(org.springframework.stereotype.Repository)")
  public void repositoryMethods() {}

  @Before("repositoryMethods()")
  public void logMethodCall(JoinPoint jp) {
    String methodName = jp.getSignature().getName();
    logger.info("Before " + methodName);
  }
}

So here’s our logging aspect class. On line 8, a decorator selects the pointcut that logging will be applied to (in this example, org.springframework.stereotype.Repository).

Then, on line 10, we use the Before advice and specify the method before which logMethodCall will run. Inside the method itself, we simply print the name of the method where we caught the event.

The difference between a pointcut and a join point should be clearer now: on line 7 we started watching method events via the pointcut, and on line 10 we caught events from those exact methods and worked with specific join points.

Note that Java uses decorators to work with aspects. TypeScript also supports decorators, but with one limitation — they only work with classes and class methods. And since best practice in React favors functional components with hooks, we won’t be using decorators there.

Let’s look at the implementation details next.

Solving John’s problem

Instead of John, the big, big company hired a bright, motivated junior named Robert, who happened to know exactly why his predecessor had quit.

Luckily, Robert had attended a talk titled «Aspect-Oriented Programming in TypeScript» and knew exactly how to solve the problem at hand.

And all because he’d joined the Clubs!

Motivated junior RobertMotivated junior Robert

Say we’re given a functional component in React and TS:

import React, { type FC } from "react";

const MyComponent: FC = () => {
  return <div>Hello from Aboba!</div>
}

Let’s implement functionality that tracks how much time a user spends on the MyComponent component. Assume some utility class AnnoyingMetrics already exists for this.

Then we could rewrite the component like this:

import React, { useEffect, type FC } from "react";
import AnnoyingMetrics from "./metrics"

const MyComponent: FC = () => {
  useEffect(() => {
    // Component mounted
    AnnoyingMetrics.start();

    return () => {
      // Component unmounted
      AnnoyingMetrics.logResultAndStop();
    }
  }, []);

  return <div>Hello from Aboba!</div>
}

Overall, that’s not much extra code. But a careful engineer always looks ahead: right now we’re dealing with one small component and a couple of function calls. Now remember John’s problem — 193 huge components with complex logic. In reality there could be even more, and the cross-cutting concerns could be more complex too, and more numerous.

So to implement AOP here, we’ll reach for the Higher-Order Component (HOC) pattern — we’ll build a so-called «higher-order component». Following this pattern, we describe a function that takes a component as input and wraps it with a new, intermediate component:

function MetricsAspect<C extends ComponentType<any>>(Component: C) {
  const ComponentWithAnnoyingMetrics: FC<any> = (props) => {
    useEffect(() => {
      // Component mounted
      AnnoyingMetrics.start();

      return () => {
        // Component unmounted
        AnnoyingMetrics.logResultAndStop();
      };
    }, []);

    return <Component {...props} />;
  };
}

And after that, we just use MetricsAspect(MyComponent) instead of MyComponent. John’s problem, solved a sprint later!

Solving Oliver’s problem

At the same company, in the backend department, instead of Oliver they hired an equally bright and motivated junior named Teo, who was well aware of why his predecessor had quit.

Luckily, Teo had listened to Robert’s talk titled «Aspect-Oriented Programming in TypeScript» and knew exactly how to solve the problem at hand.

And all because he’d joined the Clubs!

Motivated junior TeoMotivated junior Teo

Say we’re given some class-based module in TS:

class MyModule {
  doSomething() {
    // some implementation
  }
}

Let’s add logging to this module!

First, we’ll create a Logger class using the Singleton pattern [GOF]. It’ll hold the core logging methods:

class Logger {
  private static instance: Logger | null = null;

  public static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }
    return Logger.instance;
  }

  public log(message: string): void {
    // some implementation
  }
}

Nothing complicated here — just two methods: getInstance to get the current singleton instance, and log to write a message.

The interesting part is left — how do we actually wire the logging in? The most naive solution, something like:

class MyModule {
  doSomething() {
    Logger.getInstance().log("Started doing something")
    // some implementation
    Logger.getInstance().log("Ended doing something")
  }
}

literally forces us to rewrite every single method. And once again, AOP comes to the rescue. But first, let’s talk about an implementation detail — decorators.

Decorators are special wrapper functions that, in a given context, intercept a method’s execution and additionally run some code of their own. And I said «methods» deliberately — decorators in TS can’t exist outside of classes.

There are several kinds of decorators: class decorators, field decorators, getter and setter decorators. We’ll be looking at method decorators.

We also won’t go too deep into the technical details here, and will only touch on decorators lightly, through a practical case. For a deeper dive, you can check the list of sources.

How a decorator works. The original method doesn't necessarily have to be called inside it — it's really just method substitution! How a decorator works. The original method doesn't necessarily have to be called inside it — it's really just method substitution!

So, a method decorator is a function that returns a function (which will run instead of the method). But to preserve information about the original method — effectively proxying it — we need to handle the method’s current context:

function LogDeco(
  target: object,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: any[]) {
    Logger.getInstance().log("Started doing something")
    const result = originalMethod.apply(this, args);
    Logger.getInstance().log("Ended doing something")
    return result;
  };
  return descriptor;
}

Let’s go through this line by line.

We defined a decorator function, LogDeco. It takes three arguments:

  • target: the prototype of the class we’re working with (the one containing the decorated method);
  • propertyKey: the name of the method we’re decorating;
  • descriptor: the interface for interacting with the method. It’s a special-purpose object with various fields and methods, such as getters and setters, plus a number of properties describing the method.

In particular, on lines 6 and 7 we access the descriptor.value field, which holds the original decorated method. We save that field and overwrite it with our new function, which will call the original method as part of its execution. This principle is genuinely very similar to Proxy.

Most importantly: our new function needs to return the result of calling the original method (line 11), so we can keep using it further down the line without losing it.

At the end of the decorator function, we return the modified descriptor, which contains the new method. And that’s how we swap the methods out.

Using the decorator on a module:

class MyModule {
  @LogDeco
  doSomething() {
    // some implementation
  }
}

We can write as many decorators as we like, each implementing different behavior. And in doing so, we keep the methods themselves clean, without cluttering them.

Bonus: on decorator factories

Sometimes we need a conditional decorator. For example, an access decorator for different roles:

class MyModule {
  @RoleDeco("admin")
  onlyAdminMethod() {}

  @RoleDeco("special-user")
  onlySpecialUserMethod() {}
}

In situations like this, decorator factories come in handy. Instead of a decorator function, we implement a function that returns a decorator function:

function RoleDeco(role: string) {
  return function(
    target: object,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    // decorator implementation
    // it can use `role` as parameter
  }
}

Functions like this are called decorator factories.

Takeaways

AOP’s pros and cons

Of course, the AOP methodology isn’t perfect. Its main drawback is that the methodology itself isn’t fully standardized. And since AOP was originally applied in Java with its own particular quirks, implementations in other languages can vary quite a bit.

On the other hand, AOP solves the problem of code duplication (calls to cross-cutting functionality inside methods get moved out into the corresponding decorators), and it separates concerns very well, which does wonders for code cleanliness.

Epilogue

And so John and Oliver returned to the big, big company once more. And they started applying AOP, writing good, clean code. Bugs became more obvious, and the programs — more readable.