Skip to main content

Decorator Pattern

Attach additional behavior to a function dynamically, without altering the original function.


The Problem​

You have a DNA sequence analysis function. Now you need to add quality filtering. Then caching for reference sequences. Then retry logic for long sequences. Then timing metrics.

The naive approach:

async function analyzeSequence(dna: string) {
console.log(`Analyzing ${dna.length} base pairs`); // logging mixed in
const start = Date.now(); // timing mixed in

if (getQualityScore(dna) < 30) { // filtering mixed in
throw new Error("Low quality sequence");
}

const cached = cache.get(dna); // caching mixed in
if (cached) return cached;

try {
const result = await runAnalysis(dna);
cache.set(dna, result);
console.log(`Took ${Date.now() - start}ms`);
return result;
} catch (e) {
// retry logic mixed in...
}
}

The core analysis is buried under cross-cutting concerns. Testing is painful.


The Solution​

Keep the core function pure. Stack decorators that each wrap the previous one β€” same signature in, same signature out:

import { decorate } from "@pithos/core/eidos/decorator/decorator";

// Pure core function β€” just the analysis
const analyzeSequence = async (dna: string) => runAnalysis(dna);

// Each decorator wraps the previous, preserving the signature
const enhanced = decorate(
analyzeSequence,
withQualityFilter(30), // reject low-quality sequences
withCache(new Map()), // cache results for known sequences
withRetry(3), // retry on timeout
withTiming("analysis"), // log execution time
);

const result = await enhanced("ATCGATCG...");

The consumer sees the same (dna: string) => Promise<Result> signature regardless of how many layers are stacked. That's the key insight: decorators are invisible to the caller.

For simpler cases, use before, after, and around helpers:

import { before, after, around } from "@pithos/core/eidos/decorator/decorator";

// before/after for side effects
const withLogging = before((dna) => console.log(`Analyzing ${dna.length}bp`));
const withMetrics = after((_, result) => metrics.record(result));

// around for full control (caching, retry, etc.)
const withCache = (cache: Map<string, Result>) => around((fn, dna) => {
const cached = cache.get(dna);
if (cached) return cached;
const result = fn(dna);
cache.set(dna, result);
return result;
});

Live Demo​

Code
/**
* DNA Analysis Pipeline using Pithos Decorator pattern.
*
* decorate(fn, ...decorators) stacks behaviors without modifying the core function.
* Each decorator wraps the previous one β€” same signature in, same signature out.
*/
import { decorate, around, type Decorator } from "@pithos/core/eidos/decorator/decorator";
import type { AnalysisResult, DecoratorOption, LogEntry } from "./types";

/** Shared execution log for visualization */
export const executionLog: LogEntry[] = [];

export function clearLog() { executionLog.length = 0; }

function log(decorator: string, action: string, duration?: number) {
executionLog.push({ decorator, action, timestamp: Date.now(), duration });
}

/** Core analysis function β€” pure, no side effects */
export async function analyzeSequence(dna: string): Promise<AnalysisResult> {
await new Promise((r) => setTimeout(r, 100 + Math.random() * 200));
const upper = dna.toUpperCase();
const gcCount = (upper.match(/[GC]/g) || []).length;
const gcContent = (gcCount / dna.length) * 100;
const quality = Math.min(100, 50 + gcContent / 2 + Math.random() * 20);
return { gcContent: Math.round(gcContent * 10) / 10, length: dna.length, quality: Math.round(quality), isValid: true };
}

/** Quality filter decorator */
function withQualityFilter(minQuality: number): Decorator<string, Promise<AnalysisResult>> {
return around(async (fn, dna) => {
log("Quality Filter", `Checking sequence (min: ${minQuality})`);
const start = Date.now();
const upper = dna.toUpperCase();
const estimatedQuality = ((upper.match(/[ATCG]/g) || []).length / dna.length) * 100;
if (estimatedQuality < minQuality) {
log("Quality Filter", `❌ Rejected (quality: ${estimatedQuality.toFixed(0)})`, Date.now() - start);
throw new Error(`Sequence quality ${estimatedQuality.toFixed(0)} below threshold ${minQuality}`);
}
log("Quality Filter", `βœ“ Passed (quality: ${estimatedQuality.toFixed(0)})`, Date.now() - start);
return fn(dna);
});
}

/** Cache decorator */
function withCache(cache: Map<string, AnalysisResult>): Decorator<string, Promise<AnalysisResult>> {
return around(async (fn, dna) => {
log("Cache", "Checking cache...");
const start = Date.now();
const cached = cache.get(dna);
if (cached) { log("Cache", "βœ“ Cache hit!", Date.now() - start); return cached; }
log("Cache", "β—‹ Cache miss, analyzing...", Date.now() - start);
const result = await fn(dna);
cache.set(dna, result);
return result;
});
}

/** Retry decorator */
function withRetry(maxAttempts: number): Decorator<string, Promise<AnalysisResult>> {
return around(async (fn, dna) => {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
log("Retry", `Attempt ${attempt}/${maxAttempts}`);
const start = Date.now();
try {
const result = await fn(dna);
log("Retry", `βœ“ Success on attempt ${attempt}`, Date.now() - start);
return result;
} catch (e) {
lastError = e instanceof Error ? e : new Error(String(e));
log("Retry", `βœ— Failed: ${lastError.message}`, Date.now() - start);
if (attempt < maxAttempts) await new Promise((r) => setTimeout(r, 100 * attempt));
}
}
throw lastError;
});
}

/** Timing decorator */
function withTiming(label: string): Decorator<string, Promise<AnalysisResult>> {
return around(async (fn, dna) => {
log("Timing", `Starting ${label}...`);
const start = Date.now();
const result = await fn(dna);
log("Timing", `βœ“ ${label} completed in ${Date.now() - start}ms`, Date.now() - start);
return result;
});
}

/** Shared cache instance */
const analysisCache = new Map<string, AnalysisResult>();
export function clearCache() { analysisCache.clear(); }

const DECORATOR_FACTORIES: Record<DecoratorOption, () => Decorator<string, Promise<AnalysisResult>>> = {
qualityFilter: () => withQualityFilter(80),
cache: () => withCache(analysisCache),
retry: () => withRetry(3),
timing: () => withTiming("analysis"),
};

/** Build the analysis pipeline based on selected decorators */
export function buildPipeline(options: DecoratorOption[]): (dna: string) => Promise<AnalysisResult> {
const decorators = options.map((opt) => DECORATOR_FACTORIES[opt]());
return decorators.length ? decorate(analyzeSequence, ...decorators) : analyzeSequence;
}
Result

Real-World Analogy​

A coffee order. Start with espresso (core). Add milk (decorator). Add sugar (decorator). Add whipped cream (decorator). Each addition wraps the previous result without changing how espresso is made.


When to Use It​

  • Add logging, caching, validation, retry, or timing to existing functions
  • You want to compose multiple independent behaviors
  • Cross-cutting concerns should be separate from business logic

When NOT to Use It​

If you just need one fixed enhancement, a simple wrapper function is enough. Decorator shines when you compose multiple independent behaviors.


API​

  • decorate β€” Apply multiple decorators to a function
  • before β€” Run code before the function executes
  • after β€” Run code after the function returns
  • around β€” Wrap the function with full control over execution