Skip to content

Constructor Injection

Photon uses a single mechanism for all dependency injection: constructor parameters. The runtime inspects each parameter and determines what to inject based on its type and matching docblock declarations.

Injection Types

typescript
/**
 * DevOps Dashboard
 * @mcp github anthropics/mcp-server-github
 * @photon billing billing-photon
 * @stateful true
 */
export default class Dashboard {
  constructor(
    apiKey: string,                  // 1. Environment variable
    private github: any,             // 2. MCP client (matches @mcp github)
    private billing: any,            // 3. Photon instance (matches @photon billing)
    private incidents: Incident[] = [] // 4. Persisted state (restored on restart)
  ) {}
}
#TypeTriggerManaged bySource
1Constructor envPrimitive constructor paramExisting env-var mapping, captured by loaderCurrent process.env, then .data/{ns}/{photon}/env.json
2Constructor env with defaultPrimitive constructor param with defaultSame mapping; default applies when unsetCurrent process.env, then captured .data value
3MCP clientName matches @mcp declarationRuntimeProxy to running MCP server
4Photon instanceName matches @photon declarationRuntimeLoaded photon class instance
5Persisted stateNon-primitive with default, on @stateful photonRuntime.data/{ns}/{photon}/state/

See CONSTRUCTOR-CONTEXT.md for full details on constructor env capture and context-based state partitioning for @stateful photons.

Resolution Order

For each constructor parameter, the runtime resolves in this order:

  1. Matches @mcp tag? → Create/reuse MCP client proxy
  2. Matches @photon tag? → Load/reuse photon instance
  3. Primitive constructor param? → Current process.env when present and captured, otherwise stored value from .data by param/env-style key
  4. Non-primitive with default on @stateful? → Restore from state snapshot
  5. Fallbackundefined (constructor default applies)

1. Environment Variables

Primitive constructor parameters are automatically mapped to environment variable names. When a value is present in the caller environment, Photon captures it into the runtime-owned .data store so daemon restarts and scheduled work can replay the same constructor input.

typescript
export default class Mailer {
  constructor(
    private smtpHost: string = 'localhost',
    private smtpPort: number = 587,
    private useTls: boolean = true
  ) {}
}
ParameterEnvironment VariableConversion
smtpHostMAILER_SMTP_HOSTString (as-is)
smtpPortMAILER_SMTP_PORTNumber("587")587
useTlsMAILER_USE_TLS"true"true

If the env var is not set but a captured value exists, the captured value is used. If neither exists and the parameter has a default, the default applies. If neither exists and the parameter is required, the runtime reports a configuration error.


2. MCP Client Injection

Declare MCP dependencies with @mcp and receive ready-to-use client proxies.

typescript
/**
 * @mcp github anthropics/mcp-server-github
 * @mcp filesystem npm:@modelcontextprotocol/server-filesystem
 */
export default class Manager {
  constructor(
    private github: any,     // Injected: proxy to GitHub MCP
    private filesystem: any  // Injected: proxy to Filesystem MCP
  ) {}

  async listRepos() {
    return await this.github.list_repos({ org: 'my-org' });
  }

  async readFile(path: string) {
    return await this.filesystem.read_file({ path });
  }
}

The injected proxy supports:

  • client.<tool>(params) — Call any tool directly
  • client.list() — List available tools
  • client.find(query) — Search tools by name
  • client.call(name, params) — Call tool by string name

MCP clients are cached and reused across the photon's lifetime.

Source Types

FormatExampleDescription
GitHubanthropics/mcp-server-githubCloned and run via bunx
npmnpm:@modelcontextprotocol/server-filesystemInstalled from npm
URLhttps://api.example.com/mcpRemote Streamable HTTP
Local./my-local-mcpLocal file path

3. Photon Instance Injection

Declare photon dependencies with @photon and receive initialized instances.

typescript
/**
 * @photon rss rss-feed
 * @photon weather ./weather.photon.ts
 */
export default class NewsDigest {
  constructor(
    private rss: any,     // Injected: loaded rss-feed photon instance
    private weather: any  // Injected: loaded local weather photon instance
  ) {}

  async digest() {
    const articles = await this.rss.fetch({ url: 'https://news.ycombinator.com/rss' });
    const forecast = await this.weather.today();
    return { articles, forecast };
  }
}

Photon dependencies are resolved recursively — an injected photon can itself have dependencies that get injected.

Source Types

