Service Architecture
SkillDocs & knowledgeAn obsidian skill that guides your coding agent to build and wire services correctly in the ZotLit Obsidian plugin.
Use Service Architecture in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add Service Architecture and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the Service Architecture skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
About this skill
Service architecture patterns for the Obsidian plugin. Use when creating, modifying, or wiring services in apps/obsidian/src/services/ or editing zt-main.ts lifecycle code.
What this skill tells your AI
The instructions your AI receives, as published by aidenlx/zotlit in .agents/skills/obsidian-services/SKILL.md and read by ahel’s review.
Use the source as the canonical reference. Do not copy service/container/plugin-shell snippets out of this skill; read the current implementation before editing so examples cannot drift from real code.
Canonical Files
apps/obsidian/src/services/service-base.ts:Service(abstract base class),ServiceContainer, and other utils.apps/obsidian/src/services/build.ts:buildServiceswiring.apps/obsidian/src/zt-main.ts: plugin lifecycle ownership,await using,stack.move(), cleanup, and debug service access.apps/obsidian/src/lib/disposables.ts:Disposablehelpers forstack.use(...).
Workflow
- Open
apps/obsidian/src/services/service-base.tsfirst forService/ServiceContainer/ServiceInitErrordefinitions and JSDoc. Openapps/obsidian/src/services/build.tsto see howbuildServiceswires services throughcontainer.use(...). Treat both as authoritative. - Open
apps/obsidian/src/zt-main.tsbefore changing plugin load/unload behavior. Keep it a thin lifecycle shell. - For a new service, create
apps/obsidian/src/services/<service-name>/service.ts. - Co-locate the service deps interface with the service class. Use concrete class types via
import typefor upstream services. - Register the service in
buildServiceswith one keyed.use(...)entry. Let the accumulated service type come from the container chain. - Pass
plugin,plugin.app, upstream services, and optional deps explicitly through deps objects as needed. Avoid module-global service lookups. - Use
apps/obsidian/src/lib/disposables.tshelpers when adapting Obsidian or DOM registrations intoDisposablevalues forstack.use(...). - Run the Obsidian package typecheck after edits.
Service Rules
- Services
extend Servicefromservices/service-base.ts. The base class owns[Symbol.asyncDispose]and theawait ready→disposeAsyncordering; subclasses must not override[Symbol.asyncDispose]. Serviceis generic in thereadyresolve type. Use plainextends Servicefor startup-only services whosereadyresolves tovoid; useextends Service<State>when#load()returns loaded resources/state.- Do not introduce an Obsidian
Componentsubclass, a DI library, or any other runtime dependency for service wiring. - Constructors call
super(), store deps, and start startup by assigningready(typicallythis.ready = this.#load()). They must not synchronously acquire resources or throw after startup begins. - Registration factories should normally be direct constructor calls. Do not construct a service and then run fallible setup before returning it.
- Resource acquisition belongs in startup work guarded by a local
await using stack = new AsyncDisposableStack(), then handed to the base viathis.commit(stack.move())on the success path.commit()should be the last meaningful side effect before returning the ready state; avoid fallible work after commit.commit()throws on double-commit or commit-after-dispose; in those guard-failure paths it also fires offdisposeAsync()on the passed stack so resources don't leak through the throw (disposal errors there are intentionally swallowed). readyis startup-only and must always settle. Do not await long-lived/post-load signals such asworkspace.onLayoutReadyinsideready— disposal awaitsready, so a non-settlingreadyhangs cleanup.- Service startup waits only for upstream dependency
readypromises. There is no global readiness gate or scheduler. - Store deps as private fields instead of reaching through another service to its deps.
- Keep services constructable in isolation with plain mocked deps; lifecycle is driven through
readyand the base class disposer. - Sync-only services extend
Serviceand initializeready = Promise.resolve(). They do not callcommit()and do not need a constructor unless they have deps. - Avoid dependency cycles. If a lazy reference is unavoidable, do not await the lazy target during startup in a way that can create a mutual
readydependency.
Container Notes
ServiceContainer.use()accepts exactly one service entry and rejects duplicate keys.- The service key is the object property name in the registration entry.
- The container verifies factory return values with
instanceof Service; a non-Servicereturn is a typed error at registration time. - Startup failures are wrapped in
ServiceInitError(original error preserved ascause) and reported per service. The wrapped rejection — not the original — is what cascades to dependent services that await this service'sready. buildServices(plugin, stack)wires services only; lifecycle ownership remains with the caller's stack inzt-main.ts.
Plugin Shell Notes
- Keep
zt-main.tsfocused on lifecycle wiring, action/menu/view registration, and cleanup. - Use the
await usingplusstack.move()pattern already implemented there for rollback-safe startup. - Do not treat the plugin
servicesgetter as normal dependency access. It is an escape hatch/debug surface; services should receive deps throughbuildServices. - Wire Obsidian views by closure-capturing the needed services in the
registerViewfactory.
External Signals
Pass layout or UI readiness signals as deps when needed, but schedule post-load work instead of making ready wait on those signals. Disposal waits for ready, so a non-settling ready can hang cleanup.
Example: Service Subclass
The shape below shows the required surface of a Service subclass. It is a structural template, not a snippet to copy verbatim — always read existing services in apps/obsidian/src/services/ before authoring a new one, since real services carry their own dep types and resource patterns.
Async service with deps and acquired resources:
import { Service } from "../service-base";
import type { SettingsService } from "../settings/service";
interface DatabaseState {
conn: Connection;
}
interface DatabaseServiceDeps {
plugin: ZotLitPlugin;
settings: SettingsService;
}
export class DatabaseService extends Service<DatabaseState> {
readonly #plugin;
readonly #settings;
ready: Promise<DatabaseState>;
constructor(deps: DatabaseServiceDeps) {
super();
this.#plugin = deps.plugin;
this.#settings = deps.settings;
this.ready = this.#load();
}
async #load(): Promise<DatabaseState> {
await this.#settings.ready;
await using stack = new AsyncDisposableStack();
const conn = stack.adopt(
await openDatabase(),
async (conn) => await conn.close(),
);
await this.#runMigrations(conn);
this.commit(stack.move());
return { conn };
}
async query(sql: string): Promise<Result> {
const { conn } = await this.ready;
return conn.query(sql);
}
}
Sync-only service (no deps, no acquired resources):
export class TimeService extends Service {
ready = Promise.resolve();
now(): number {
return Date.now();
}
}
Sync-only service with deps:
interface ClockServiceDeps {
plugin: ZotLitPlugin;
}
export class ClockService extends Service {
readonly #plugin;
ready = Promise.resolve();
constructor(deps: ClockServiceDeps) {
super();
this.#plugin = deps.plugin;
}
}
Notes the shape encodes:
extends Service, neverimplements Service(no interface) and never a custom base.super()first in any explicit constructor.- Deps stored in
readonlyprivate (#) fields; no public dep fields, no reach-through. Omit the type annotation on the field — let TypeScript infer it from the constructor assignment (readonly #app;notreadonly #app: App;). readyis a mutable instance field. Declare it asready: Promise<State>and assign in the constructor when load is async and returns state; useready: Promise<void>for async startup with no state; initialize asready = Promise.resolve()when load is sync. Do not mark itreadonly— the container reassigns it to attachServiceInitErrorwrapping.- All resource acquisition lives inside
#load()under a localawait using stack, handed off withthis.commit(stack.move())only on the success path. Treatcommit()as the last meaningful side effect before returning the ready state. - Resources acquired during load are returned from
#load()as thereadyresolve value; accessors doconst { ... } = await this.ready;instead of storing nullable resource fields. - No
[Symbol.asyncDispose]in the subclass — the base owns it.
Signals
- GitHub stars
- 1k
- Forks
- 63
- Last commit
- Oct 2026
Advanced
- Item type
- skill
- Key
obsidian-services- Source
- github.com/aidenlx/zotlit
Related picks
Skill · mattpocock
The pick for TypeScripttypescript-pro
Skill · jeffallan
The pick for TypeScripthandoff
Skill · mattpocock
More in Docs & knowledgecanvas-design
Skill · anthropics
More in Docs & knowledgedoc-coauthoring
Skill · anthropics
More in Docs & knowledgespec-driven-development
Skill · addyosmani
More in Docs & knowledge