Skip to content

Standardised service instantiation - #34

Draft
ErisDS wants to merge 9 commits into
mainfrom
codex/service-instantiation-pattern
Draft

Standardised service instantiation#34
ErisDS wants to merge 9 commits into
mainfrom
codex/service-instantiation-pattern

Conversation

@ErisDS

@ErisDS ErisDS commented Jul 23, 2026

Copy link
Copy Markdown
Owner

What this demonstrates

This draft applies one service-instantiation contract across eight deliberately different Ghost services:

  • service logic receives stateful collaborators through construction
  • every composition root exports the same {init, service} shape
  • init() owns runtime wiring, is idempotent, and creates at most one intended runtime instance
  • service is a stable lazy facade with an actionable pre-init failure
  • service implementations receive typed dependencies rather than importing other composition roots
  • initialized-but-unconfigured behavior remains distinct from not initialized

The conversions cover wrapper singletons, a test-only factory, a deferred TypeScript export, a static optional instance, eager construction, a handed-onward repository, and an unmemoized per-consumer factory.

Minimal service pattern

Every service has the same public composition-root shape. The root owns the single runtime instance, resolves Ghost dependencies during init(), and exports a stable service facade:

// core/server/services/example-service/index.ts
import {lazySingleton} from '../../../shared/lazy-singleton';
import {ExampleService} from './example-service';

let instance: ExampleService | undefined;

export const service = lazySingleton('ExampleService', () => instance);

export function init(): void {
    if (instance) {
        return;
    }

    const settingsCache = require('../../../shared/settings-cache');
    const logging = require('@tryghost/logging');

    instance = new ExampleService({settingsCache, logging});
}

Boot owns initialization:

// core/boot.js
const exampleService = require('./server/services/example-service');

await exampleService.init();

Consumers use the stable facade:

// core/server/api/endpoints/example.js
const {service: exampleService} = require('../../services/example-service');

await exampleService.doSomething();

Every composition root exports {init, service}, even when no other module currently calls the service directly. This gives developers one pattern to learn and gives a future generator one exact shape to create. A later change could add a minimal boot loader that calls init() while leaving service construction inside each composition root.

The facade is not a container, registry, or service locator. It does not choose implementations, store a list of services, calculate boot order, or look services up by name. Service implementations continue to receive typed dependencies through construction.

Implementation notes

  • Added and directly tested a small lazySingleton facade that preserves method binding, property access, assignment, prototype behavior, and test stubbing.
  • Converted IndexNow, Slack, and Explore ping composition roots to the same {init, service} contract without changing when outbound requests are sent.
  • Removed gift-links consumer non-null assertions.
  • Preserved Tinybird's semantic null result when initialized without configuration.
  • Converted Donations to the same stable facade and passed that facade into Stripe without resolving the repository during module import. The existing dependency direction is unchanged.
  • Moved the Posts composition root to services/posts/index.js, removed its implementation export, and stopped returning its concrete instance from init().
  • Collapsed Posts to one initialized PostsService, PostsExporter, and PostStats instance after confirming they hold configuration dependencies rather than per-request state.
  • Kept the commits aligned with the review slices so the higher-risk Posts change can be evaluated independently.

Verification

  • pnpm build
  • Ghost Core lint and TypeScript typecheck
  • focused unit suites for the facade and all affected services
  • existing IndexNow, Slack, Explore, gift-links, Tinybird, announcement-bar, donations/Stripe, Posts, Posts export, and search-index tests
  • gift-links integration suite
  • legacy API/frontend behaviour suite that previously reproduced the Donations/Stripe import-order failure

The complete Ghost Core unit run passed 7,549 tests. Five unrelated tests failed locally: three gift-preview image tests timed out with Fontconfig unavailable, and two automations repository timestamp tests also failed when rerun alone.

This is a draft reference implementation for review. It is expected to change as the service-instantiation contract is discussed.

@ErisDS
ErisDS changed the base branch from main to codex/service-instantiation-base July 23, 2026 11:37
@github-actions

Copy link
Copy Markdown

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 30003783379 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

@ErisDS
ErisDS force-pushed the codex/service-instantiation-pattern branch from 5752bbb to cc38a7d Compare August 17, 2026 09:30
@ErisDS
ErisDS changed the base branch from codex/service-instantiation-base to main August 17, 2026 09:30
@github-actions

Copy link
Copy Markdown

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 32015574704 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

no ref

Give every converted composition root the same init and service exports so the proposed pattern is predictable and can be generated mechanically.
@github-actions

Copy link
Copy Markdown

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 32017958344 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

ErisDS added 3 commits August 17, 2026 11:58
no ref

Make every demonstrated root match the documented init and service contract, while deferring Donations access so importing Stripe before boot no longer violates initialization order.
no ref

Keep one initialized service instance while preserving runtime configuration changes used by the token endpoint and acceptance tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant