Architecture Review

obsidian-unicode-search · 25 June 2026

deep module seam leakage strong worth exploring
Strong in-process

Collapse the Storage Adapter Chain

Before

PersistCache RootPluginData Storage (10 methods) CodePointStorage UseHistoryStorage FavoriteStorage PoolStorage CodePointStore UseHistoryStore FavoriteStore PoolStore UserCharacterService

After

PersistCache ChunkStorage<T> get(chunk) overwrite(chunk, data) merge(chunk, partial) UserCharacterService PoolModule 4 Storage classes 4 Store interfaces

Files

service/rootPluginDataStorage.ts · service/codePointStorage.ts · service/useHistoryStorage.ts · service/favoriteStorage.ts · service/poolStorage.ts · service/metaStorage.ts · service/codePointStore.ts · service/useHistoryStore.ts · service/favoriteStore.ts · service/poolStore.ts

Problem

The storage layer has five near-identical adapter classes that each wrap RootPluginDataStorage with one trivial transformation (get chunk → extract array, serialize → overwrite chunk). The interface surface — RootPluginDataStorage with 10 get/overwrite methods, plus four separate *Store interfaces — is nearly as wide as the implementation. The modules are shallow.

Solution

Collapse the chain into a single generic ChunkStorage<T> adapter that handles get/overwrite/merge for any chunk type. Delete the four *Storage classes and four *Store interfaces. The few that have extra logic (e.g., PoolStorage's event triggering) move into a dedicated PoolModule.

Benefits

  • leverage: one generic adapter handles all five chunks — delete four shallow wrappers
  • locality: serialization/deserialization logic centralises in one place
  • interface shrinks: callers depend on ChunkStorage<T> instead of five separate interfaces
  • tests: one adapter to mock, not four — test surface halves
Worth exploring in-process

Collapse Pool Configuration into a Deep Module

Before

SettingTab PoolStore PoolStorage PoolChunkHandler MetaStorage Initialization lives here Runtime logic lives here Event trigger lives here

After

SettingTab PoolModule getFilter() · setBlock() · setCategory() initData() · downloadCharacters() owns: filter logic + event triggering + chunk init PoolStore PoolStorage PoolChunkHandler MetaStorage

Files

service/poolStore.ts · service/poolStorage.ts · service/poolChunkHandler.ts · service/metaStorage.ts · components/settingTab.ts

Problem

Pool configuration logic is scattered across four modules. PoolChunkHandler owns initialization, PoolStorage owns runtime get/set, and MetaStorage owns the event that triggers UCD re-download. To understand "what happens when a user toggles a block," you must trace through three files. The SettingTab (332 lines) directly calls all of them.

Solution

Merge into a single PoolModule that owns the full lifecycle: initialization, runtime get/set of blocks and categories, event triggering for re-download, and filter serialization. The module has one small interface; the implementation absorbs the logic from all four predecessors.

Benefits

  • locality: all pool-related changes concentrate in one module
  • leverage: one interface for initialization + runtime + events
  • deletion test passes: deleting it would scatter pool logic across three files plus the settings tab
  • tests: one seam to mock, not three — test through the module interface
Worth exploring in-process

Replace Modal Inheritance with Composition

Before

SuggestModal (Obsidian) FuzzySearchModal search · render · statistics TODO: "very messy, use composition" InsertCharacterModal PickCharacterModal overrides onChooseSuggestion to do nothing overrides selectSuggestion to resolve a promise

After

SuggestModal (Obsidian) SearchModule candidates · render · statistics · ranking pure search logic, no UI coupling InsertModal PickModal modals become thin wrappers around SearchModule

Files

components/fuzzySearchModal.ts · components/insertCharacterModal.ts · components/pickCharacterModal.ts

Problem

The codebase already flags this as a TODO: "The inheritance used here is very messy, use composition instead." PickCharacterModal overrides onChooseSuggestion to do nothing and overrides selectSuggestion to resolve a promise — fighting the base class's contract. The search/render/statistics logic is locked inside the inheritance tree, untestable without an Obsidian App.

Solution

Extract search logic into a SearchModule — a pure class with no Obsidian dependency. The module owns candidate retrieval, scoring, ranking, statistics, and rendering helpers. Each modal becomes a thin wrapper that composes SearchModule and wires onChooseSuggestion to its own behaviour.

Benefits

  • locality: search logic concentrates in one module, not spread across three classes
  • leverage: SearchModule usable from any future modal without inheritance
  • testability: SearchModule testable without Obsidian mocks (move to libraries/)
  • interface: modals become thin — only the choose-action differs
Worth exploring in-process

Decouple the Bootstrap Pipeline from Chunk Handlers

Before

main.ts RootDataBootstrapper MetaChunkHandler PoolChunkHandler CharacterChunkH. UseHistoryChunkH. FavoriteChunkH. also creates 6 storage instances + downloader 97 lines of wiring

After

main.ts PluginContext createStorage() createBootstrapper() createServices() initialize() ChunkHandlers Meta · Pool Character UseHist · Fav main.ts: ~15 lines all wiring inside PluginContext

Files

main.ts · service/rootDataBootstrapper.ts · service/rootPluginDataStorage.ts + all storage/handler creation

Problem

main.ts (97 lines) is almost entirely wiring: it creates PersistCache, six storage instances, a downloader, five chunk handlers, the bootstrapper, and the commander. This is the composition root, but it's doing too much — understanding "how does the plugin start?" requires reading all of it plus tracing each dependency.

Solution

Extract a PluginContext module that owns the full wiring: storage creation, handler creation, bootstrapper assembly, and service construction. main.ts becomes a thin entry point that calls PluginContext.create() and PluginContext.initialize().

Benefits

  • locality: all wiring in one place — change a dependency graph in one file
  • leverage: PluginContext reusable if the plugin is ever tested outside Obsidian
  • interface shrinks: main.ts becomes a two-line entry point
  • tests: can instantiate PluginContext with test adapters to integration-test the full pipeline
Speculative ports & adapters

Move Search Ranking into libraries/

Before

libraries/ comparison/ helpers/ data/ order/ types/ unicode-search/ Search + Ranking Logic fuzzySearchModal.ts characterSearch.ts Obsidian coupled

After

libraries/ search/ ranking · scoring · statistics pure functions, no Obsidian unicode-search/ Modal (UI only) renders, calls search module clean import

Files

components/fuzzySearchModal.ts · components/characterSearch.ts · components/characterSearchAttributes.ts · libraries/comparison/*

Problem

Search ranking logic (comparing matches, filling null scores, use-history-based ranking) lives in unicode-search/components/ — coupled to Obsidian's SearchResult type. The comparison functions in libraries/comparison/ are already pure, but the ranking orchestration in fuzzySearchModal.ts is not testable without Obsidian mocks. This violates ADR-0005's principle that libraries/ should contain all testable logic.

Solution

Create libraries/search/ with pure ranking and scoring functions. The modal becomes a thin adapter that calls into libraries/search/ and renders the results. The SearchResult type dependency stays in the adapter; the ranking logic becomes Obsidian-agnostic.

Benefits

  • testability: ranking logic testable without Obsidian — add to tests/libraries/
  • leverage: search module reusable for any future search UI (settings, CLI)
  • seam: clean boundary between ranking (pure) and rendering (Obsidian)
  • note: depends on Candidate 3 (composition) being done first
Speculative in-process

Extract Settings Business Logic from SettingTab

Before

SettingTab (332 lines) UI Rendering settings, toggles, lists Business Logic toggleHotkey, displayChar mixed: UI + logic in one class re-initializes data on hide()

After

SettingTab UI only, delegates to modules SettingsModule toggleFavoriteQuickInsert() addFavorite() · removeFavorite() saveAndReinitialize() SettingTab: ~120 lines pure rendering testable business logic

Files

components/settingTab.ts

Problem

SettingTab (332 lines) mixes Obsidian UI rendering with business logic: toggling hotkey commands, managing favourite lists, and triggering re-initialization on hide(). The codebase already flags this as a TODO: "Make settings code easier to comprehend." The business logic is untestable because it's embedded in the UI class.

Solution

Extract a SettingsModule that owns the business logic (favourite management, hotkey command registration, re-initialization). SettingTab becomes pure rendering that calls into SettingsModule.

Benefits

  • locality: settings business logic in one module
  • testability: SettingsModule testable without Obsidian UI
  • seam: clean boundary between rendering and logic
  • note: lower priority — the current code works, this is about long-term navigability

Top Recommendation

This candidate has the highest leverage. The storage layer currently has ten near-identical adapter classes (five *Storage implementations + five *Store interfaces) that mostly wrap RootPluginDataStorage with trivial transformations. Collapsing them into a single generic ChunkStorage<T> deletes six shallow modules and halves the test surface. Every other candidate builds on top of this — cleaner storage makes the pool module, search extraction, and bootstrap decoupling all easier to reason about.

Why first: it's the deepest seam. The deletion test passes with flying colours — delete the chain and you get five copies of the same merge logic scattered across the codebase. Collapsing it concentrates that complexity in one place.