Service Architecture
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.