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

94 lines
4 KiB
Markdown

# 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.ts``SERVICE_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.ts``eventReactions`
getter, `isLoading` getter; `src/modules/base/nostr/ProfileService.ts`
`profiles` getter.
```ts
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](./replaceable-events.md#vue-3-reactivity-for-nested-refmap).
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.