Skip to content

Entry Points Architecture

Understanding how temporal-contract handles the separation between activities and workflows.

Why Separate Entry Points?

Temporal requires workflows to be loaded via workflowsPath for sandboxing and determinism. This architectural decision means:

  1. Activities are loaded directly into the Worker
  2. Workflows are loaded from a separate file path

temporal-contract respects this architecture while providing type safety across both.

Architecture Overview

File Structure

Recommended project structure with separate contract package:

contract-package/           # Shared contract package
├── src/
│   ├── contract.ts        # Contract definition
│   ├── schemas.ts         # Domain schemas
│   └── index.ts          # Package exports
└── package.json          # Package configuration

worker-application/        # Worker implementation
├── src/
│   ├── activities/
│   │   └── index.ts      # Activities handler
│   ├── workflows/
│   │   └── order.workflow.ts  # Workflow implementations
│   └── worker.ts         # Worker setup
└── package.json          # Imports contract-package

client-application/        # Client (can be separate app)
├── src/
│   └── client.ts         # Client code
└── package.json          # Imports contract-package

Contract Definition

Define your contract in a separate package that can be shared:

typescript
// contract-package/src/contract.ts
import { defineContract } from "@temporal-contract/contract";
import { z } from "zod";

export const orderContract = defineContract({
  taskQueue: "orders",

  activities: {
    sendEmail: {
      input: z.object({ to: z.string(), body: z.string() }),
      output: z.object({ sent: z.boolean() }),
    },
  },

  workflows: {
    processOrder: {
      input: z.object({ orderId: z.string() }),
      output: z.object({ success: z.boolean() }),
      activities: {
        processPayment: {
          input: z.object({ amount: z.number() }),
          output: z.object({ transactionId: z.string() }),
        },
      },
    },
  },
});
typescript
// contract-package/src/index.ts
export { orderContract } from "./contract.js";
// Export schemas and types as needed

Activities Entry Point

Create a single activities handler in your worker application:

typescript
// worker-application/src/activities/index.ts
import { declareActivitiesHandler } from "@temporal-contract/worker/activity";
import { orderContract } from "contract-package";

export const activities = declareActivitiesHandler({
  contract: orderContract,
  activities: {
    sendEmail: async ({ to, body }) => {
      await emailService.send({ to, body });
      return { sent: true };
    },
    processPayment: async ({ amount }) => {
      const txId = await paymentGateway.charge(amount);
      return { transactionId: txId };
    },
  },
});

Workflows Entry Point

Create separate workflow files in your worker application:

typescript
// worker-application/src/workflows/order.workflow.ts
import { declareWorkflow } from "@temporal-contract/worker/workflow";
import { orderContract } from "contract-package";

export const processOrder = declareWorkflow({
  workflowName: "processOrder",
  contract: orderContract,
  implementation: async ({ activities }, { orderId }) => {
    const payment = await activities.processPayment({
      amount: 100,
    });

    await activities.sendEmail({
      to: "customer@example.com",
      body: `Order ${orderId} processed`,
    });

    return { success: true };
  },
});

Worker Setup

Wire everything together in your worker application:

typescript
// worker-application/src/worker.ts
import { Worker } from "@temporalio/worker";
import { activities } from "./activities";

const worker = await Worker.create({
  // Workflows loaded from path (Temporal requirement)
  workflowsPath: require.resolve("./workflows/order.workflow"),

  // Activities loaded directly
  activities: activities.activities,

  // Task queue from contract
  taskQueue: activities.contract.taskQueue,
});

await worker.run();

Client Application

Use the contract in a separate client application (can be in a different codebase):

typescript
// client-application/src/client.ts
import { Connection } from "@temporalio/client";
import { TypedClient } from "@temporal-contract/client";
import { orderContract } from "contract-package";

// Connect to Temporal server
const connection = await Connection.connect({
  address: "localhost:7233",
});

// Create type-safe client from contract
const client = TypedClient.create(orderContract, {
  connection,
  namespace: "default",
});

// Start workflow with full type safety
const handle = await client.startWorkflow("processOrder", {
  workflowId: "order-123",
  args: { orderId: "ORD-123" }, // ✅ Type-checked!
});

// Wait for result (also type-checked)
const result = await handle.result();
console.log(result.success); // ✅ TypeScript knows the shape

Multiple Workflows

For multiple workflows, export them all from a single file:

typescript
// workflows/index.ts
export * from "./order.workflow";
export * from "./shipment.workflow";
export * from "./refund.workflow";
typescript
// worker.ts
const worker = await Worker.create({
  workflowsPath: require.resolve("./workflows"),
  activities: activities.activities,
  taskQueue: activities.contract.taskQueue,
});

Type Safety Across Boundaries

Despite the separation, type safety is maintained:

From Contract to Activities

typescript
// Contract defines the shape
const contract = defineContract({
  activities: {
    processPayment: {
      input: z.object({ amount: z.number() }),
      output: z.object({ transactionId: z.string() }),
    },
  },
});

// Activities handler must match
declareActivitiesHandler({
  contract,
  activities: {
    processPayment: async ({ amount }) => {
      // ✅ amount is number
      return { transactionId: "TXN-123" };
      // ✅ Must return { transactionId: string }
    },
  },
});

From Contract to Workflows

typescript
// Workflow context is typed from contract
declareWorkflow({
  workflowName: "processOrder",
  contract,
  implementation: async ({ activities }, input) => {
    // ✅ TypeScript knows processPayment exists
    const result = await activities.processPayment({
      amount: 100, // ✅ Type checked
    });

    // ✅ result.transactionId is string
    console.log(result.transactionId);
  },
});

Benefits of This Architecture

1. Contract Reusability

The contract package can be imported by multiple applications:

  • Worker application (to implement)
  • Client applications (to consume)
  • Even other services that need the type definitions

2. Type Safety

Full TypeScript inference across all boundaries and applications.

3. Validation

Automatic validation at:

  • Workflow entry/exit
  • Activity calls
  • Client requests

4. Temporal Compliance

Respects Temporal's architecture requirements.

5. Independent Deployment

  • Contract package can be versioned independently
  • Client applications can be in different codebases
  • Workers and clients can be deployed separately

6. Testability

Activities and workflows can be tested independently:

typescript
// Test activities directly
const result = await activities.activities.processPayment({
  amount: 100,
});

// Test workflows with mock context
const workflow = processOrder.implementation;
const mockContext = createMockContext();
await workflow(mockContext, { orderId: "ORD-123" });

7. Organization

Clear separation of concerns:

  • Contract package: API definition
  • Worker application: Implementation
  • Client application: Consumption

Common Patterns

Pattern 1: Shared Activities

typescript
// activities/shared.ts
export const sharedActivities = {
  sendEmail: async ({ to, body }) => ({ sent: true }),
  logEvent: async ({ event }) => ({ logged: true }),
};

// activities/order.ts
import { sharedActivities } from "./shared";

export const orderActivities = declareActivitiesHandler({
  contract: orderContract,
  activities: {
    ...sharedActivities,
    processPayment: async ({ amount }) => ({ transactionId: "TXN" }),
  },
});

Pattern 2: Activity Composition

typescript
// activities/index.ts
const baseActivities = {
  validateInput: async ({ data }) => ({ valid: true }),
};

const paymentActivities = {
  processPayment: async ({ amount }) => ({ transactionId: "TXN" }),
};

export const activities = declareActivitiesHandler({
  contract: orderContract,
  activities: {
    ...baseActivities,
    ...paymentActivities,
  },
});

See Also

Released under the MIT License.