Skip to content

Composition Architecture

Composition is a core architectural principle in the ReX system. This document explains the composition architecture, its principles, patterns, and implementation strategies.

Core Principles

Functional Composition

The composition architecture is built on functional composition rather than inheritance. This approach provides several advantages:

  1. Flexibility: Small, focused functions can be combined in many ways
  2. Testability: Pure functions are easier to test in isolation
  3. Reusability: Functions can be reused across different contexts
  4. Maintainability: Simpler reasoning about behavior without inheritance complexity
  5. Progressive Enhancement: Functionality can be added incrementally

Composition Over Inheritance

The content system explicitly chooses composition over inheritance by:

  • Preferring higher-order functions over class hierarchies
  • Using function decorators to extend behavior
  • Implementing the middleware pattern for cross-cutting concerns
  • Applying adapters through delegation rather than subclassing
  • Building complex behavior from simple, composable parts

Composition Patterns

Function Composition

The most fundamental pattern is the composition of functions:

typescript
// Simple function composition
const pipeline = compose(validateContent, transformContent, normalizeContent)

// Usage
const result = pipeline(content)

This pattern creates new functions by combining existing ones, where the output of one function becomes the input to the next.

Higher-Order Functions

Higher-order functions create or modify other functions:

typescript
// Higher-order function that adds logging
function withLogging<T>(fn: (input: T) => T): (input: T) => T {
  return (input: T) => {
    console.log('Input:', input)
    const result = fn(input)
    console.log('Output:', result)
    return result
  }
}

// Usage
const loggedTransform = withLogging(transformContent)

This pattern allows extending or modifying behavior without changing the original function.

Middleware Pipeline

The middleware pattern applies a series of processors to a context object:

typescript
// Middleware function signature
type Middleware<T> = (context: T, next: () => Promise<T>) => Promise<T>

// Middleware composition
const pipeline = composeMiddleware<ContentContext>(
  cacheMiddleware,
  validationMiddleware,
  transformationMiddleware,
  loggingMiddleware
)

// Usage
const result = await pipeline(context)

This pattern enables:

  • Bidirectional processing (on the way in and out)
  • Short-circuiting the pipeline when needed
  • Combining cross-cutting concerns

Factory Functions

Factory functions create configured instances:

typescript
// Factory function for creating adapters
function createFileSystemAdapter(
  options: FileSystemAdapterOptions
): FileSystemAdapter {
  // Create and configure the adapter
  return adapter
}

// Factory function that accepts plugins
function createContentStore(options?: ContentStoreOptions): ContentStore {
  // Create and configure the store with appropriate behavior
  return store
}

This pattern enables:

  • Hiding implementation details
  • Providing sane defaults
  • Encapsulating complex initialization logic
  • Supporting different configurations

Composition Architecture Layers

The composition architecture spans multiple layers of the system:

Store Layer Composition

Content stores are composed from:

  • A base store implementation
  • One or more adapters
  • A middleware pipeline
  • Optional plugins
typescript
const store = createContentStore({
  adapter: createFileSystemAdapter({ basePath: '/content' }),
  middleware: [
    cacheMiddleware({ ttl: 60000 }),
    validationMiddleware({ schema }),
    loggingMiddleware({ level: 'info' }),
  ],
  plugins: [createVersioningPlugin(), createSearchPlugin()],
})

Adapter Layer Composition

Adapters can be composed using:

  • Base adapters with specific capabilities
  • Adapter decorators for enhanced behavior
  • Composite adapters combining multiple adapters
typescript
// Composite adapter: fallback chain
const fallbackAdapter = createFallbackAdapter([
  createHttpAdapter({ baseUrl: 'https://api.example.com/content' }),
  createIndexedDBAdapter({ databaseName: 'content-cache' }),
  createMemoryAdapter(),
])

// Decorated adapter: transformation
const compressingAdapter = createTransformAdapter(baseAdapter, {
  read: decompressContent,
  write: compressContent,
})

Middleware Layer Composition

Middleware functions are composed into pipelines:

typescript
// Create middleware pipeline
const pipeline = composeMiddleware<ContentContext>([
  // Request phase (inbound)
  createTimingMiddleware(),
  createAuthMiddleware({ validateToken }),
  createCacheMiddleware({ ttl: 3600 }),

  // Core operation
  createCoreOperationMiddleware(),

  // Response phase (outbound)
  createTransformMiddleware({ formats }),
  createCompressionMiddleware({ level: 'fast' }),
  createLoggingMiddleware({ level: 'info' }),
])

Implementation Strategies

Simple Function Composition

The simplest composition implementation:

typescript
// Simple compose function for synchronous functions
function compose<T>(...fns: Array<(arg: T) => T>): (arg: T) => T {
  return (arg: T) => fns.reduceRight((acc, fn) => fn(acc), arg)
}

Promise-Based Composition

For asynchronous operations:

typescript
// Compose function for promise-returning functions
function composeAsync<T>(
  ...fns: Array<(arg: T) => Promise<T>>
): (arg: T) => Promise<T> {
  return async (arg: T) => {
    let result = arg
    for (const fn of fns) {
      result = await fn(result)
    }
    return result
  }
}

Middleware Composition

Bidirectional middleware with next() pattern:

