Skip to Content
Course content

199: Decorator Composition Order

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've always found that the most confusing part of decorators isn't how to write the factory function, but understanding exactly when the code inside those decorators actually runs. If you're coming from a language where middleware is explicit, this "magic" can feel a bit opaque. To get a handle on it, we need to think of decorators as layers of an onion.

Creating a simple audit trail

Let's start by building a small system to track when methods are called. I'll create a @Log decorator. My goal here is simple: I want to see a message when a method starts and another when it finishes. In a real app, this would be your audit trail.

function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
        console.log(`[Log] Entering ${propertyKey}...`);
        const result = originalMethod.apply(this, args);
        console.log(`[Log] Exiting ${propertyKey}.`);
        return result;
    };

    return descriptor;
}

class UserService {
    @Log
    saveUser(name: string) {
        console.log(`Saving user ${name} to the database...`);
    }
}

const service = new UserService();
service.saveUser("Alice");
// Output:
// [Log] Entering saveUser...
// Saving user Alice to the database...
// [Log] Exiting saveUser.

Adding a guard for input validation

Now, we rarely just log things. Usually, we need to make sure the data is actually valid before the method even touches it. I'll add a @Validate decorator that checks if the first argument is a non-empty string. If it's empty, I'll throw an error to stop the execution.

function Validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
        if (!args[0] || args[0].trim() === "") {
            throw new Error(`[Validate] Invalid argument provided to ${propertyKey}`);
        }
        return originalMethod.apply(this, args);
    };

    return descriptor;
}

Flipping the script to fix the execution flow

Here is where things get interesting. I'm going to apply both decorators to my saveUser method. I'll put @Log on top, and @Validate underneath it.

class UserService {
    @Log
    @Validate
    saveUser(name: string) {
        console.log(`Saving user ${name} to the database...`);
    }
}

const service = new UserService();
try {
    service.saveUser(""); // Testing with an empty string
} catch (e) {
    console.error(e.message);
}
// Output:
// [Log] Entering saveUser...
// [Validate] Invalid argument provided to saveUser

Wait, look at that output. I've made a mistake in my architectural logic. My logs are recording that we "Entered" the method, but then the validation failed. In a high-traffic production system, my logs would be flooded with "Entering saveUser..." entries for requests that were fundamentally invalid. I don't want to log the start of a process if the input is garbage.

The rule for decorator composition is that they are evaluated from top to bottom, but the wrappers are applied from bottom to top. Essentially, the decorator closest to the method is the innermost wrapper. In the example above, @Log wrapped @Validate, which wrapped the saveUser method. When I called the function, I hit the @Log logic first.

To fix this, I need to move @Validate to the top. This makes @Validate the outermost layer of the onion.

class UserService {
    @Validate // Outermost layer
    @Log      // Innermost layer
    saveUser(name: string) {
        console.log(`Saving user ${name} to the database...`);
    }
}

const service = new UserService();
try {
    service.saveUser(""); 
} catch (e) {
    console.error(e.message);
}
// Output:
// [Validate] Invalid argument provided to saveUser

Now it's correct. The @Validate decorator catches the error and throws before the @Log wrapper is ever even invoked. If the validation passes, it then passes control down to the @Log wrapper, and finally to the actual method. Always remember: the top-most decorator is the first one to execute its wrapping logic.




📋 Practical Task

Refactoring the Order of the UserPermissions Guard

You are working on a system where methods are decorated with @CheckPermissions (which verifies if a user is an admin) and @CacheResult (which saves the method output to memory to avoid expensive DB calls).

Currently, the code looks like this:

class DocumentService {
    @CheckPermissions
    @CacheResult
    async getSecureDocument(id: string) {
        // Expensive DB call here
    }
}

The team has noticed a security flaw: the @CacheResult decorator is returning cached data to users before the @CheckPermissions decorator even runs. This means a non-admin user can see a document if it was previously cached by an admin.

Your Task: Rewrite the DocumentService class to ensure that permissions are verified before the system attempts to retrieve a cached result. Explain why your change fixes the security leak based on the composition order.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.