⌘K
Development
Local development environment
Test
Test environment for QA
Production
Production environment
EventCatalog Acme Inc
Catalog Documentation Schemas
Browse
Domains Services External Systems Events Commands Queries Flows Data Stores Data Products
Organization
Teams Users

Settings
Creating new microservices

New TypeScript service

Guide to implementing microservices using TypeScript at FlowMart

This guide details the recommended patterns, practices, and tools for implementing TypeScript-based microservices at FlowMart.

Why TypeScript?

At FlowMart, we recommend TypeScript for new microservices because it offers:

  • Type Safety: Catch errors during development instead of at runtime
  • Better IDE Support: Enhanced auto-completion, navigation, and refactoring
  • Self-Documenting Code: Types serve as documentation for your codebase
  • Enterprise-Ready: Better maintainability for large codebases and teams
  • Ecosystem Compatibility: Full access to the Node.js ecosystem

Prerequisites

Before you begin:

  • Install Node.js (v18 or later)
  • Familiarize yourself with our Node.js service guide
  • Have basic TypeScript knowledge

Scaffolding a TypeScript Service

Use our service generator to create a TypeScript service:

Terminal window
# Install the FlowMart service generator
npm install -g @flowmart/service-generator
# Create a new TypeScript service
flowmart-create-service my-service --type typescript

TypeScript Project Structure

The generated service follows our standard TypeScript structure:

my-service/
├── src/
│ ├── api/ # API definition and controllers
│ ├── config/ # Configuration management
│ ├── domain/ # Domain models and business logic
│ │ ├── models/ # Domain entities and value objects
│ │ └── services/ # Domain services
│ ├── events/ # Event producers and consumers
│ ├── infrastructure/ # External dependencies and adapters
│ ├── repositories/ # Data access layer
│ ├── types/ # TypeScript type definitions
│ ├── utils/ # Utility functions
│ └── app.ts # Application entry point
├── test/
│ ├── unit/ # Unit tests
│ ├── integration/ # Integration tests
│ └── contract/ # Contract tests
├── terraform/ # Infrastructure as code
├── .github/ # GitHub Actions workflows
├── Dockerfile # Container definition
├── docker-compose.yml # Local development setup
├── tsconfig.json # TypeScript configuration
├── package.json # Dependencies and scripts
└── README.md # Service documentation

TypeScript Configuration

Our template includes a pre-configured tsconfig.json:

