Skip to content

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

typescript
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

Pipeline Pattern

Pattern Overview

The Pipeline pattern structures middleware as a sequence of processing steps with clear enter/exit points.

Implementation Example

typescript
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

Middleware Factory Pattern

Pattern Overview

The Middleware Factory pattern creates configurable middleware functions from reusable templates.

Implementation Example

typescript
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

Context Propagation Pattern

Pattern Overview

The Context Propagation pattern ensures that metadata and state flow through middleware chains.

Implementation Example

typescript
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

Conditional Middleware Pattern

Pattern Overview

The Conditional Middleware pattern selectively applies middleware based on runtime conditions.

Implementation Example

typescript
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

Observable Middleware Pattern

Pattern Overview

The Observable Middleware pattern introduces reactive behavior to content operations.

Implementation Example

typescript
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

Performance Optimization Patterns

Pattern Overview

These patterns focus on improving middleware performance through various techniques.

Implementation Examples

Caching Results Pattern

typescript
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

typescript
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

typescript
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

Released under the MIT License.