Obsidian Reference Architecture
Overview
Architecture patterns for complex Obsidian plugins: modular project structure with separate files for views, commands, settings, and services; state management; a service layer for vault operations; command registry; view manager; and CSS scoping.
Prerequisites
- TypeScript and Obsidian API familiarity
- Working build pipeline (esbuild recommended)
- Plugin scaffolding complete (
manifest.json, package.json, tsconfig.json)
Instructions
Step 1: Project Structure
my-plugin/
├── src/
│ ├── main.ts # Plugin entry — thin orchestrator
│ ├── types.ts # Shared interfaces and type definitions
│ ├── constants.ts # Plugin-wide constants
│ ├── commands/
│ │ ├── index.ts # Command registry
│ │ ├── insert-template.ts
│ │ └── toggle-sidebar.ts
│ ├── views/
│ │ ├── index.ts # View registry
│ │ ├── sidebar-view.ts
│ │ └── modal-view.ts
│ ├── settings/
│ │ ├── settings.ts # Settings interface and defaults
│ │ └── settings-tab.ts # Settings UI tab
│ └── services/
│ ├── vault-service.ts # File read/write/search operations
│ ├── metadata-service.ts # Frontmatter and cache operations
│ └── sync-service.ts # External sync or background tasks
├── styles.css
├── manifest.json
├── versions.json
├── package.json
├── tsconfig.json
└── esbuild.config.mjs
Step 2: Thin Main Entry Point
import { Plugin } from 'obsidian';
import { MyPluginSettings, DEFAULT_SETTINGS } from './settings/settings';
import { MySettingTab } from './settings/settings-tab';
import { registerCommands } from './commands';
import { registerViews } from './views';
import { VaultService } from './services/vault-service';
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
vaultService: VaultService;
async onload() {
await this.loadSettings();
this.vaultService = new VaultService(this.app);
registerCommands(this);
registerViews(this);
this.addSettingTab(new MySettingTab(this.app, this));
}
onunload() {
this.vaultService.destroy();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
Step 3: Command Registry Pattern
import type MyPlugin from '../main';
import { insertTemplate } from './insert-template';
import { toggleSidebar } from './toggle-sidebar';
export function registerCommands(plugin: MyPlugin) {
plugin.addCommand({
id: 'insert-template',
name: 'Insert Template',
editorCallback: (editor, view) => insertTemplate(plugin, editor, view),
});
plugin.addCommand({
id: 'toggle-sidebar',
name: 'Toggle Sidebar',
callback: () => toggleSidebar(plugin),
});
}
import { Editor, MarkdownView } from 'obsidian';
import type MyPlugin from '../main';
export function insertTemplate(plugin: MyPlugin, editor: Editor, view: MarkdownView) {
const template = plugin.settings.defaultTemplate;
editor.replaceSelection(template);
}
Step 4: View Manager
import type MyPlugin from '../main';
import { SidebarView, VIEW_TYPE_SIDEBAR } from './sidebar-view';
export function registerViews(plugin: MyPlugin) {
plugin.registerView(VIEW_TYPE_SIDEBAR, (leaf) => new SidebarView(leaf, plugin));
plugin.addRibbonIcon('layout-sidebar-right', 'Open Sidebar', () => {
activateView(plugin);
});
}
async function activateView(plugin: MyPlugin) {
const { workspace } = plugin.app;
let leaf = workspace.getLeavesOfType(VIEW_TYPE_SIDEBAR)[0];
if (!leaf) {
const rightLeaf = workspace.getRightLeaf(false);
if (rightLeaf) {
await rightLeaf.setViewState({ type: VIEW_TYPE_SIDEBAR, : });
leaf = rightLeaf;
}
}
(leaf) {
workspace.(leaf);
}
}
import { ItemView, WorkspaceLeaf } from 'obsidian';
import type MyPlugin from '../main';
export const VIEW_TYPE_SIDEBAR = 'my-plugin-sidebar';
export class SidebarView extends ItemView {
plugin: MyPlugin;
constructor(leaf: WorkspaceLeaf, plugin: MyPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return VIEW_TYPE_SIDEBAR;
}
getDisplayText(): string {
return 'My Plugin';
}
getIcon(): string {
return 'layout-sidebar-right';
}
async onOpen() {
const container = this.containerEl.children[1];
container.();
container.();
container.(, { : });
list = container.();
files = ...();
( file files) {
list.(, { : file. });
}
}
() {
..();
}
}
Step 5: Service Layer for Vault Operations
import { App, TFile, TFolder, CachedMetadata } from 'obsidian';
export class VaultService {
constructor(private app: App) {}
async readFile(path: string): Promise<string> {
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) throw new Error(`Not a file: ${path}`);
return this.app.vault.read(file);
}
async writeFile(path: string, content: string): Promise<void> {
const file = this.app.vault.(path);
(file ) {
...(file, content);
} {
...(path, content);
}
}
(: ): [] {
folder = ...(folderPath);
(!(folder )) [];
folder..((f): f is => f );
}
(: ): [] {
...()
.( b.. - a..)
.(, limit);
}
(: ): | {
...(file);
}
(: ): <, > | {
.(file)?.;
}
() {
}
}
Step 6: Settings Architecture
export interface MyPluginSettings {
version: number;
defaultTemplate: string;
showStatusBar: boolean;
syncInterval: number;
}
export const DEFAULT_SETTINGS: MyPluginSettings = {
version: 1,
defaultTemplate: '## New Section\n\n',
showStatusBar: true,
syncInterval: 300,
};
import { App, PluginSettingTab, Setting } from 'obsidian';
import type MyPlugin from '../main';
export class MySettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Default template')
.setDesc('Content inserted by the Insert Template command')
.addTextArea(text => text
.setValue(this.plugin.settings.defaultTemplate)
.onChange(async (value) => {
... = value;
..();
}));
(containerEl)
.()
.()
.( toggle
.(...)
.( (value) => {
... = value;
..();
}));
(containerEl)
.()
.()
.( slider
.(, , )
.(...)
.()
.( (value) => {
... = value;
..();
}));
}
}
Step 7: CSS Architecture with Plugin-Scoped Classes
.my-plugin-sidebar {
padding: 8px 12px;
}
.my-plugin-sidebar h3 {
margin: 0 0 12px;
font-size: var(--font-ui-medium);
color: var(--text-normal);
}
.my-plugin-sidebar ul {
list-style: none;
padding: 0;
margin: 0;
}
.my-plugin-sidebar li {
padding: 4px 8px;
border-radius: var(--radius-s);
cursor: pointer;
color: var(--text-muted);
}
.my-plugin-sidebar li:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.my-plugin-modal .modal-content {
padding: 16px;
}
.my-plugin-highlight {
background: var(--background-modifier-success);
: (--text-on-accent);
: (--radius-s);
: ;
}
{
: ;
}
{
: ;
}
Key CSS rules:
- Prefix every class with your plugin id to avoid collisions
- Use Obsidian CSS variables (
--text-normal, --background-modifier-hover, etc.) for theme compatibility
- Use
.is-mobile for mobile-specific overrides
- Never use
!important — it breaks theme compatibility
Step 8: State Management Pattern
For plugins with complex state (multiple views, background sync, shared data):
import { Events } from 'obsidian';
interface PluginState {
isProcessing: boolean;
lastSync: number | null;
activeItems: string[];
}
export class StateService extends Events {
private state: PluginState = {
isProcessing: false,
lastSync: null,
activeItems: [],
};
get(): Readonly<PluginState> {
return this.state;
}
update(partial: Partial<PluginState>) {
Object.assign(this.state, partial);
this.trigger('state-changed', this.state);
}
}
Output
- Modular
src/ directory with separate folders for commands, views, settings, and services
- Thin
main.ts that wires components together without implementing business logic
- Command registry that scales to dozens of commands without cluttering main.ts
- View manager with proper lifecycle (onOpen/onClose) and leaf management
- Service layer abstracting vault operations behind a clean API
- Type-safe settings with defaults and migration support
- Plugin-scoped CSS using Obsidian's CSS variable system
Error Handling
| Issue | Cause | Solution |
|---|
| Circular dependencies | Services importing main, main importing services | Use type imports or interface segregation |
| Missing types at runtime | Interface-only imports | Create types.ts for shared interfaces |
| Event listener leaks | Direct addEventListener calls | Use this.registerEvent() for automatic cleanup |
| Settings lost on upgrade | No migration path | Version your settings interface (see obsidian-prod-checklist) |
| CSS conflicts with other plugins | Generic class names | Prefix all classes with your plugin id |
| View not restoring on restart | Missing registerView | Register in onload, Obsidian restores state from layout |
| Service holds stale references | No cleanup on unload | Call destroy() on services in onunload() |
Examples
Adding a New Command
import type MyPlugin from '../main';
export async function exportNotes(plugin: MyPlugin) {
const files = plugin.vaultService.getRecentFiles(50);
}
import { exportNotes } from './export-notes';
plugin.addCommand({
id: 'export-notes',
name: 'Export Recent Notes',
callback: () => exportNotes(plugin),
});
Adding a New Service
import { App, TFile, PreparedQuery, prepareQuery, fuzzySearch } from 'obsidian';
export class SearchService {
constructor(private app: App) {}
fuzzyFind(query: string): TFile[] {
const prepared: PreparedQuery = prepareQuery(query);
return this.app.vault.getMarkdownFiles()
.filter(f => fuzzySearch(prepared, f.basename)?.score !== undefined)
.sort((a, b) => {
const sa = fuzzySearch(prepared, a.basename)?.score ?? -Infinity;
const sb = fuzzySearch(prepared, b.basename)?.score ?? -Infinity;
return sb - sa;
});
}
}
Resources
Next Steps
SDK patterns: obsidian-sdk-patterns. Production readiness: obsidian-prod-checklist.