Middleware Composition Patterns
This document outlines patterns for implementing and composing middleware in the ReX content system. Middleware provides a powerful way to enhance content operations with cross-cutting concerns such as validation, caching, logging, and error handling.
Function Composition Pattern
Pattern Overview
The Function Composition pattern combines multiple middleware functions into a single processing pipeline.
Implementation Example
import {
ContentMiddleware,
ContentOperation,
} from '@lib/content/middleware/types'
// Simple function composition utility
export function compose<T>(...fns: Array<(arg: T) => T>): (arg: T) => T {
return (arg: T) => fns.reduceRight((acc, fn) => fn(acc), arg)
}
// Middleware composition with content operations
export function composeMiddleware(
...middleware: ContentMiddleware[]
): ContentMiddleware {
return next => {
// Create a chain of middleware functions
const chain = middleware.reduceRight(
(nextMiddleware, currentMiddleware) => {
return currentMiddleware(nextMiddleware)
},
next
)
return chain
}
}
// Usage example
const enhancedOperation = composeMiddleware(
withLogging({ level: 'info' }),
withValidation(contentSchema),
withCaching({ ttl: 60000 })
)(baseOperation)
Considerations
- Order matters: Middleware executes in reverse order of declaration
- Keep middleware functions pure and focused on a single concern
- Handle error propagation consistently through the chain
- Consider the performance impact of deeply nested middleware chains
Related Patterns
- Pipeline Pattern - Alternative approach to middleware composition
- Middleware Factory Pattern - Creating configurable middleware
Pipeline Pattern
Pattern Overview
The Pipeline pattern structures middleware as a sequence of processing steps with clear enter/exit points.
Implementation Example
import { ContentOperation, ContentContext } from '@lib/content/middleware/types'
type PipelineStep = (
context: ContentContext,
next: () => Promise<void>
) => Promise<void>
export function createPipeline(...steps: PipelineStep[]): ContentOperation {
return async context => {
// Create a linked list of steps
let index = 0
// Function to invoke the next step in the pipeline
const invokeNext = async (): Promise<void> => {
// End of pipeline
if (index >= steps.length) {
return
}
// Get current step and increment for next call
const currentStep = steps[index++]
// Execute current step with the next step as continuation
await currentStep(context, invokeNext)
}
// Start pipeline execution
await invokeNext()
return context.result
}
}
// Usage example
const pipeline = createPipeline(
// Step 1: Validate input
async (context, next) => {
if (!isValidContent(context.params.content)) {
throw new ContentValidationError('Invalid content format')
}
await next()
},
// Step 2: Check cache
async (context, next) => {
const { uri } = context.params
const cached = await cache.get(uri)
if (cached) {
context.result = cached
// Skip remaining steps
return
}
// Continue to next step
await next()
},
// Step 3: Process content
async (context, next) => {
// Process content
context.result = await processContent(context.params)
// Continue to next step
await next()
},
// Step 4: Update cache
async (context, next) => {
if (context.result) {
await cache.set(context.params.uri, context.result)
}
// Continue to next step
await next()
}
)
Considerations
- Provides more explicit control over execution flow than function composition
- Enables early termination of the pipeline at any step
- Allows explicit context sharing between steps
- Can be more verbose than simple function composition
Related Patterns
- Function Composition Pattern - Simpler approach for linear middleware
- Command Pattern - Similar pattern focused on encapsulating operations
Middleware Factory Pattern
Pattern Overview
The Middleware Factory pattern creates configurable middleware functions from reusable templates.
Implementation Example
import {
ContentMiddleware,
ContentOperation,
} from '@lib/content/middleware/types'
// Cache middleware factory
export function withCaching(options = {}): ContentMiddleware {
const { ttl = 60000, namespace = 'content' } = options
// Return a configured middleware function
return (next: ContentOperation): ContentOperation => {
// Return the enhanced operation
return async params => {
const { uri } = params
const cacheKey = `${namespace}:${uri}`
// Check cache
try {
const cached = await cache.get(cacheKey)
if (cached) {
return cached
}
} catch (error) {
// Log cache error but continue with operation
console.warn('Cache read error:', error)
}
// Execute the operation
const result = await next(params)
// Update cache
try {
await cache.set(cacheKey, result, ttl)
} catch (error) {
// Log cache error
console.warn('Cache write error:', error)
}
return result
}
}
}
// Validation middleware factory
export function withValidation(schema): ContentMiddleware {
return (next: ContentOperation): ContentOperation => {
return async params => {
// Validate input parameters
const validationResult = schema.validate(params.content)
if (!validationResult.valid) {
throw new ContentValidationError('Content validation failed', {
details: validationResult.errors,
})
}
// Continue with operation
return next(params)
}
}
}
// Usage example
const enhancedOperation = pipe(
baseOperation,
withValidation(contentSchema),
withCaching({ ttl: 300000, namespace: 'blog' }),
withLogging({ level: 'debug', prefix: 'CONTENT' })
)
Considerations
- Separate configuration from implementation for reusability
- Use sensible defaults for all options
- Document the purpose and available options clearly
- Consider combining multiple concerns only when they are intrinsically related
Related Patterns
- Decorator Pattern - Enhancing objects with additional functionality
- Specialization Pattern - Creating specialized middleware variants
Context Propagation Pattern
Pattern Overview
The Context Propagation pattern ensures that metadata and state flow through middleware chains.
Implementation Example
import { ContentContext, ContentOperation } from '@lib/content/middleware/types'
// Create an operation with shared context
export function createContextualOperation(
baseOperation: ContentOperation
): ContentOperation {
return async params => {
// Initialize shared context
const context: ContentContext = {
params,
result: null,
metadata: {
startTime: Date.now(),
operationId: generateUUID(),
operationType: params.type || 'read',
},
state: new Map(),
}
try {
// Execute operation with context
context.result = await baseOperation(params, context)
// Add completion information
context.metadata.endTime = Date.now()
context.metadata.duration =
context.metadata.endTime - context.metadata.startTime
return context.result
} catch (error) {
// Enrich error with context
if (error instanceof ContentError) {
error.context = {
...error.context,
operationId: context.metadata.operationId,
duration: Date.now() - context.metadata.startTime,
}
}
throw error
}
}
}
// Middleware that uses and modifies context
export function withContextLogging(options = {}): ContentMiddleware {
return (next: ContentOperation): ContentOperation => {
return async (params, context) => {
if (!context) {
// Create context if not provided
context = {
params,
result: null,
metadata: { startTime: Date.now() },
state: new Map(),
}
}
// Log operation start
console.log(`Operation ${context.metadata.operationType} started`, {
uri: params.uri,
operationId: context.metadata.operationId,
})
try {
// Execute with context
const result = await next(params, context)
// Log operation completion
console.log(`Operation ${context.metadata.operationType} completed`, {
uri: params.uri,
operationId: context.metadata.operationId,
duration: Date.now() - context.metadata.startTime,
})
return result
} catch (error) {
// Log operation failure
console.error(`Operation ${context.metadata.operationType} failed`, {
uri: params.uri,
operationId: context.metadata.operationId,
duration: Date.now() - context.metadata.startTime,
error: error.message,
})
throw error
}
}
}
}
Considerations
- Define a clear context structure to prevent naming conflicts
- Use immutable context properties for critical metadata
- Provide fallback behavior when context is missing
- Consider performance implications of large contexts
Related Patterns
- Thread Local Storage Pattern - Alternative for context propagation
- Ambient Context Pattern - Implicit context access
Conditional Middleware Pattern
Pattern Overview
The Conditional Middleware pattern selectively applies middleware based on runtime conditions.
Implementation Example
import {
ContentMiddleware,
ContentOperation,
} from '@lib/content/middleware/types'
// Apply middleware conditionally
export function when(
condition: (params) => boolean,
middleware: ContentMiddleware
): ContentMiddleware {
return (next: ContentOperation): ContentOperation => {
return async params => {
// Check condition
if (condition(params)) {
// Apply middleware
return middleware(next)(params)
}
// Skip middleware
return next(params)
}
}
}
// Only for specific content types
export function onlyForContentType(
contentType: string | string[],
middleware: ContentMiddleware
): ContentMiddleware {
const contentTypes = Array.isArray(contentType) ? contentType : [contentType]
return when(
params => contentTypes.includes(params.content?.contentType),
middleware
)
}
// Only in specific environments
export function onlyInEnvironment(
environment: 'node' | 'browser' | 'serviceworker',
middleware: ContentMiddleware
): ContentMiddleware {
return when(() => {
switch (environment) {
case 'node':
return isNode()
case 'browser':
return isBrowser()
case 'serviceworker':
return isServiceWorker()
default:
return false
}
}, middleware)
}
// Usage example
const enhancedOperation = pipe(
baseOperation,
withLogging(), // Always applied
onlyForContentType('text/markdown', withMarkdownProcessing()),
onlyInEnvironment('browser', withBrowserCache()),
when(params => params.content?.size > 1000000, withCompression())
)
Considerations
- Keep condition functions pure and efficient
- Consider caching condition results for repeated evaluations
- Provide debugging options to understand why middleware was skipped
- Document the conditions clearly for maintainability
Related Patterns
- Strategy Pattern - Selecting different implementations at runtime
- Feature Flag Pattern - Enabling/disabling features based on configuration
Observable Middleware Pattern
Pattern Overview
The Observable Middleware pattern introduces reactive behavior to content operations.
Implementation Example
import {
ContentMiddleware,
ContentOperation,
} from '@lib/content/middleware/types'
import { Subject, Observable } from 'rxjs'
// Create observable for operation events
export function withObservable(): ContentMiddleware {
// Subject for operation events
const subject = new Subject()
// Expose the observable
const observable = subject.asObservable()
return (next: ContentOperation): ContentOperation => {
// Attach observable to the operation
const operation: ContentOperation & {
observable: Observable<any>
} = async params => {
// Emit operation start event
subject.next({
type: 'start',
uri: params.uri,
operationType: params.type || 'read',
timestamp: Date.now(),
})
try {
// Execute operation
const result = await next(params)
// Emit operation success event
subject.next({
type: 'success',
uri: params.uri,
operationType: params.type || 'read',
timestamp: Date.now(),
result,
})
return result
} catch (error) {
// Emit operation error event
subject.next({
type: 'error',
uri: params.uri,
operationType: params.type || 'read',
timestamp: Date.now(),
error,
})
throw error
}
}
// Attach observable to operation
operation.observable = observable
return operation
}
}
// Usage example
const enhancedOperation = withObservable()(baseOperation)
// Subscribe to operation events
enhancedOperation.observable.subscribe(event => {
console.log('Operation event:', event)
})
// Execute operation
const result = await enhancedOperation({ uri: 'content/example.md' })
Considerations
- Decide on the appropriate granularity of events
- Consider memory usage for long-lived subscriptions
- Provide unsubscribe mechanisms to prevent memory leaks
- Document the observable contract and event types
Related Patterns
- Event Emitter Pattern - Simpler event-based approach
- Reactive Streams Pattern - Stream-based processing
Performance Optimization Patterns
Pattern Overview
These patterns focus on improving middleware performance through various techniques.
Implementation Examples
Caching Results Pattern
import {
ContentMiddleware,
ContentOperation,
} from '@lib/content/middleware/types'
// Cache operation results based on input parameters
export function withResultCache(options = {}): ContentMiddleware {
const { ttl = 60000, maxSize = 100 } = options
const cache = new LRUCache<string, any>({ max: maxSize })
return (next: ContentOperation): ContentOperation => {
return async params => {
// Generate cache key from params
const cacheKey = generateCacheKey(params)
// Check cache
if (cache.has(cacheKey)) {
return cache.get(cacheKey)
}
// Execute operation
const result = await next(params)
// Update cache
cache.set(cacheKey, result, { ttl })
return result
}
}
}
// Helper to generate cache key
function generateCacheKey(params) {
// Create stable JSON representation for caching
return JSON.stringify({
uri: params.uri,
type: params.type,
options: params.options,
})
}
Batch Processing Pattern
import {
ContentMiddleware,
ContentOperation,
} from '@lib/content/middleware/types'
// Batch similar operations
export function withBatching(options = {}): ContentMiddleware {
const { maxBatchSize = 10, maxWaitTime = 50 } = options
const batches = new Map()
return (next: ContentOperation): ContentOperation => {
return async params => {
// Only batch read operations
if (params.type !== 'read') {
return next(params)
}
// Generate batch key based on similar operations
const batchKey = generateBatchKey(params)
// Create promise for this operation
return new Promise((resolve, reject) => {
// Get or create batch
if (!batches.has(batchKey)) {
// Create new batch
const batch = {
operations: [],
timer: setTimeout(() => processBatch(batchKey), maxWaitTime),
}
batches.set(batchKey, batch)
}
// Add to batch
const batch = batches.get(batchKey)
batch.operations.push({
params,
resolve,
reject,
})
// Process immediately if batch is full
if (batch.operations.length >= maxBatchSize) {
clearTimeout(batch.timer)
processBatch(batchKey)
}
})
}
// Process a batch of operations
async function processBatch(batchKey) {
const batch = batches.get(batchKey)
if (!batch) return
// Remove batch from map
batches.delete(batchKey)
// Extract operations
const operations = batch.operations
try {
// Create batch parameters
const batchParams = {
type: 'batchRead',
uris: operations.map(op => op.params.uri),
}
// Execute batch operation
const results = await next(batchParams)
// Resolve individual operations
operations.forEach((op, index) => {
op.resolve(results[index])
})
} catch (error) {
// Reject all operations
operations.forEach(op => {
op.reject(error)
})
}
}
}
}
// Helper to generate batch key
function generateBatchKey(params) {
// Group similar read operations
return `${params.type}:${params.options?.namespace || 'default'}`
}
Lazy Evaluation Pattern
import {
ContentMiddleware,
ContentOperation,
} from '@lib/content/middleware/types'
// Only process content when needed
export function withLazyProcessing(): ContentMiddleware {
return (next: ContentOperation): ContentOperation => {
return async params => {
// Execute base operation
const result = await next(params)
// Return a proxy that processes content only when accessed
return new Proxy(result, {
get: (target, prop) => {
// Process content on first access
if (prop === 'processedContent' && !target._processed) {
target.processedContent = processContent(target.data)
target._processed = true
}
return target[prop]
},
})
}
}
}
// Process content (expensive operation)
function processContent(content) {
// Expensive transformation
return transform(content)
}
Considerations
- Balance memory usage against performance benefits
- Add monitoring to verify performance improvements
- Consider edge cases like cache invalidation
- Document performance characteristics for middleware users
Related Patterns
- Memoization Pattern - Caching function results based on inputs
- Object Pool Pattern - Reusing objects to reduce allocation costs