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, ApplicationFailure } from "@temporal-contract/worker/activity";
import { ResultAsync } from "neverthrow";
import { orderContract } from "contract-package";

export const activities = declareActivitiesHandler({
  contract: orderContract,
  activities: {
    sendEmail: ({ to, body }) =>
      ResultAsync.fromPromise(emailService.send({ to, body }), (error) =>
        ApplicationFailure.create({
          type: "EMAIL_FAILED",
          message: error instanceof Error ? error.message : "Failed to send email",
          cause: error instanceof Error ? error : undefined,
        }),
      ).map(() => ({ sent: true })),
    processPayment: ({ amount }) =>
      ResultAsync.fromPromise(paymentGateway.charge(amount), (error) =>
        ApplicationFailure.create({
          type: "PAYMENT_FAILED",
          message: error instanceof Error ? error.message : "Payment failed",
          cause: error instanceof Error ? error : undefined,
        }),
      ).map((txId) => ({ 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,
  activityOptions: { startToCloseTimeout: "1 minute" },
  implementation: async (context, args) => {
    const payment = await context.activities.processPayment({
      amount: 100,
    });

    await context.activities.sendEmail({
      to: "customer@example.com",
      body: `Order ${args.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 { orderContract } from "contract-package";
import { activities } from "./activities";

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

  // Activities loaded directly (handler is a flat object)
  activities,

  // Task queue from contract
  taskQueue: orderContract.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, Client } 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 Temporal client and type-safe client from contract
const temporalClient = new Client({ connection, namespace: "default" });
const client = TypedClient.create(orderContract, temporalClient);

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

// Unwrap the Result and wait for the workflow result
handleResult.match(
  async (handle) => {
    const result = await handle.result();
    result.match(
      (output) => console.log(output.success), // ✅ TypeScript knows the shape
      (error) => console.error("Workflow failed:", error),
    );
  },
  (error) => console.error("Failed to start workflow:", error),
);

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,
  taskQueue: orderContract.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 — must return ResultAsync<T, ApplicationFailure>
declareActivitiesHandler({
  contract,
  activities: {
    processPayment: ({ amount }) => {
      // ✅ amount is number
      return okAsync({ transactionId: "TXN-123" });
      // ✅ Must return ResultAsync<{ transactionId: string }, ApplicationFailure>
    },
  },
});

From Contract to Workflows

typescript
// Workflow context is typed from contract
declareWorkflow({
  workflowName: "processOrder",
  contract,
  activityOptions: { startToCloseTimeout: "1 minute" },
  implementation: async (context, args) => {
    // ✅ TypeScript knows processPayment exists
    const result = await context.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 (handler is a flat object)
const result = await 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
import { okAsync } from "neverthrow";

export const sharedActivities = {
  sendEmail: ({ to, body }) => okAsync({ sent: true }),
  logEvent: ({ event }) => okAsync({ logged: true }),
};

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

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

Pattern 2: Activity Composition

typescript
// activities/index.ts
import { okAsync } from "neverthrow";

const baseActivities = {
  validateInput: ({ data }) => okAsync({ valid: true }),
};

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

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

See Also

Released under the MIT License.