Inject a dependency
Use this pattern when a capability needs a database, model client, external API, or another dependency that should remain replaceable. The custom engine owns the interfaces. Concrete implementations enter through a factory at the composition root.
This recipe follows the repository’s support-engine example. It classifies a
support ticket with a repository, classifier, and permission checker.
Build the boundary
Section titled “Build the boundary”-
Declare engine-owned ports
Create
src/application/ports.ts:import type { Principal } from "@invokta/core";import type { Ticket, TicketClassification } from "../domain/ticket.js";export interface TicketRepository {findById(ticketId: string): Promise<Ticket | null>;}export interface TicketClassifier {classify(ticket: Ticket,options: { readonly signal: AbortSignal },): Promise<TicketClassification>;}export interface PermissionChecker {can(principal: Principal,permission: "ticket:classify",ticketId: string,): boolean | Promise<boolean>;}export interface SupportDependencies {readonly tickets: TicketRepository;readonly classifier: TicketClassifier;readonly permissions: PermissionChecker;}These interfaces describe what the capability needs. They do not expose a provider SDK or create a framework-wide dependency registry.
-
Capture dependencies in the capability factory
import { defineCapability, EngineError } from "@invokta/core";import { z } from "zod";import type { SupportDependencies } from "../application/ports.js";export function createClassifyTicket({tickets,classifier,permissions,}: SupportDependencies) {return defineCapability({description: "Classify a support ticket into an operational category.",input: z.object({ ticketId: z.string().trim().min(1) }),output: z.object({category: z.enum(["billing", "technical", "commercial", "other"]),confidence: z.number().min(0).max(1),rationale: z.string().min(1),}),access: async ({ principal, input }) =>principal !== null &&permissions.can(principal, "ticket:classify", input.ticketId),timeoutMs: 30_000,async run({ input, context }) {const ticket = await tickets.findById(input.ticketId);if (ticket === null) {throw new EngineError({code: "EXECUTION_FAILED",message: "Ticket not found.",publicDetails: { ticketId: input.ticketId },});}return classifier.classify(ticket, { signal: context.signal });},});}The closure makes the dependencies available to
accessandrun. The execution context stays limited to request state. -
Wire implementations at the composition root
export function createSupportEngine(dependencies: SupportDependencies) {return createEngine({name: "support-engine",version: "1.0.0",capabilities: {"support.classify-ticket": createClassifyTicket(dependencies),},});}export const engine = createSupportEngine({tickets: createInMemoryTicketRepository(seedTickets),classifier: createRuleBasedTicketClassifier(),permissions: createAttributePermissionChecker(),});A production composition can pass a database repository and a model-backed classifier without changing the capability schema or inbound adapters.
Run it
Section titled “Run it”From the repository root:
yarn buildnode examples/support-engine/dist/direct.jsThe deterministic adapters return:
{"category":"billing","confidence":0.9,"rationale":"The ticket contains language associated with billing."}Invoke the same capability through the CLI:
node examples/support-engine/dist/cli.js run support.classify-ticket \ --input '{"ticketId":"T-123"}'Verify it
Section titled “Verify it”The example tests dependency substitution, output validation, safe failures, cancellation, and the four inbound entry points:
yarn workspace @invokta/example-support testyarn workspace @invokta/example-support typecheckyarn workspace @invokta/example-support buildRead the complete
support-engine
example when you need the domain types and deterministic adapters used above.