Temporal has emerged as a powerful workflow orchestration engine that helps developers build reliable, scalable, and maintainable distributed systems. In this article, we'll walk through creating a well-structured Temporal application in TypeScript, following best practices for separation of concerns and maintainability.
Why Temporal?
Before diving into the code, it's worth understanding why Temporal is gaining traction in the developer community:
- Durability: Workflows survive server crashes, network failures, and process restarts
- Visibility: Built-in monitoring and debugging capabilities
- Scalability: Automatically handles retry logic, timeouts, and state management
- Developer Experience: Write workflows as regular code without worrying about infrastructure complexities
Project Structure
A clean architecture is crucial for maintainable Temporal applications. Here's the folder structure we'll implement:
src/
├── config/
│ └── temporal.ts
├── service/
│ └── workflowClient.ts
├── controller/
│ └── workflowController.ts
├── workflows.ts
├── activities.ts
└── worker.ts
This structure separates concerns effectively:
- config/: Handles connection setup and configuration
- service/: Wraps workflow operations with business logic
- controller/: Exposes API endpoints without exposing Temporal internals
- workflows.ts: Defines workflow logic
- activities.ts: Contains activity implementations
- worker.ts: Runs the Temporal worker independently
Step 1: Configuration Layer
Let's start by setting up the Temporal connection configuration:
// src/config/temporal.ts
import { Connection, Client } from '@temporalio/client';
export const temporalConfig = {
address: process.env.TEMPORAL_ADDRESS || 'localhost:7233',
namespace: process.env.TEMPORAL_NAMESPACE || 'default',
};
export async function createTemporalClient() {
const connection = await Connection.connect({
address: temporalConfig.address,
});
return new Client({
connection,
namespace: temporalConfig.namespace,
});
}
Key Benefits:
- Centralized configuration makes it easy to switch between environments (dev, staging, production)
- Environment variables allow flexible deployment configurations
- The
createTemporalClientfunction can be reused across the application
Step 2: Service Layer
The service layer abstracts Temporal-specific details and provides a clean interface for starting workflows:
// src/service/workflowClient.ts
import { createTemporalClient } from '../config/temporal';
export async function startWorkflow(workflowFn: any, options: any) {
const client = await createTemporalClient();
return client.workflow.start(workflowFn, {
workflowExecutionTimeout: '1 day', // global default
...options, // allow per-workflow overrides
});
}
Why This Matters:
- Sets sensible defaults (like a 1-day execution timeout)
- Allows individual workflows to override defaults when needed
- Keeps workflow initiation logic consistent across the application
- Makes it easy to add logging, metrics, or other cross-cutting concerns later
Step 3: Controller Layer
The controller exposes HTTP endpoints to trigger workflows without exposing Temporal implementation details:
// src/controller/workflowController.ts
import { startWorkflow } from '../service/workflowClient';
import { myWorkflow } from '../workflows';
export async function triggerWorkflow(req: any, res: any) {
try {
const handle = await startWorkflow(myWorkflow, {
taskQueue: process.env.TASK_QUEUE_NAME || 'my-queue',
workflowId: `workflow-${Date.now()}`,
// override timeout if needed:
// workflowExecutionTimeout: '2 days',
});
res.json({ workflowId: handle.workflowId });
} catch (err) {
res.status(500).json({ error: err.message });
}
}
Design Principles:
- Controllers remain unaware of Temporal internals
- Easy to swap out the underlying workflow engine if needed
- Clean error handling and response formatting
- Workflow IDs are generated systematically for tracking
Step 4: Workflow Definition
Workflows define the business logic and orchestrate activities:
// src/workflows.ts
import * as workflow from '@temporalio/workflow';
import type * as activities from './activities';
const { myActivity } = workflow.proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
retry: {
initialInterval: '1 second',
maximumInterval: '1 minute',
backoffCoefficient: 2,
},
});
export async function myWorkflow(): Promise<string> {
return await myActivity();
}
Important Concepts:
- proxyActivities: Creates type-safe proxies for activities
- startToCloseTimeout: Ensures activities don't run indefinitely
- Retry Policy: Automatic retries with exponential backoff handle transient failures
- Workflows are deterministic and replayable
Step 5: Worker Implementation
The worker executes workflows and activities:
// src/worker.ts
import { NativeConnection, Worker } from '@temporalio/worker';
import * as activities from './activities';
import { temporalConfig } from './config/temporal';
async function run() {
const connection = await NativeConnection.connect({
address: temporalConfig.address,
});
try {
const worker = await Worker.create({
connection,
namespace: temporalConfig.namespace,
taskQueue: process.env.TASK_QUEUE_NAME || 'my-queue',
workflowsPath: require.resolve('./workflows'),
activities,
});
await worker.run();
} finally {
await connection.close();
}
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
Worker Best Practices:
- Reuses the same configuration as the client
- Properly closes connections on shutdown
- Error handling ensures clean exits
- Can be scaled horizontally by running multiple workers
Key Architectural Benefits
1. Separation of Concerns
Each layer has a clear responsibility:
- Configuration manages connectivity
- Services handle business logic
- Controllers manage HTTP interactions
- Workers execute the actual work
2. Testability
- Each component can be tested in isolation
- Mock the Temporal client in service tests
- Test workflows and activities separately
3. Maintainability
- Changes to Temporal configuration only affect the config layer
- Adding new workflows doesn't require modifying existing code
- Clear boundaries make onboarding easier
4. Flexibility
- Easy to add middleware (logging, metrics, tracing)
- Simple to modify retry policies or timeouts
- Can integrate with existing Express/Fastify applications
Production Considerations
When deploying this architecture to production, consider:
- Monitoring: Integrate with tools like New Relic APM or Prometheus for visibility
- Logging: Add structured logging at each layer
- Security: Implement authentication and authorization in controllers
- Scaling: Run multiple workers across different machines or containers
- Environment Management: Use different namespaces for dev, staging, and production
Conclusion
This architecture provides a solid foundation for building Temporal-based applications in TypeScript. By separating concerns and following clean code principles, you create a system that's:
- Easy to understand: Clear boundaries between components
- Simple to extend: Add new workflows without modifying existing code
- Ready for production: Proper error handling and configuration management
- Maintainable: Changes are localized and predictable
Whether you're building appointment scheduling systems, order processing pipelines, or complex multi-step workflows, this structure will help you leverage Temporal's power while keeping your codebase clean and organized.
The key takeaway is that good architecture isn't just about making things work—it's about making them work well today and remain manageable tomorrow. With Temporal handling the complexity of distributed systems, you can focus on delivering business value through well-structured, maintainable code.
Top comments (0)