Skip to content

Architecture: Obsidian Integration Abstraction ​

Overview ​

This document describes the architectural approach for abstracting Obsidian API integration in the Query All The Things plugin. The goal is to improve testability, maintainability, and reduce coupling to the Obsidian platform.

Problem Statement ​

The original codebase had several challenges:

  1. Tight Coupling: Services directly used Obsidian API (app.vault, app.workspace, app.metadataCache)
  2. Difficult Testing: Unit tests required a full Obsidian environment or extensive mocking
  3. Maintenance Burden: Obsidian API changes required updates throughout the codebase
  4. Poor Separation of Concerns: Business logic was mixed with platform-specific code

Solution: Adapter Pattern ​

The Adapter Pattern provides a stable interface to wrap the Obsidian API. This creates an abstraction layer between the plugin's business logic and the Obsidian platform.

Architecture Diagram ​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  Plugin Business Logic              β”‚
β”‚  (Services, Queries, Rendering, Data Management)    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚ depends on
                  β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Adapter Interfaces                     β”‚
β”‚  IVaultAdapter | IWorkspaceAdapter                  β”‚
β”‚  IMetadataCacheAdapter | IPluginAdapter             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚ implemented by
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β–Ό                   β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Obsidian Adaptersβ”‚  β”‚  Mock Adapters   β”‚
β”‚  (Production)    β”‚  β”‚   (Testing)      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Obsidian API    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Components ​

1. Adapter Interfaces (src/Adapters/I*.ts) ​

Define the contract for interacting with Obsidian. These interfaces are:

  • Stable: Changes should be rare and backward compatible
  • Focused: Each interface covers one aspect of Obsidian integration
  • Platform-agnostic: No Obsidian-specific implementation details

IVaultAdapter ​

Handles file system operations:

  • File CRUD operations (create, read, update, delete)
  • File system events (create, modify, delete, rename)
  • Frontmatter processing

IWorkspaceAdapter ​

Handles workspace and UI operations:

  • Custom event triggering and listening
  • Layout readiness
  • Vault information

IMetadataCacheAdapter ​

Handles metadata operations:

  • File metadata access
  • Metadata change events
  • Custom metadata events (e.g., DataView)

IPluginAdapter ​

Handles plugin lifecycle operations:

  • Event registration
  • Code block processors
  • Commands and ribbon icons
  • Plugin status checking

2. Obsidian Adapters (src/Adapters/Obsidian*.ts) ​

Production implementations that wrap the real Obsidian API:

  • Thin Wrappers: Minimal logic, just delegate to Obsidian API
  • One-to-One Mapping: Each adapter method maps to one or more Obsidian API calls
  • Error Handling: Preserves Obsidian's error behavior

3. Mock Adapters (src/Adapters/Mock*.ts) ​

Testing implementations that simulate Obsidian behavior:

  • In-Memory State: Maintains state in memory (files, events, metadata)
  • Event Simulation: Provides methods to trigger events for testing
  • Inspection Methods: Additional methods for test assertions

4. Adapter Factory (src/Adapters/createObsidianAdapters.ts) ​

Centralized creation of adapter sets:

  • Single Responsibility: Creates all adapters with consistent configuration
  • Dependency Injection: Returns a complete IObsidianAdapter object
  • Type Safety: Ensures all adapters are properly initialized

Migration Strategy ​

The migration from direct Obsidian API usage to adapters follows these phases:

Phase 1: Create Adapters (Completed) ​

  • βœ… Define adapter interfaces
  • βœ… Implement Obsidian adapters
  • βœ… Implement mock adapters
  • βœ… Create tests for mock adapters

Phase 2: Refactor Core Services (In Progress) ​

  • [ ] Identify services with high Obsidian coupling
  • [ ] Refactor services to accept adapters via constructor
  • [ ] Update service tests to use mock adapters
  • [ ] Update plugin initialization to inject adapters

Phase 3: Gradual Migration ​

  • [ ] Migrate one service at a time
  • [ ] Maintain backward compatibility
  • [ ] Add integration tests alongside unit tests
  • [ ] Document each migration

Phase 4: Complete Migration ​

  • [ ] Migrate all services to use adapters
  • [ ] Remove direct Obsidian API usage from services
  • [ ] Update documentation
  • [ ] Establish coding standards for future development

Benefits Realized ​

Improved Testability ​

typescript
// Before: Hard to test without Obsidian
class ServiceOld {
  constructor(private plugin: Plugin) {}
  
  async doWork() {
    const files = this.plugin.app.vault.getMarkdownFiles();
    // ... business logic
  }
}

// After: Easy to test with mocks
class ServiceNew {
  constructor(private adapters: IObsidianAdapter) {}
  
  async doWork() {
    const files = this.adapters.vault.getMarkdownFiles();
    // ... business logic
  }
}

// Test
const mockAdapters = {
  vault: new MockVaultAdapter(),
  workspace: new MockWorkspaceAdapter(),
  metadataCache: new MockMetadataCacheAdapter(),
  plugin: new MockPluginAdapter()
};
const service = new ServiceNew(mockAdapters);

Reduced Coupling ​

  • Services depend on stable interfaces, not Obsidian implementation
  • Obsidian API changes only affect adapter implementations
  • Business logic is isolated from platform specifics

Better Maintainability ​

  • Single location to update when Obsidian API changes
  • Clear separation between business logic and platform integration
  • Easier to onboard new developers

Enhanced Flexibility ​

  • Can add new platforms by implementing adapters
  • Can create different adapter implementations for different scenarios
  • Can add instrumentation/logging at adapter level

Design Principles ​

  1. Dependency Inversion: Services depend on abstractions (interfaces), not concretions (Obsidian API)
  2. Single Responsibility: Each adapter focuses on one aspect of Obsidian integration
  3. Interface Segregation: Interfaces are focused and minimal
  4. Liskov Substitution: Mock adapters can replace Obsidian adapters seamlessly
  5. Open/Closed: Adapters are open for extension, closed for modification

Future Enhancements ​

Potential Additions ​

  • Logging Adapter: Add logging to all adapter calls for debugging
  • Performance Adapter: Add performance metrics to adapter calls
  • Caching Adapter: Add caching layer for expensive operations
  • Adapter Composition: Combine multiple adapter behaviors

Extensibility ​

The adapter pattern makes it easier to:

  • Support multiple Obsidian versions simultaneously
  • Create compatibility layers for major API changes
  • Add new Obsidian features incrementally
  • Support alternative platforms (if needed)

Conclusion ​

The adapter pattern provides a robust foundation for Obsidian integration. By isolating platform-specific code, we achieve better testability, maintainability, and flexibility. This investment in architecture will pay dividends as the plugin evolves and Obsidian's API continues to develop.