{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2020"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true,
"declaration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

Domain Modeling with TypeScript

TypeScript enables us to model our domain with strong types:

Example Domain Model

src/domain/models/product.ts
export enum ProductCategory {
ELECTRONICS = 'electronics',
CLOTHING = 'clothing',
GROCERY = 'grocery',
HOME = 'home',
BEAUTY = 'beauty'
}
export interface ProductAttributes {
[key: string]: string | number | boolean;
}
export class Product {
constructor(
public readonly id: string,
public name: string,
public summary: string,
public price: number,
public category: ProductCategory,
public sku: string,
public inventoryCount: number,
public attributes: ProductAttributes = {},
public isActive: boolean = true,
public createdAt: Date = new Date(),
public updatedAt: Date = new Date()
) {}
updateInventory(count: number): void {
if (count < 0) {
throw new Error('Inventory count cannot be negative');
}
this.inventoryCount = count;
this.updatedAt = new Date();
}
updatePrice(price: number): void {
if (price < 0) {
throw new Error('Price cannot be negative');
}
this.price = price;
this.updatedAt = new Date();
}
deactivate(): void {
this.isActive = false;
this.updatedAt = new Date();
}
activate(): void {
this.isActive = true;
this.updatedAt = new Date();
}
isInStock(): boolean {
return this.inventoryCount > 0;
}
}

Repository Pattern with TypeScript

Use interfaces to define repositories:

src/repositories/product-repository.ts
import { Product } from '../domain/models/product';
export interface ProductRepository {
findById(id: string): Promise<Product | null>;
findAll(limit?: number, offset?: number): Promise<Product[]>;
findByCategory(category: string, limit?: number, offset?: number): Promise<Product[]>;
save(product: Product): Promise<Product>;
update(product: Product): Promise<Product>;
delete(id: string): Promise<boolean>;
}
// src/repositories/mongodb-product-repository.ts
import { Collection, MongoClient, ObjectId } from 'mongodb';
import { Product, ProductCategory } from '../domain/models/product';
import { ProductRepository } from './product-repository';
import { logger } from '../infrastructure/observability';
export class MongoDBProductRepository implements ProductRepository {
private collection: Collection;
constructor(client: MongoClient) {
this.collection = client.db('ecommerce').collection('products');
}
async findById(id: string): Promise<Product | null> {
try {
const result = await this.collection.findOne({ _id: new ObjectId(id) });
if (!result) return null;
return this.mapToProduct(result);
} catch (error) {
logger.error('Error finding product by id', { error, id });
throw error;
}
}
async findAll(limit = 100, offset = 0): Promise<Product[]> {
try {
const results = await this.collection
.find({})
.skip(offset)
.limit(limit)
.toArray();
return results.map(this.mapToProduct);
} catch (error) {
logger.error('Error finding all products', { error });
throw error;
}
}
async findByCategory(category: string, limit = 100, offset = 0): Promise<Product[]> {
try {
const results = await this.collection
.find({ category })
.skip(offset)
.limit(limit)
.toArray();
return results.map(this.mapToProduct);
} catch (error) {
logger.error('Error finding products by category', { error, category });
throw error;
}
}
async save(product: Product): Promise<Product> {
try {
const productDoc = {
name: product.name,
summary: product.description,
price: product.price,
category: product.category,
sku: product.sku,
inventoryCount: product.inventoryCount,
attributes: product.attributes,
isActive: product.isActive,
createdAt: product.createdAt,
updatedAt: product.updatedAt
};
const result = await this.collection.insertOne(productDoc);
return {
...product,
id: result.insertedId.toString()
};
} catch (error) {
logger.error('Error saving product', { error, product });
throw error;
}
}
async update(product: Product): Promise<Product> {
try {
const result = await this.collection.updateOne(
{ _id: new ObjectId(product.id) },
{
$set: {
name: product.name,
summary: product.description,
price: product.price,
category: product.category,
sku: product.sku,
inventoryCount: product.inventoryCount,
attributes: product.attributes,
isActive: product.isActive,
updatedAt: product.updatedAt
}
}
);
if (result.matchedCount === 0) {
throw new Error(`Product with id ${product.id} not found`);
}
return product;
} catch (error) {
logger.error('Error updating product', { error, productId: product.id });
throw error;
}
}
async delete(id: string): Promise<boolean> {
try {
const result = await this.collection.deleteOne({ _id: new ObjectId(id) });
return result.deletedCount === 1;
} catch (error) {
logger.error('Error deleting product', { error, id });
throw error;
}
}
private mapToProduct(doc: any): Product {
return new Product(
doc._id.toString(),
doc.name,
doc.description,
doc.price,
doc.category as ProductCategory,
doc.sku,
doc.inventoryCount,
doc.attributes,
doc.isActive,
new Date(doc.createdAt),
new Date(doc.updatedAt)
);
}
}

Type-safe API Controllers

TypeScript enables type-safe API controllers with Express:

src/api/controllers/product-controller.ts
import { Router, Request, Response, NextFunction } from 'express';
import { ProductService } from '../../domain/services/product-service';
import { validateProduct } from '../middleware/product-validator';
import { tracing } from '../../infrastructure/observability';
import { Product, ProductCategory } from '../../domain/models/product';
export interface CreateProductRequest {
name: string;
summary: string;
price: number;
category: ProductCategory;
sku: string;
inventoryCount: number;
attributes?: { [key: string]: string | number | boolean };
}
export interface UpdateProductRequest {
name?: string;
description?: string;
price?: number;
category?: ProductCategory;
sku?: string;
inventoryCount?: number;
attributes?: { [key: string]: string | number | boolean };
isActive?: boolean;
}
export class ProductController {
private router: Router;
constructor(private productService: ProductService) {
this.router = Router();
this.setupRoutes();
}
private setupRoutes(): void {
this.router.get('/', tracing.middleware('get-all-products'), this.getAllProducts.bind(this));
this.router.get('/:id', tracing.middleware('get-product'), this.getProductById.bind(this));
this.router.post('/', tracing.middleware('create-product'), validateProduct, this.createProduct.bind(this));
this.router.put('/:id', tracing.middleware('update-product'), this.updateProduct.bind(this));
this.router.delete('/:id', tracing.middleware('delete-product'), this.deleteProduct.bind(this));
}
getRouter(): Router {
return this.router;
}
private async getAllProducts(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 100;
const offset = req.query.offset ? parseInt(req.query.offset as string, 10) : 0;
const products = await this.productService.getAllProducts(limit, offset);
res.json(products);
} catch (error) {
next(error);
}
}
private async getProductById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const product = await this.productService.getProductById(req.params.id);
if (!product) {
res.status(404).json({ error: 'Product not found' });
return;
}
res.json(product);
} catch (error) {
next(error);
}
}
private async createProduct(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const productData = req.body as CreateProductRequest;
const product = await this.productService.createProduct(productData);
res.status(201).json(product);
} catch (error) {
next(error);
}
}
private async updateProduct(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const productData = req.body as UpdateProductRequest;
const product = await this.productService.updateProduct(req.params.id, productData);
if (!product) {
res.status(404).json({ error: 'Product not found' });
return;
}
res.json(product);
} catch (error) {
next(error);
}
}
private async deleteProduct(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const success = await this.productService.deleteProduct(req.params.id);
if (!success) {
res.status(404).json({ error: 'Product not found' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
}
}

Strongly Typed Event Handling

Create strongly typed event producers and consumers:

src/events/types/event-types.ts
export interface EventMetadata {
eventId: string;
timestamp: string;
service: string;
correlationId?: string;
causationId?: string;
}
export interface Event<T> {
type: string;
data: T;
metadata: EventMetadata;
}
export interface ProductCreatedEvent extends Event<{
id: string;
name: string;
price: number;
category: string;
sku: string;
inventoryCount: number;
}> {
type: 'PRODUCT_CREATED';
}
export interface ProductUpdatedEvent extends Event<{
id: string;
changes: {
name?: string;
price?: number;
category?: string;
inventoryCount?: number;
};
}> {
type: 'PRODUCT_UPDATED';
}
export interface InventoryUpdatedEvent extends Event<{
productId: string;
quantity: number;
warehouseId: string;
}> {
type: 'INVENTORY_UPDATED';
}
// src/events/producers/product-event-producer.ts
import { v4 as uuidv4 } from 'uuid';
import { KafkaClient } from '../../infrastructure/kafka';
import { Product } from '../../domain/models/product';
import { ProductCreatedEvent, ProductUpdatedEvent } from '../types/event-types';
export class ProductEventProducer {
private readonly topic = 'product-events';
constructor(private kafkaClient: KafkaClient) {}
async productCreated(product: Product): Promise<void> {
const event: ProductCreatedEvent = {
type: 'PRODUCT_CREATED',
data: {
id: product.id,
name: product.name,
price: product.price,
category: product.category,
sku: product.sku,
inventoryCount: product.inventoryCount
},
metadata: {
eventId: uuidv4(),
timestamp: new Date().toISOString(),
service: 'product-service'
}
};
await this.kafkaClient.produce({
topic: this.topic,
key: product.id,
value: JSON.stringify(event)
});
}
async productUpdated(product: Product, changes: Partial<Product>): Promise<void> {
const event: ProductUpdatedEvent = {
type: 'PRODUCT_UPDATED',
data: {
id: product.id,
changes: {
name: changes.name,
price: changes.price,
category: changes.category,
inventoryCount: changes.inventoryCount
}
},
metadata: {
eventId: uuidv4(),
timestamp: new Date().toISOString(),
service: 'product-service'
}
};
await this.kafkaClient.produce({
topic: this.topic,
key: product.id,
value: JSON.stringify(event)
});
}
}

Dependency Injection with TypeScript

We use the inversify library for dependency injection:

src/infrastructure/ioc/container.ts
import { Container } from 'inversify';
import { MongoClient } from 'mongodb';
import { KafkaClient } from '../kafka';
import { ProductRepository } from '../../repositories/product-repository';
import { MongoDBProductRepository } from '../../repositories/mongodb-product-repository';
import { ProductService } from '../../domain/services/product-service';
import { ProductEventProducer } from '../../events/producers/product-event-producer';
import { ProductController } from '../../api/controllers/product-controller';
import { AppConfig } from '../../config/app-config';
import TYPES from './types';
const container = new Container();
// Config
container.bind<AppConfig>(TYPES.AppConfig).to(AppConfig).inSingletonScope();
// Infrastructure
container.bind<MongoClient>(TYPES.MongoClient).toDynamicValue((context) => {
const config = context.container.get<AppConfig>(TYPES.AppConfig);
return new MongoClient(config.mongoDbUri);
}).inSingletonScope();
container.bind<KafkaClient>(TYPES.KafkaClient).toDynamicValue((context) => {
const config = context.container.get<AppConfig>(TYPES.AppConfig);
return new KafkaClient(config.kafkaBrokers);
}).inSingletonScope();
// Repositories
container.bind<ProductRepository>(TYPES.ProductRepository).toDynamicValue((context) => {
const mongoClient = context.container.get<MongoClient>(TYPES.MongoClient);
return new MongoDBProductRepository(mongoClient);
}).inSingletonScope();
// Event Producers
container.bind<ProductEventProducer>(TYPES.ProductEventProducer).toDynamicValue((context) => {
const kafkaClient = context.container.get<KafkaClient>(TYPES.KafkaClient);
return new ProductEventProducer(kafkaClient);
}).inSingletonScope();
// Services
container.bind<ProductService>(TYPES.ProductService).toDynamicValue((context) => {
const repository = context.container.get<ProductRepository>(TYPES.ProductRepository);
const eventProducer = context.container.get<ProductEventProducer>(TYPES.ProductEventProducer);
return new ProductService(repository, eventProducer);
}).inSingletonScope();
// Controllers
container.bind<ProductController>(TYPES.ProductController).toDynamicValue((context) => {
const service = context.container.get<ProductService>(TYPES.ProductService);
return new ProductController(service);
}).inSingletonScope();
export default container;
// src/infrastructure/ioc/types.ts
const TYPES = {
AppConfig: Symbol.for('AppConfig'),
MongoClient: Symbol.for('MongoClient'),
KafkaClient: Symbol.for('KafkaClient'),
ProductRepository: Symbol.for('ProductRepository'),
ProductEventProducer: Symbol.for('ProductEventProducer'),
ProductService: Symbol.for('ProductService'),
ProductController: Symbol.for('ProductController')
};
export default TYPES;

Error Handling with TypeScript

Use custom error classes:

src/utils/errors.ts
export class AppError extends Error {
constructor(
public readonly message: string,
public readonly statusCode: number = 500,
public readonly code: string = 'INTERNAL_ERROR',
public readonly details?: any
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} with id ${id} not found`, 404, 'NOT_FOUND');
this.name = this.constructor.name;
}
}
export class ValidationError extends AppError {
constructor(message: string, details?: any) {
super(message, 400, 'VALIDATION_ERROR', details);
this.name = this.constructor.name;
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, 'CONFLICT_ERROR');
this.name = this.constructor.name;
}
}
export class AuthorizationError extends AppError {
constructor(message: string = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED');
this.name = this.constructor.name;
}
}
// src/api/middleware/error-handler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../../utils/errors';
import { logger } from '../../infrastructure/observability';
export function errorHandler(
error: Error,
req: Request,
res: Response,
next: NextFunction
): void {
logger.error('Request error', {
error: error.message,
stack: error.stack,
path: req.path,
method: req.method
});
if (error instanceof AppError) {
res.status(error.statusCode).json({
error: {
code: error.code,
message: error.message,
details: error.details
}
});
return;
}
res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred'
}
});
}

Testing TypeScript Services

We use Jest for testing TypeScript services:

test/unit/domain/services/product-service.test.ts
import { ProductService } from '../../../../src/domain/services/product-service';
import { Product, ProductCategory } from '../../../../src/domain/models/product';
import { NotFoundError } from '../../../../src/utils/errors';
describe('ProductService', () => {
const mockProduct = new Product(
'1',
'Test Product',
'Test Description',
9.99,
ProductCategory.ELECTRONICS,
'TEST-123',
100
);
const mockRepository = {
findById: jest.fn(),
findAll: jest.fn(),
findByCategory: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
const mockEventProducer = {
productCreated: jest.fn(),
productUpdated: jest.fn()
};
const productService = new ProductService(mockRepository, mockEventProducer);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getProductById', () => {
it('should return a product when found', async () => {
mockRepository.findById.mockResolvedValue(mockProduct);
const result = await productService.getProductById('1');
expect(result).toEqual(mockProduct);
expect(mockRepository.findById).toHaveBeenCalledWith('1');
});
it('should return null when product not found', async () => {
mockRepository.findById.mockResolvedValue(null);
const result = await productService.getProductById('999');
expect(result).toBeNull();
expect(mockRepository.findById).toHaveBeenCalledWith('999');
});
});
describe('createProduct', () => {
it('should create and return a new product', async () => {
const productData = {
name: 'New Product',
summary: 'New Description',
price: 19.99,
category: ProductCategory.CLOTHING,
sku: 'NEW-123',
inventoryCount: 50
};
mockRepository.save.mockImplementation(product => Promise.resolve(product));
const result = await productService.createProduct(productData);
expect(result).toMatchObject(productData);
expect(mockRepository.save).toHaveBeenCalledTimes(1);
expect(mockEventProducer.productCreated).toHaveBeenCalledWith(expect.objectContaining(productData));
});
});
describe('updateProduct', () => {
it('should update and return the product', async () => {
const changes = { price: 29.99, inventoryCount: 75 };
const updatedProduct = { ...mockProduct, ...changes };
mockRepository.findById.mockResolvedValue(mockProduct);
mockRepository.update.mockResolvedValue(updatedProduct);
const result = await productService.updateProduct('1', changes);
expect(result).toEqual(updatedProduct);
expect(mockRepository.findById).toHaveBeenCalledWith('1');
expect(mockRepository.update).toHaveBeenCalledWith(expect.objectContaining(changes));
expect(mockEventProducer.productUpdated).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining(changes)
);
});
it('should throw NotFoundError when product not found', async () => {
mockRepository.findById.mockResolvedValue(null);
await expect(productService.updateProduct('999', { price: 29.99 }))
.rejects
.toThrow(NotFoundError);
expect(mockRepository.update).not.toHaveBeenCalled();
expect(mockEventProducer.productUpdated).not.toHaveBeenCalled();
});
});
});

Building and Packaging TypeScript Services

Our template includes optimized build scripts:

// package.json (excerpt)
{
"scripts": {
"build": "tsc",
"start": "node dist/app.js",
"dev": "ts-node-dev --respawn --transpile-only src/app.ts",
"lint": "eslint src --ext .ts",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}

Dockerizing TypeScript Services

Our Docker setup uses multi-stage builds for optimal container size:

# Dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/app.js"]

CI/CD for TypeScript Services

Our CI/CD workflow ensures proper TypeScript builds:

# .github/workflows/main.yml (excerpt)
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test
build:
name: Build and Push
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- run: npm ci
- run: npm run build
# Then Docker build and push steps...

TypeScript Best Practices at FlowMart

  1. Use Interfaces for Public APIs: Define interfaces for repositories, services, and controllers.

  2. Embrace Type Inference: Let TypeScript infer types where it makes sense to reduce verbosity.

  3. Use Discriminated Unions: For handling different event types or command patterns.

  4. Leverage Utility Types: Use built-in utility types like Partial<T>, Readonly<T>, Pick<T>, etc.

  5. Strict Null Checks: Always enable strictNullChecks to prevent null/undefined errors.

  6. Immutability: Use readonly properties and const variables to enforce immutability.

  7. Error Handling: Use custom error classes with type information.

  8. Asynchronous Code: Use async/await consistently with proper error handling.

  9. Use Enums for Constants: Define related constants as TypeScript enums.

  10. Module Structure: Organize code into cohesive modules with clear interfaces.

Recommended Libraries

  • Express.js: Web framework
  • inversify: Dependency injection
  • zod: Runtime validation
  • winston: Logging
  • prom-client: Prometheus metrics
  • jaeger-client: Distributed tracing
  • mongodb: Database driver
  • kafkajs: Kafka client
  • jest: Testing framework
  • supertest: API testing
  • ts-node-dev: Development server

Next Steps

  • Learn about Database patterns for microservices
  • Understand Event schema design
  • Explore API design best practices
Previous Event Schema Design Next Getting Started with Event Storming

On this page

Why TypeScript? Prerequisites Scaffolding a TypeScript Service TypeScript Project Structure TypeScript Configuration Domain Modeling with TypeScript Example Domain Model Repository Pattern with TypeScript Type-safe API Controllers Strongly Typed Event Handling Dependency Injection with TypeScript Error Handling with TypeScript Testing TypeScript Services Building and Packaging TypeScript Services Dockerizing TypeScript Services CI/CD for TypeScript Services TypeScript Best Practices at FlowMart Recommended Libraries Next Steps

EventCatalog Backstage Integration

Missing license key for backstage integration.

Please configure the backstage plugin to embed this page into Backstage.

Configure backstage plugin →