Decorator is a way to enhance a behavior of an object by adding a wrapper around
its functionality. In the example below, class SimulatedApi provides a fake call
to an API that can take anywhere between one and five seconds.
class SimulatedApi {
makeRequest = async (): Promise =>
new Promise((resolve) =>
setTimeout(() => resolve(this.getData()), this.getTimeout() * 1000)
);
getData = () => [
{ name: "John Johnson", age: 32 },
{ name: "Jane Johnson", age: 32 },
{ name: "Jim Johnson", age: 10 },
];
getTimeout = () => {
const delay = Math.random() * (5 - 1) + 1;
//console.log("delay (ms): ", Math.round(delay * 1000));
return delay;
};
}
Let's imagine that
we want to enhance the method by tracking how long the calls take and also add
caching in case the data has been previously received. Something that may come
to mind first would be to extend a class and add desired behavior. But inheritance
may put us on wheels such that future updates and enhancements may be limited. Favoring
Composition over Inheritance, the solution is to create a new class and inject the
API service as a dependency. First, we'll extract the interface:
interface IMakingSimulatedApiCalls {
makeRequest(): Promise;
}
The next class implements this interface and calculates the total time it took for the
data to come back.
class SimulatedApiWithLogging implements IMakingSimulatedApiCalls {
simulatedApi: IMakingSimulatedApiCalls;
startDate: number;
endDate: number;
constructor(simulatedApi: IMakingSimulatedApiCalls) {
this.simulatedApi = simulatedApi;
}
async makeRequest(): Promise {
this.startDate = Date.now();
const responseData = await this.simulatedApi.makeRequest();
this.endDate = Date.now();
console.log("time taken (ms): ", this.endDate - this.startDate);
return new Promise((resolve) => resolve(responseData));
}
}
When the class is instantiated, it takes an instance of the SimulatedApi class in a
constructor. This is the main idea behind a Decorator pattern -- get something, wrap it
in another class and decorate the existing functionality with the extra functionality that's
needed further. Single Responsibility Principle in action, so to speak.
In order to cache data and keep the code clean, we need to create a SimpleCacheAccessor
class that implements an ICacheAccessor interface.
interface ICacheAccessor {
setCache(incomingData: Array<{}>): void;
getCache: () => Array<{}>;
hasData(): boolean;
showData(): any[];
}
class SimpleCacheAccessor implements ICacheAccessor {
setCache(incomingData: {}[]): void {
this.data = incomingData;
}
getCache = () => this.data;
data: any[] = [];
showData = () => this.data;
hasData(): boolean {
return this.data.length > 0;
}
}
Finally, the code for the SimulatedApiWithCaching class that can make calls, log time
taken, and use cache.
class SimulatedApiWithCaching implements IMakingSimulatedApiCalls {
api: IMakingSimulatedApiCalls;
cacheAccessor: ICacheAccessor;
constructor(api: IMakingSimulatedApiCalls, cacheAccessor: ICacheAccessor) {
this.api = api;
this.cacheAccessor = cacheAccessor;
}
async makeRequest(): Promise {
if (!this.cacheAccessor.hasData()) {
console.log("reaching out to API");
const result = await this.api.makeRequest();
this.cacheAccessor.setCache(result);
console.log("data:", this.cacheAccessor.showData());
} else {
console.log("data (from cache):", this.cacheAccessor.showData());
return this.cacheAccessor.showData();
}
}
}
Here's the code to call an instance of the SimulatedApiWithCaching class. Notice the
pattern like nesting dolls, this is also one of the peculiarities of the Decorator
pattern -- nested wrapping of the instantiation calls.
let simpleApiAccessor = new SimulatedApi(); //instantiate api accessor
let apiAccessorWithLogging = new SimulatedApiWithLogging(simpleApiAccessor); //use it for class with logging
let cacheAccessor = new SimpleCacheAccessor();
let apiAccessorWithLoggingWithCaching = new SimulatedApiWithCaching( //use that for class with caching
apiAccessorWithLogging,
cacheAccessor
);
console.log("--- request 1 ---");
apiAccessorWithLoggingWithCaching.makeRequest();
setTimeout(() => {
console.log("--- request 2 ---");
apiAccessorWithLoggingWithCaching.makeRequest();
}, 5000);
Here are the results:
--- request 1 ---
reaching out to API
time taken (ms): 1815
data: [
{ name: 'John Johnson', age: 32 },
{ name: 'Jane Johnson', age: 32 },
{ name: 'Jim Johnson', age: 10 }
]
--- request 2 ---
data (from cache): [
{ name: 'John Johnson', age: 32 },
{ name: 'Jane Johnson', age: 32 },
{ name: 'Jim Johnson', age: 10 }
]