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:
Environment Coupling: Client-side code cannot use Node.js-specific features (filesystem operations, process APIs), creating friction in our isomorphic design.
Complexity Overhead: The orchestration and reactive streams architecture introduces significant complexity and increases bundle size.
Inheritance Limitations: Class hierarchies make it difficult to adapt to environment-specific constraints and optimize for different platforms.
Progressive Web App Support: Current architecture doesn’t adequately support offline-first scenarios for PWAs.
Mental Model: Reactive streams with complex subscription management creates a steep learning curve for new developers.
Decision
Adopt a Compositional Content Architecture that emphasizes:
- Function Composition over class inheritance
- Pure Functions with explicit dependencies
- Promise-based APIs rather than Observable streams
- Build-time Environment Selection where possible
- Simple Event Emitters for change notification
The foundation of this architecture will be a simple content store interface:
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:
// Instead of inheritance:
const store = pipe(
createMemoryStore(),
withValidation(),
withLogging(),
withMetrics()
)
Environment Selection
Use build-time environment detection:
// platform.ts (selected at build time)
export const createStore =
process.env.NODE_ENV === 'browser' ? createBrowserStore : createNodeStore
Storage Adapters
Implement adapters for different environments:
- Node.js: Filesystem adapter
- Browser: IndexedDB, localStorage adapters
- Shared: Memory adapter, HTTP/fetch adapter
- Progressive: ServiceWorker adapter with offline support
Event-Based Change Notification
Simple event system for content changes:
function onChange(callback) {
eventEmitter.on('change', callback)
return () => eventEmitter.off('change', callback)
}
React Integration
Hooks for React applications:
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
- Create the new compositional architecture alongside existing code
- Implement core interfaces and adapters
- Build React integration components
- Gradually migrate components to use the new architecture
- 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