FormatExampleDescription
Marketplacerss-feedInstalled from photon marketplace
GitHubportel-dev/photons/rss-feedCloned from GitHub
npmnpm:@portel/rss-feed-photonInstalled from npm
Local./weather.photon.tsLocal file path

4. Stateful Persistence

When a photon is marked @stateful true, non-primitive constructor parameters with defaults become automatically persisted. The runtime snapshots their values on every mutation and restores them on restart.

How It Works

typescript
/**
 * A simple list
 * @stateful true
 */
export default class List {
  items: string[];

  constructor(items: string[] = []) {
    this.items = items;
  }

  add(item: string): void {
    this.items.push(item);
  }

  remove(item: string): boolean {
    const idx = this.items.indexOf(item);
    if (idx !== -1) {
      this.items.splice(idx, 1);
      return true;
    }
    return false;
  }

  getAll(): string[] {
    return this.items;
  }
}

First run:

  1. No snapshot exists → new List() → default items = [] applies
  2. User calls add("apples") → reactive array detects .push()
  3. Runtime persists { "items": ["apples"] } to ~/.photon/.data/list/state/default/state.json

Daemon restarts:

  1. Runtime reads ~/.photon/.data/list/state/default/state.json{ "items": ["apples"] }
  2. Instantiates new List(["apples"]) — constructor default overridden
  3. State is fully restored, user sees their data

Ongoing:

  • Every mutation to items triggers a debounced persist
  • The same mutation also emits events for auto-UI (via reactive array)
  • Persistence is a side-effect of reactivity, not a separate mechanism

Why Constructor Injection

The constructor already serves as the dependency injection point for env vars, MCPs, and photons. Adding persisted state to the same mechanism means:

  • No new API — constructors with defaults are standard TypeScript
  • No @persist tag@stateful true already declares the intent
  • Testable — pass mock data to the constructor in tests
  • Explicit — the constructor signature documents what state the photon holds

Sync Methods Work

Reactive arrays work with synchronous methods. The layers are:

items.push('hello')           ← sync, in your method
  → Proxy intercepts push()   ← sync, reactive array
  → emitter('items:added')    ← sync, queues the event

  daemon broadcasts via SSE    ← async, runtime handles it
  runtime persists to disk     ← async, debounced, runtime handles it

Your method is sync, returns void, finishes immediately. The reactive machinery captures the mutation synchronously, but network delivery (SSE to auto-UI) and disk persistence happen asynchronously in the runtime — the photon never needs to know or wait.

Shared State Across Clients

Because @stateful photons live in the daemon, all clients share the same instance:

CLI: photon cli list add --item "apples"


              Daemon instance ← single List instance


Beam auto-UI sees 'items:added' event → re-renders

Add from CLI → see it in Beam. Add from Beam → see it in CLI. One instance, multiple clients.


Combining All Four

A real-world photon might use all four injection types:

typescript
/**
 * Incident tracker
 * @mcp slack anthropics/mcp-server-slack
 * @photon pagerduty pagerduty-photon
 * @stateful true
 */
export default class IncidentTracker {
  incidents: Incident[];

  constructor(
    private webhookUrl: string,            // ENV: INCIDENTTRACKER_WEBHOOK_URL
    private slack: any,                    // MCP: Slack client proxy
    private pagerduty: any,               // Photon: PagerDuty instance
    incidents: Incident[] = []            // State: restored from snapshot
  ) {
    this.incidents = incidents;
  }

  report(title: string, severity: string): Incident {
    const incident = { id: crypto.randomUUID(), title, severity, status: 'open' };
    this.incidents.push(incident);
    // Reactive array: auto-emits 'incidents:added'
    // Runtime: auto-persists to disk
    return incident;
  }

  async escalate(id: string) {
    const incident = this.incidents.find(i => i.id === id);
    await this.slack.send_message({ channel: '#incidents', text: `Escalating: ${incident.title}` });
    await this.pagerduty.trigger({ service: 'backend', description: incident.title });
    return { escalated: true };
  }
}

The runtime resolves each parameter independently:

  1. webhookUrl — primitive string → reads INCIDENTTRACKER_WEBHOOK_URL env var
  2. slack — matches @mcp slack → creates Slack MCP client proxy
  3. pagerduty — matches @photon pagerduty → loads pagerduty photon instance
  4. incidents — non-primitive with default on @stateful → restores from snapshot

Released under the MIT License.