@h1y/promise-aop
v6.0.0
Published
**Latest version: v6.0.0**
Downloads
22
Readme
Promise-AOP
Latest version: v6.0.0
A TypeScript-first, zero-dependency AOP (Aspect-Oriented Programming) framework for robust and maintainable asynchronous code. It provides a structured way to manage cross-cutting concerns like logging, caching, and error handling with a strong emphasis on type safety and explicit context management.
🏗️ Architecture Overview
Promise-AOP follows a clear execution model where Aspects define cross-cutting concerns, Processes compile them into execution chains, and Context provides thread-safe data access.
flowchart TD
A["🎯 Target Function<br/>(Your Business Logic)"]
B["📦 Aspect<br/>(Cross-cutting Concern)"]
C["⚙️ Process<br/>(Compiled Execution Chain)"]
D["🔄 runProcess<br/>(Execute with Context)"]
E["📊 Context<br/>(Shared Data)"]
B --> C
A --> D
C --> D
E --> D
subgraph "Advice Types"
F["before"]
G["around"]
H["after"]
I["afterReturning"]
J["afterThrowing"]
end
B --> F
B --> G
B --> H
B --> I
B --> JExecution Flow
sequenceDiagram
participant Context
participant Before
participant Around
participant Target
participant AfterReturning
participant AfterThrowing
participant After
Context->>Before: Execute (parallel)
Before->>Around: Setup wrappers
Around->>Target: Execute wrapped
alt Success
Target->>AfterReturning: Result
AfterReturning->>After: Continue
else Error
Target->>AfterThrowing: Error
AfterThrowing->>After: Continue
end
After->>Context: Complete📦 Installation
# npm
npm install @h1y/promise-aop
# yarn
yarn add @h1y/promise-aop
# pnpm
pnpm add @h1y/promise-aopRequirements: Node.js 16+ (uses AsyncLocalStorage)
🚀 Quick Start
This example demonstrates a simple logging aspect applied to a business logic function.
import { createAspect, createProcess, runProcess } from "@h1y/promise-aop";
// Define shared context for your application
type AppContext = { logger: Console };
// 1. Create an Aspect for a cross-cutting concern (e.g., logging)
const LoggingAspect = createAspect<string, AppContext>((createAdvice) => ({
name: "logging",
before: createAdvice({
use: ["logger"], // Declare which parts of the context you need
advice: async ({ logger }) => logger.info("🚀 Starting operation..."),
}),
after: createAdvice({
use: ["logger"],
advice: async ({ logger }) => logger.info("✅ Operation completed!"),
}),
}));
// 2. Create a Process to compose your aspects
const process = createProcess<string, AppContext>({ aspects: [LoggingAspect] });
// 3. Run your target function with the process
const result = await runProcess({
process,
context: () => ({ logger: console }),
target: async () => {
// Your actual business logic
await new Promise((resolve) => setTimeout(resolve, 100));
return "Hello, AOP World!";
},
});
console.log(result); // "Hello, AOP World!"
// Console Output:
// 🚀 Starting operation...
// ✅ Operation completed!What happened here?
- Aspect Definition: Created a reusable logging concern with
beforeandafteradvice - Process Compilation: Combined aspects into an executable process
- Context Provision: Provided shared services (logger) to all aspects
- Automatic Weaving: Framework automatically executed advice around your target function
🧐 Understanding AOP Concepts
Before diving into Promise-AOP, let's understand the core concepts:
What is Aspect-Oriented Programming?
Aspect-Oriented Programming (AOP) is a programming paradigm that separates cross-cutting concerns (like logging, security, caching) from your main business logic.
graph LR
A["Business Logic<br/>(What your app does)"]
B["Cross-cutting Concerns<br/>(How it's monitored/secured/cached)"]
C["AOP Framework<br/>(Weaves them together)"]
D["Clean, Maintainable Code"]
A --> C
B --> C
C --> DKey Terminology
| Term | Definition | Example |
| ----------- | -------------------------------------------------------- | ----------------------------------- |
| Aspect | A modular unit that encapsulates a cross-cutting concern | LoggingAspect, CachingAspect |
| Advice | The actual code that gets executed (when/how/where) | before, after, around |
| Target | Your original business function | getUserById(), processPayment() |
| Context | Shared data/services available to all aspects | { logger, db, metrics } |
| Process | Compiled execution chain of aspects + target | Result of createProcess() |
Context & Section-Based Access
Promise-AOP uses a section-based context system for thread-safe access:
graph TD
A["Context: { logger, db, metrics, cache }"]
B["Aspect A<br/>use: ['logger']"]
C["Aspect B<br/>use: ['db', 'metrics']"]
D["Aspect C<br/>use: ['cache']"]
A --> B
A --> C
A --> D
E["✅ Parallel Execution<br/>(No overlapping sections)"]
B --> E
C --> E
D --> E✨ Why Promise-AOP?
Promise-AOP helps you decouple cross-cutting concerns from your core business logic, leading to cleaner, more maintainable, and testable code.
Before: Scattered Concerns
// Business logic is cluttered with logging, metrics, and error handling
async function getUser(id: string) {
logger.info(`Fetching user ${id}...`);
const startTime = Date.now();
try {
const user = await db.fetchUser(id);
metrics.record("user.fetch.success", Date.now() - startTime);
return user;
} catch (e) {
logger.error(`Failed to fetch user ${id}`, e);
metrics.record("user.fetch.failure", Date.now() - startTime);
throw e;
}
}After: Clean Separation
// Pure business logic, easy to read and test
const fetchUser = async (id: string) => db.fetchUser(id);
// Apply concerns declaratively
const processedGetUser = (id: string) =>
runProcess({
process: createProcess({ aspects: [LoggingAspect, MetricsAspect] }),
context: () => ({ logger, metrics, db }),
target: async () => fetchUser(id),
});Key Benefits:
- Separation of Concerns: Isolate business logic from infrastructure code.
- Type Safety: Full TypeScript support with intelligent context inference.
- Section-Based Locking: Safe concurrent access to shared context.
- Reduced Boilerplate: Define concerns once, apply everywhere.
- Centralized Control: Manage application-wide policies in one place.
- Enhanced Testability: Test core logic without mocking unrelated services.
📚 API Reference
Core Functions
createAspect<Result, Context>(factory)
Creates an aspect, which is a modular unit for a cross-cutting concern.
Type Parameters:
Result: The expected return type of the target functionContext: A shared object available to all advice (dictionary-like type where keys are section names)
Parameters:
factory:(createAdvice: AdviceGeneratorHelper<Result, Context>) => Aspect<Result, Context>
Returns: Aspect<Result, Context>
type Aspect<Result, Context> = {
readonly name: string;
readonly before?: AdviceMetadata<Result, Context, "before">;
readonly around?: AdviceMetadata<Result, Context, "around">;
readonly afterReturning?: AdviceMetadata<Result, Context, "afterReturning">;
readonly afterThrowing?: AdviceMetadata<Result, Context, "afterThrowing">;
readonly after?: AdviceMetadata<Result, Context, "after">;
};createAdvice(metadata) Helper
The createAdvice helper function defines the behavior of a single piece of advice.
type AdviceMetadata<Result, Context, AdviceType, Sections> = {
readonly use?: Sections; // Array of context section names
readonly dependsOn?: readonly string[]; // Array of aspect names
readonly advice: AdviceFunctionWithContext<Result, Context, AdviceType>;
};Properties:
| Property | Type | Description |
| ------------ | ------------------- | ------------------------------------------------------------------------------------ |
| use? | (keyof Context)[] | Context sections this advice needs. Enables type checking and section-based locking. |
| dependsOn? | string[] | Aspect names this advice must run after (within same phase). |
| advice | AdviceFunction | The actual advice logic. Signature varies by advice type. |
Advice Function Signatures
| Advice Type | Signature | Purpose |
| ---------------- | ------------------------------------------------------------------------------- | --------------------------------- |
| before | (context: Restricted<Context, Sections>) => Promise<void> | Setup, validation, preparation |
| around | (context: Restricted<Context, Sections>, hooks: AroundHooks) => Promise<void> | Wrapping, transformation, caching |
| afterReturning | (context: Restricted<Context, Sections>, result: Result) => Promise<void> | Success handling, cleanup |
| afterThrowing | (context: Restricted<Context, Sections>, error: unknown) => Promise<void> | Error handling, logging |
| after | (context: Restricted<Context, Sections>) => Promise<void> | Always runs, final cleanup |
Around Advice Hooks:
type AroundHooks<Result> = {
attachToTarget: (wrapper: TargetWrapper<Result>) => void;
attachToResult: (wrapper: TargetWrapper<Result>) => void;
};
type TargetWrapper<Result> = (target: Target<Result>) => Target<Result>;
type Target<Result> = () => Promise<Result>;createProcess<Result, Context>(config)
Compiles a set of aspects into an executable process.
Type Parameters:
Result: Expected return type of target functionsContext: Shared context type
Parameters:
type CreateProcessConfig<Result, Context> = {
readonly aspects: readonly Aspect<Result, Context>[];
readonly buildOptions?: BuildOptions;
readonly processOptions?: ProcessOptions<Result, Context>;
};Returns: Process<Result, Context>
type Process<Result, Context> = (
context: ContextAccessor<Context>,
exit: ExecutionOuterContext,
target: Target<Result>,
) => Promise<Result>;runProcess<Result, Context>(props)
Executes a process with a target function and context.
Parameters:
type RunProcessProps<Result, Context> = {
readonly process: Process<Result, Context>;
readonly target: Target<Result>; // () => Promise<Result>
readonly context: ContextGenerator<Context> | AsyncContext<Context>;
};
type ContextGenerator<Context> = () => Context;Returns: Promise<Result>
Context Types:
ContextGenerator<Context>: Simple function that returns contextAsyncContext<Context>: For automatic propagation across async boundaries
Example:
// Using context generator
const result = await runProcess({
process,
target: async () => fetchUserData(id),
context: () => ({ logger: console, db }),
});
// Using AsyncContext for propagation
const asyncCtx = AsyncContext.create(() => ({ logger, db }));
const result = await runProcess({
process,
target: async () => fetchUserData(id),
context: asyncCtx,
});runProcessWith<Result, Context>(props)
Executes a process with an existing AsyncContext instance and a separate context generator. This function provides more precise control over context management and enables AsyncContext reuse for better performance.
Parameters:
type RunProcessWithProps<Result, Context> = {
readonly process: Process<Result, Context>;
readonly target: Target<Result>; // () => Promise<Result>
readonly context: AsyncContext<Context>; // Pre-created AsyncContext instance
readonly contextGenerator: ContextGenerator<Context>; // () => Context
};Returns: Promise<Result>
Key Differences from runProcess:
- Separation of Concerns: Takes a pre-created
AsyncContextinstance and a separatecontextGenerator - Performance Optimization: Allows reuse of AsyncContext instances across multiple executions
- Advanced Control: Enables complex context management scenarios
Use Cases:
- Context Reuse: When you need to reuse the same AsyncContext across multiple operations
- Performance Critical: Reduce overhead by pre-creating and reusing AsyncContext instances
- Complex Scenarios: Advanced context propagation patterns
Example:
// Pre-create AsyncContext for reuse
const sharedAsyncContext = AsyncContext.create();
// Execute multiple operations with the same context instance
const results = await Promise.all([
runProcessWith({
process: userProcess,
target: async () => fetchUser("user1"),
context: sharedAsyncContext,
contextGenerator: () => ({ logger: console, db: userDb }),
}),
runProcessWith({
process: userProcess,
target: async () => fetchUser("user2"),
context: sharedAsyncContext, // Same context instance
contextGenerator: () => ({ logger: console, db: userDb }),
}),
]);
// Performance optimization pattern
class ServiceManager {
private sharedContext = AsyncContext.create<ServiceContext>();
async executeOperation<T>(
operation: () => Promise<T>,
process: Process<T, ServiceContext>,
): Promise<T> {
return runProcessWith({
process,
target: operation,
context: this.sharedContext, // Reused across all operations
contextGenerator: () => this.createServiceContext(),
});
}
private createServiceContext(): ServiceContext {
return { logger: this.logger, metrics: this.metrics, db: this.db };
}
}Configuration Options
BuildOptions
Controls execution strategy and error handling for each advice type.
type BuildOptions = {
readonly advice?: {
readonly [AdviceType in Advice]?: {
readonly execution?: ExecutionStrategy;
readonly error?: {
readonly aggregation?: AggregationUnit;
readonly runtime?: {
readonly afterThrow?: ErrorAfter;
};
};
};
};
};
type ExecutionStrategy = "parallel" | "sequential";
type AggregationUnit = "unit" | "all";
type ErrorAfter = "halt" | "continue";Default Configuration:
| Advice Type | Execution | Aggregation | After Throw | Notes |
| ---------------- | ------------ | ----------- | ----------- | ------------------------- |
| before | parallel | unit | halt | Fails fast on first error |
| around | sequential | unit | halt | Wraps in sequence |
| afterReturning | parallel | all | continue | Collects all errors |
| afterThrowing | parallel | all | continue | Collects all errors |
| after | parallel | all | continue | Always runs |
Error Configuration Options:
aggregation: "unit": Stop on first erroraggregation: "all": Collect all errorsafterThrow: "halt": Stop execution on errorafterThrow: "continue": Continue despite errors
Section Locking: If parallel advice use the same context sections, Promise-AOP throws a runtime error. Resolve by:
- Making execution
sequential - Setting
dependsOnrelationships - Using different context sections
ProcessOptions
Centralized error handling and recovery configuration.
type ProcessOptions<Result, Context> = {
readonly determineError?: (props: {
context: ContextAccessor<Context>;
exit: ExecutionOuterContext;
errors: unknown[];
info: ErrorInfo;
}) => Promise<unknown>;
readonly handleError?: (props: {
currentTarget: Target<Result>;
context: ContextAccessor<Context>;
exit: ExecutionOuterContext;
error: unknown;
}) => Promise<Result>;
readonly handleContinuedErrors?: (props: {
context: ContextAccessor<Context>;
exit: ExecutionOuterContext;
errors: readonly (readonly [unknown[], ErrorInfo])[];
}) => Promise<void>;
};Error Handling Flow:
flowchart TD
A["Multiple Errors Occur"] --> B["determineError"]
B --> C["Primary Error Selected"]
C --> D["handleError"]
D --> E{"Recovery Decision"}
E -->|"Return Value"| F["Successful Result"]
E -->|"Throw Error"| G["Failed Result"]
A --> H["handleContinuedErrors"]
H --> I["Log/Monitor Secondary Errors"]Handler Descriptions:
| Handler | Purpose | Return Behavior |
| ----------------------- | -------------------------------------------- | ----------------------------------------- |
| determineError | Select primary error from multiple failures | Returns the most important error |
| handleError | Core recovery logic with current target info | Return Result to recover, throw to fail |
| handleContinuedErrors | Handle secondary errors | For logging/monitoring only |
Default Behavior:
determineError: Returns first errorhandleError: Re-throws error (no recovery),currentTargetprovides target function info for error contexthandleContinuedErrors: No-op
currentTarget Usage:
- Provides reference to the target function where the error occurred for precise debugging and logging
- Enables target-specific recovery strategies
- Allows including function name or metadata in error context
class DatabaseError extends Error {
name = "DatabaseError";
}
class LoggingError extends Error {
name = "LoggingError";
}
const process = createProcess<string, AppContext>({
aspects: [DatabaseAspect, LoggingAspect],
processOptions: {
// Prioritize database errors over logging errors
determineError: async ({ errors }) => {
return errors.find((e) => e instanceof DatabaseError) ?? errors[0];
},
// Implement recovery strategy
handleError: async ({ currentTarget, context, error }) => {
const { logger, cache } = context();
if (error instanceof DatabaseError) {
logger.error(
`Database failed (target: ${currentTarget.name}), using cache`,
error,
);
return cache.get("fallback-value");
}
// Include target function info in error logging
logger.error(
`Unrecoverable error in target: ${currentTarget.name}`,
error,
);
throw error; // Re-throw non-recoverable errors
},
// Log secondary errors for monitoring
handleContinuedErrors: async ({ context, errors }) => {
const { metrics } = context();
errors.forEach(([errorList]) => {
errorList.forEach((error) => {
metrics.incrementCounter("secondary_errors", {
type: error.constructor.name,
});
});
});
},
},
});Types Reference
Core Types
// Main exports
export {
createAspect,
createProcess,
runProcess,
runProcessWith,
AsyncContext,
};
// Error types
export { Rejection, HaltRejection, ContinuousRejection };
// Configuration types
export type { BuildOptions, ProcessOptions };
export type { Aspect, Process, Target };
export type { Advice, AdviceMetadata };Context Types
type ContextGenerator<Context> = () => Context;
type ContextAccessor<Context> = () => Context;
type SectionsUsed<Context> = readonly (keyof Context)[];
type Restricted<
Context,
Sections extends SectionsUsed<Context>,
> = Context extends object
? { readonly [key in Sections[number]]: Context[key] }
: Context;Utility Types
type ExecutionOuterContext = <SharedContext>(
callback: () => SharedContext,
) => SharedContext;🔬 Advanced Topics
The around advice is the most powerful, allowing you to wrap the target function's execution. It provides two hooks:
attachToTarget(wrapper): Wraps the original target function. These wrappers are executed closest to the target.attachToResult(wrapper): Wraps the entire execution chain, including otheraroundadvice. These wrappers are executed at the outermost layer.
Wrappers are composed like onions: the last one attached is the first one executed (LIFO).
const AdvancedAspect = createAspect<number, { log: Console }>(
(createAdvice) => ({
name: "advanced",
around: createAdvice({
use: ["log"],
advice: async ({ log }, { attachToTarget, attachToResult }) => {
// 1. Result wrapper (outermost)
attachToResult((target) => async () => {
log.info("Result wrapper: Start");
const result = await target(); // Executes target wrappers + original target
log.info("Result wrapper: End");
return result * 10;
});
// 2. Target wrapper (innermost)
attachToTarget((target) => async () => {
log.info("Target wrapper: Start");
const result = await target(); // Executes original target
log.info("Target wrapper: End");
return result + 1;
});
},
}),
}),
);
// If original target returns 5:
// Console Output:
// > Result wrapper: Start
// > Target wrapper: Start
// > Target wrapper: End
// > Result wrapper: End
// Final result: (5 + 1) * 10 = 60Composition Visualization:
graph TD
A["Result Wrapper (outermost)"] --> B["Target Wrapper"] --> C["Original Target"]
C --> D["Return + 1"] --> E["Return * 10"] --> F["Final Result"]Context Optimization
- Minimal Context: Only request sections you need via
useto reduce overhead and prevent section conflicts - Section Isolation: Design context with fine-grained sections for better parallelization
Execution Strategy
- Parallel by Default: Most advice types run in parallel for better performance
- Strategic Sequential: Use
execution: "sequential"only when order matters (e.g., database transactions)
Process Reuse
- Memoize Processes:
createProcessis computationally intensive - create once, reuse everywhere - Stateless Design: Processes are stateless and thread-safe - safe to share across requests
// ✅ Good: Create once, reuse
const commonProcess = createProcess({
aspects: [LoggingAspect, MetricsAspect],
});
const processUserRequest = (data) =>
runProcess({
process: commonProcess, // Reuse
target: async () => processUser(data),
context: () => ({ logger, metrics, db }),
});
// ❌ Bad: Creating process every time
const processUserRequest = (data) =>
runProcess({
process: createProcess({ aspects: [LoggingAspect, MetricsAspect] }), // Recreating
target: async () => processUser(data),
context: () => ({ logger, metrics, db }),
});Layered Architecture Pattern
// Infrastructure Layer
const InfrastructureAspects = [LoggingAspect, MetricsAspect, TracingAspect];
// Business Layer
const BusinessAspects = [ValidationAspect, AuthorizationAspect, CachingAspect];
// Create specialized processes
const infraProcess = createProcess({ aspects: InfrastructureAspects });
const businessProcess = createProcess({ aspects: BusinessAspects });
const fullProcess = createProcess({
aspects: [...InfrastructureAspects, ...BusinessAspects],
});Conditional Advice Pattern
const ConditionalAspect = createAspect<User, AppContext>((createAdvice) => ({
name: "conditional",
before: createAdvice({
use: ["config", "logger"],
advice: async ({ config, logger }) => {
if (config.enableDetailedLogging) {
logger.info("Detailed logging enabled");
}
},
}),
}));Error Recovery Pattern
const RetryAspect = createAspect<Data, AppContext>((createAdvice) => ({
name: "retry",
around: createAdvice({
use: ["logger"],
advice: async ({ logger }, { attachToTarget }) => {
attachToTarget((target) => async () => {
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
try {
return await target();
} catch (error) {
attempts++;
if (attempts >= maxAttempts) throw error;
logger.warn(`Attempt ${attempts} failed, retrying...`);
await new Promise((resolve) =>
setTimeout(resolve, 1000 * attempts),
);
}
}
});
},
}),
}));🔧 Development
# Install dependencies
yarn install
# Run tests
yarn test
# Build the library
yarn build📝 License
MIT © h1ylabs
