Documentation / Développement / Guides pratiques / Implement a port adapter

Implement a port adapter

Back a Meridian port with your own code so the engine, nodes and workflows talk to a different external system without any change to the business layer. An adapter is a factory (config) => PortImpl that a plugin contributes and the deployment's instance manifest selects by name.

This recipe assumes you already have a plugin (see Scaffold a plugin) and know the port you are backing. For the exact method signatures of each contract, keep Port contracts open alongside this page.

Before you start

  • Decide which port you implement. Every port contract lives with its owning plugin: patient (PatientProfilePort) and prescription (PrescriptionPort) are owned by @posos/clinical (@posos/clinical/ports), notification (NotificationPort) by @posos/notify (@posos/notify/ports). The SDK itself only types the ports the core calls — agent (AgentRunnerPort) and terminology (TerminologyPort) — plus the SubjectRecordPort platform capability. The bundle is open: you can also define an entirely new port (see Define a new port).
  • To back an existing port, import type its contract from the owning plugin (and declare that plugin in dependsOn); the import is erased at runtime.
  • There is no built-in fallback: a port that no instance selects simply does not exist. Your adapter only runs once an instance binds it.

1. Write the factory module

Create a module under adapters/ that default-exports a factory. The factory receives the instance-supplied config and returns an object implementing the port interface.

For a notification adapter that POSTs each outbound notification to a webhook:

// adapters/webhook.ts
import type { NotificationPort, OutboundNotification } from "@posos/notify/ports";

export default function webhookNotifier(config: Record<string, unknown> = {}): NotificationPort {
  const url = typeof config.url === "string" ? config.url : process.env.NOTIFY_WEBHOOK_URL;
  if (!url) throw new Error("[notify/webhook] `url` required (instance config or NOTIFY_WEBHOOK_URL).");
  const headers = (config.headers ?? {}) as Record<string, string>;
  const timeoutMs = Number(config.timeoutMs ?? 5000);

  return {
    async send(n: OutboundNotification): Promise<void> {
      const res = await fetch(url, {
        method: "POST",
        headers: { "content-type": "application/json", ...headers },
        body: JSON.stringify(n),
        signal: AbortSignal.timeout(timeoutMs),
      });
      if (!res.ok) throw new Error(`[notify/webhook] ${res.status} ${res.statusText} on POST ${url}`);
    },
  };
}

Rules that keep an adapter well-behaved:

  • Validate required config eagerly and throw a clear, prefixed error — a misconfigured instance must fail loudly at wiring time, not silently at runtime.
  • Fail loudly on downstream errors (non-2xx, unreachable host). Adapter calls run inside durable workflow steps; a thrown error lets the engine retry the step rather than swallow a "best effort" send.
  • Implement every required method of the interface. Optional members (e.g. PatientProfilePort.publishValidationRequest) may be left off — the platform tolerates their absence.
  • Reuse the owning plugin's pure helpers for filtered reads where they apply. A patient adapter's getMedications/getConditions/getObservations can delegate to filterMedications, filterConditions, filterObservations from @posos/clinical/domain so filter semantics match the reference in-memory adapter and the simulation sandbox.

2. Declare the adapter in your plugin manifest

Add an entry under contributes.adapters in plugin.yaml. Each entry names the port it backs, the selection name (unique per port), and the module:

contributes:
  adapters:
    - port: notification
      name: console
      module: ./adapters/console.ts
    - port: notification
      name: webhook
      module: ./adapters/webhook.ts

Optional fields:

  • export — the named export to use when the factory is not the default export (defaults to default).
  • config — default config baked into the plugin (the instance can override it).
  • systems — for terminology adapters, the code systems this resolver handles (informative; routing is per-system in the instance manifest).
  • record: { contextKind: <kind> } — declares the adapter the record port of a context kind (e.g. the patient adapters of @posos/clinical and @posos/fhir declare record: { contextKind: patient }). The implementation must satisfy SubjectRecordPort (SDK): the host then serves GET /api/records/<kind>/:id from its snapshot, and publishes human-validation requests to the record on suspension via its optional publishValidationRequest.

The full schema is in the Plugin manifest reference.

3. Build the plugin

Bundle the plugin so its adapter modules resolve at load time:

meridian-plugin build

This esbuild-bundles the plugin (there is no build-manifest.mjs). See Build and bundle for details, and Publish to the registry once you want it installable via meridian-plugin publish / MERIDIAN_REGISTRY.

4. Select the adapter from the instance manifest

An adapter does nothing until an instance selects it. In the instance manifest, bind the port to your adapter's name and pass its runtime config:

ports:
  notification:
    adapter: webhook
    config:
      url: https://hooks.example.org/notify
      headers: { Authorization: "Bearer …" }
      timeoutMs: 5000

buildPorts (apps/api/src/instance.ts) looks up the adapter by (port, name) across the loaded plugins and calls factory(config). Every port is bound by name and left undefined when the instance omits it; a behavior whose port is missing throws a configuration error naming the port at execution time (requirePort). For the operator's view, see Configure ports and adapters and the Instance manifest reference.

Define a new port

The port bundle is open — a plugin can introduce a port the platform has never heard of. Defining a port is the owning plugin's doctrine: the plugin that owns the port exports its contract, augments the SDK's open Ports interface, and (usually) ships the reference adapter and the simulation sandbox.

  1. Export a TypeScript contract from your plugin (an interface, e.g. in a ports.ts). This is the port's contract; your plugin owns it. Type it on your own domain model.

  2. Augment the SDK's Ports interface so your dependents get ports.<name> strongly typed from a plain type import:

    declare module "@meridian/shared/domain/ports" {
      interface Ports {
        "my-port"?: MyPort;
      }
    }
    
  3. Contribute an adapter for it exactly as above (port: <your-port-name>, a name, a module whose factory returns your contract). If the port is the record of a context kind, declare record: { contextKind } on the adapter.

  4. Consume it from a node behavior via ctx.ports. Types are erased at runtime, so guard the access with the SDK's requirePort:

    import { requirePort } from "@meridian/shared/domain/ports";
    const myPort = requirePort<MyPort>(ports, "my-port");
    

    It throws a clear configuration error naming the port when the instance does not bind it. See Implement a node behavior.

  5. Optionally contribute a simulation sandbox (contributes.portSandboxes, a factory (seed, record) => impl) so simulations can exercise the port from an opaque seed with captured writes — without one, the port is absent in simulation. See the simulation engine.

  6. The instance selects it under ports.<your-port-name> like any other.

Verify

  • Point an instance manifest at your adapter and start the instance (Configure ports and adapters). The API logs port lié (port bound) with the adapter name when the factory resolves.
  • Run a workflow whose nodes use the port. For a patient adapter, exercise the filtered reads and writes; for notification, trigger a notify.send sink and confirm the downstream channel receives it.
75 documents8 sectionssource : /docs · généré au build