Skip to content

ADR-007: Compositional Content Architecture

Status

Accepted | Proposed | Superseded | Extends ADR-005

Version History

  • v1: 2025-04-29 - Initial proposal

Context

Our content architecture has evolved from a reactive streaming approach (ADR-004) to an orchestration layer (ADR-005) that coordinates multiple content streams. While this architecture has served us well during development, we’ve encountered limitations when deploying in different environments:

  1. Environment Coupling: Client-side code cannot use Node.js-specific features (filesystem operations, process APIs), creating friction in our isomorphic design.

  2. Complexity Overhead: The orchestration and reactive streams architecture introduces significant complexity and increases bundle size.

  3. Inheritance Limitations: Class hierarchies make it difficult to adapt to environment-specific constraints and optimize for different platforms.

  4. Progressive Web App Support: Current architecture doesn’t adequately support offline-first scenarios for PWAs.

  5. Mental Model: Reactive streams with complex subscription management creates a steep learning curve for new developers.

Decision

Adopt a Compositional Content Architecture that emphasizes:

  1. Function Composition over class inheritance
  2. Pure Functions with explicit dependencies
  3. Promise-based APIs rather than Observable streams
  4. Build-time Environment Selection where possible
  5. Simple Event Emitters for change notification

The foundation of this architecture will be a simple content store interface:

typescript
interface ContentStore {
  read(uri: string): Promise<Content | null>
  write(uri: string, content: Content): Promise<void>
  delete(uri: string): Promise<void>
  list(pattern?: string): Promise<string[]>
  onChange(callback: ChangeCallback): () => void
  dispose(): Promise<void>
}

Key architectural principles:

  • Composable: Build complex behaviors by composing simple functions
  • Environment-aware: Different implementations for different environments
  • Explicit: Clear dependencies and data flow
  • Minimal: Reduced external dependencies
  • Pragmatic: Focus on solving real problems simply

Consequences

Positive

  • Simplified Mental Model: Function composition creates more predictable and understandable code
  • Reduced Bundle Size: Smaller client-side bundles with fewer dependencies
  • Better Environment Support: Cleaner separation between Node.js and browser code
  • Improved Testability: Pure functions are easier to test in isolation
  • Enhanced Offline Support: Better architecture for progressive web apps
  • Lower Learning Curve: Promises and events are more familiar than reactive streams

Negative

  • Migration Cost: Significant refactoring to move from reactive to compositional model
  • Reactive Capabilities: Some reactive programming patterns become more manual
  • Transition Period: Supporting both models during migration adds complexity

Implementation

The implementation will follow these principles:

Function Composition

Build enhanced functionality through composition:

typescript
// Instead of inheritance:
const store = pipe(
  createMemoryStore(),
  withValidation(),
  withLogging(),
  withMetrics()
)

Environment Selection

Use build-time environment detection:

typescript
// platform.ts (selected at build time)
export const createStore =
  process.env.NODE_ENV === 'browser' ? createBrowserStore : createNodeStore

Storage Adapters

Implement adapters for different environments:

  1. Node.js: Filesystem adapter
  2. Browser: IndexedDB, localStorage adapters
  3. Shared: Memory adapter, HTTP/fetch adapter
  4. Progressive: ServiceWorker adapter with offline support

Event-Based Change Notification

Simple event system for content changes:

typescript
function onChange(callback) {
  eventEmitter.on('change', callback)
  return () => eventEmitter.off('change', callback)
}

React Integration

Hooks for React applications:

typescript
function useContent(uri) {
  const [content, setContent] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    let isMounted = true

    async function loadContent() {
      try {
        const result = await store.read(uri)
        if (isMounted) {
          setContent(result)
          setLoading(false)
        }
      } catch (error) {
        if (isMounted) {
          setError(error)
          setLoading(false)
        }
      }
    }

    loadContent()

    const unsubscribe = store.onChange((changedUri, newContent) => {
      if (changedUri === uri && isMounted) {
        setContent(newContent)
      }
    })

    return () => {
      isMounted = false
      unsubscribe()
    }
  }, [uri])

  return { content, loading }
}

Alternatives Considered

1. Enhanced Reactive Architecture

Extending the current approach with better environment detection would maintain consistency but not address bundle size and complexity issues.

2. Hybrid Approach

Using RxJS for server-side and Promises for client-side would create additional mapping complexity between models.

3. Full Microservices Architecture

A complete separation into microservices would introduce unnecessary network overhead.

Migration Path

  1. Create the new compositional architecture alongside existing code
  2. Implement core interfaces and adapters
  3. Build React integration components
  4. Gradually migrate components to use the new architecture
  5. Provide compatibility layers where needed

Conclusion

The Compositional Content Architecture provides a simpler, more flexible foundation that respects the constraints and capabilities of different runtime environments. This approach enables better isomorphic support, easier testing, and improved progressive web app capabilities while reducing complexity and bundle size.

References

  • Functional Programming in TypeScript
  • Progressive Web App best practices
  • Isomorphic JavaScript patterns
  • Function composition patterns

Released under the MIT License.