webapp/docs/nostr-patterns/services-and-di.md
Padreug 8303b0981b docs(nostr): add reusable Nostr patterns reference
Living reference at docs/nostr-patterns/ that future Claude Code sessions
(per memory directive) and human contributors must consult before writing
Nostr code, and update when implementing or refining patterns.

Six topic files covering 18 patterns harvested from existing modules
(activities, base, forum, market, chat, tasks, nostr-feed):

- subscriptions.md   — RelayHub lifecycle, EOSE, visibility-aware
                       reconnect, per-event-id dedup
- replaceable-events.md — monotonic created_at, per-pubkey latest-wins,
                          replaceable-list rewrite, Vue 3 nested ref<Map>
                          reactivity gotcha
- publishing.md      — result.success > 0 checks, optimistic-on-success,
                       pending-coord debounce, finalizeEvent with bytes
- reactions-and-deletions.md — NIP-25 toggle-as-delete, NIP-09 pubkey
                                check, dedup-before-mutate
- profiles.md        — kind-0 batch fetch with request dedup,
                       unsubscribe-on-EOSE for snapshot fetches
- services-and-di.md — BaseService lifecycle, injectService vs
                       tryInjectService, expose state via getters

Each pattern points at a canonical implementation (file:line) and notes
the *why* behind each pattern so a new caller doesn't trip on the same
edge case the canonical implementation already learned about.

Recurring deep-dive issue (#42) tracks mining patterns from Coracle,
Snort, NoStrudel, Damus, Habla, Highlighter, Flotilla, Zap.cooking, NDK
that we haven't reinvented yet — findings land in this directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:24:26 +02:00

4 KiB

Services, DI & reactivity

BaseService lifecycle: initialize / onInitialize / onDestroy

Canonical: src/core/base/BaseService.ts (the abstract class) + src/modules/base/nostr/ReactionService.ts (a representative concrete service).

  • Constructor: do nothing async, register no subscriptions. Just stash config.
  • onInitialize() (override): grab dependencies via injectService(...), open relay subscriptions, set up timers.
  • onDestroy(): unsubscribe everything, clear caches. Anything you started in onInitialize you tear down here.

Why not in the constructor: services are constructed before all dependencies are registered; injectService(SOME_TOKEN) from the constructor will throw or return undefined non-deterministically depending on registration order. onInitialize runs after the DI container is ready.

Always call super.initialize() if you override initialize() instead of onInitialize(). Most concrete services override only onInitialize and leave initialize() alone.

injectService vs tryInjectService

Canonical: src/core/di-container.tsSERVICE_TOKENS registry + both inject helpers.

  • injectService<T>(TOKEN): throws if not registered. Use in services that require the dependency to function.
  • tryInjectService<T>(TOKEN): returns undefined if not registered. Use in composables and components that should degrade gracefully (e.g., rendering an offline empty state when RelayHub isn't up yet).

The composable in useRSVP.ts uses tryInjectService(RELAY_HUB) and returns null from setRSVP if the hub is missing. That's the right call for user-facing buttons: don't crash, just no-op. A service that needs the hub to do its job (ReactionService.initialize) uses the throwing variant so the failure is loud at startup.

Expose service state via getters / computed, never as mutable refs

Canonical: src/modules/base/nostr/ReactionService.tseventReactions getter, isLoading getter; src/modules/base/nostr/ProfileService.tsprofiles getter.

class ReactionService extends BaseService {
  private _state = reactive(new Map<>())  // private, mutable
  get eventReactions() { return this._state }  // public, read-only by convention
  
  // Mutation through guarded methods only
  async likeEvent(...) {  }
  async unlikeEvent(...) {  }
}

The Vue reactivity stays correct because we hand out the same reactive object — components reading service.eventReactions get a reactive reference. They're trusted not to mutate it; the type system can enforce that with readonly modifiers if you want to be strict.

Why not a public field: anything mutating the cache from outside the service can produce events that don't get published, or skip recalculateEventReactions. Keep mutation paths inside the service so the invariants are enforced in one place.

Vue 3 nested ref<Map> reactivity gotcha

Covered fully in replaceable-events.md → Vue 3 reactivity for nested ref-Map. Cross-linked here because it bites every service that exposes a Map<key1, Map<key2, value>> shape (not common, but real — RSVPs are the main one).

The reactive(new Map()) route avoids the gotcha because reactive recursively wraps. ref(new Map()) doesn't. Either is fine for top-level; prefer reactive if you have nesting.

Service singletons, cleanup is real

Services are registered once per app lifetime. There's no "destroy on component unmount" — components come and go, the service persists.

This means: any subscription a service opens must be cleaned up in onDestroy(), which only runs at app teardown. Don't open per-component subscriptions from a service — that's the composable's job (with onUnmounted cleanup). Service-level subscriptions are session-long.

If you find yourself wanting a "subscribe to X just for this view" pattern inside a service, you've crossed the layer boundary. Move it to a composable that consumes the service.