typescript
// Middleware composition
function composeMiddleware<T>(
  middleware: Middleware<T>[]
): (context: T) => Promise<T> {
  return (context: T) => {
    let index = -1

    const dispatch = async (i: number, ctx: T): Promise<T> => {
      if (i <= index) {
        throw new Error('next() called multiple times')
      }

      index = i

      if (i === middleware.length) {
        return ctx
      }

      const next = () => dispatch(i + 1, ctx)
      return middleware[i](ctx, next)
    }

    return dispatch(0, context)
  }
}

Factory Pattern Implementation

Configurable factory functions:

typescript
function createConfigurableFactory<T, Options>(
  defaultOptions: Options,
  createInstance: (options: Options) => T
): (options?: Partial<Options>) => T {
  return (options?: Partial<Options>) => {
    const mergedOptions = { ...defaultOptions, ...options }
    return createInstance(mergedOptions)
  }
}

Advanced Composition Techniques

Conditional Composition

Apply middleware or transformations conditionally:

typescript
// Conditional middleware
function conditionalMiddleware<T>(
  condition: (context: T) => boolean,
  middleware: Middleware<T>
): Middleware<T> {
  return async (context: T, next: () => Promise<T>) => {
    if (condition(context)) {
      return middleware(context, next)
    }
    return next()
  }
}

// Usage
pipeline.use(
  conditionalMiddleware(
    ctx => ctx.uri.endsWith('.md'),
    markdownProcessingMiddleware
  )
)

Dynamic Composition

Build pipelines dynamically based on runtime conditions:

typescript
function createDynamicPipeline<T>(context: T): Middleware<T>[] {
  const pipeline: Middleware<T>[] = [
    // Common middleware
    loggingMiddleware,
    timingMiddleware,
  ]

  // Add environment-specific middleware
  if (isNode()) {
    pipeline.push(nodeSpecificMiddleware)
  } else if (isBrowser()) {
    pipeline.push(browserSpecificMiddleware)
  }

  // Add capability-based middleware
  if (hasCapability('compression')) {
    pipeline.push(compressionMiddleware)
  }

  return pipeline
}

Composable Options

Enable configuration objects to be composed:

typescript
// Merge option objects with deep merging
function mergeOptions<T>(baseOptions: T, ...overrides: Array<Partial<T>>): T {
  return deepMerge(baseOptions, ...overrides)
}

// Usage
const options = mergeOptions(defaultOptions, environmentOptions, userOptions)

Composition and Error Handling

Composition requires careful error handling:

Error Propagation

Ensure errors propagate correctly through the composition chain:

typescript
// Error-aware composition
function safeComposeAsync<T>(
  ...fns: Array<(arg: T) => Promise<T>>
): (arg: T) => Promise<T> {
  return async (arg: T) => {
    let result = arg
    try {
      for (const fn of fns) {
        result = await fn(result)
      }
      return result
    } catch (error) {
      // Add context to the error
      if (error instanceof Error) {
        error.message = `Composition error: ${error.message}`
      }
      throw error
    }
  }
}

Recovery Composition

Add error recovery to composition chains:

typescript
// With recovery handler
function withRecovery<T>(
  fn: (arg: T) => Promise<T>,
  recovery: (error: Error, arg: T) => Promise<T>
): (arg: T) => Promise<T> {
  return async (arg: T) => {
    try {
      return await fn(arg)
    } catch (error) {
      return recovery(error as Error, arg)
    }
  }
}

// Usage
const robustTransform = withRecovery(
  transformContent,
  async (error, content) => {
    console.error('Transform failed, using fallback:', error)
    return fallbackTransform(content)
  }
)

Performance Considerations

Composition can impact performance:

Minimizing Overhead

Minimize overhead in composed functions:

typescript
// Lightweight composition for hot paths
function lightCompose<T>(...fns: Array<(arg: T) => T>): (arg: T) => T {
  // For extremely hot paths with 2-3 functions
  if (fns.length === 1) return fns[0]
  if (fns.length === 2) {
    const [f, g] = fns
    return (arg: T) => f(g(arg))
  }
  if (fns.length === 3) {
    const [f, g, h] = fns
    return (arg: T) => f(g(h(arg)))
  }

  // Fall back to general case
  return (arg: T) => fns.reduceRight((acc, fn) => fn(acc), arg)
}

Lazy Evaluation

Use lazy evaluation when composition is expensive:

typescript
// Lazy pipeline creation
function createLazyPipeline<T>(
  ...factories: Array<() => (arg: T) => T>
): (arg: T) => T {
  let pipeline: ((arg: T) => T) | null = null

  return (arg: T) => {
    if (!pipeline) {
      const fns = factories.map(factory => factory())
      pipeline = compose(...fns)
    }
    return pipeline(arg)
  }
}

Testing Composed Functions

Strategies for testing composed functions:

Unit Testing Components

Test individual functions before composition:

typescript
// Test individual middleware
test('validation middleware', async () => {
  const middleware = validationMiddleware({ schema })
  const context = { content: invalidContent }
  const next = jest.fn().mockResolvedValue(context)

  await expect(middleware(context, next)).rejects.toThrow()
  expect(next).not.toHaveBeenCalled()
})

Testing Composition

Test the composed result:

typescript
// Test middleware pipeline
test('middleware pipeline', async () => {
  const pipeline = composeMiddleware([
    loggingMiddleware,
    validationMiddleware({ schema }),
    transformMiddleware,
  ])

  const result = await pipeline({ content: validContent })
  expect(result.content).toMatchObject(expectedTransformedContent)
})

Released under the MIT License.