typescript-development
Helps build and extend TypeScript Express APIs using Clean Architecture, inversify dependency injection, Prisma ORM, and Railway deployment patterns established in the upkeep-io project.
When & Why to Use This Skill
This Claude skill is a specialized development assistant for building scalable TypeScript Express APIs. It enforces industry-standard Clean Architecture principles and Inversify dependency injection to ensure code maintainability and testability. By leveraging Prisma ORM and Zod for shared validation, it streamlines the development of robust backend services within monorepo environments, providing a clear 8-step workflow from domain definition to production deployment on Railway.
Use Cases
- Architecting New Features: Automatically generate the boilerplate for new API endpoints following Clean Architecture layers, including entities, use cases, and controllers.
- Monorepo Schema Sharing: Implementing shared Zod validation schemas across frontend and backend to ensure data consistency and reduce redundant code.
- Dependency Injection Setup: Configuring InversifyJS containers to manage complex service dependencies and improve unit testing through repository mocking.
- Database Layer Implementation: Designing Prisma repositories with type-safe transformations to bridge the gap between database models and domain entities.
- Production Deployment Optimization: Setting up structured JSON logging with Pino and configuring Railway-specific environment variables and health checks for cloud readiness.
| name | typescript-development |
|---|---|
| description | Helps build and extend TypeScript Express APIs using Clean Architecture, inversify dependency injection, Prisma ORM, and Railway deployment patterns established in the upkeep-io project. |
TypeScript Development
Research Protocol
MANDATORY: Follow the research protocol in @shared/research-protocol.md before implementing backend features.
When to Research
You MUST use mcp__Ref__ref_search_documentation before:
- Using Prisma features you haven't verified this session
- Implementing inversify patterns
- Using Express middleware patterns
- Making Zod validation decisions
- Advising on JWT or authentication patterns
Never assume training data reflects current library versions. When in doubt, verify.
Project Context
This is a monorepo property management system with shared libraries:
upkeep-io/
├── apps/
│ ├── backend/ # Node/Express API (CommonJS)
│ └── frontend/ # Vue 3 SPA (ES Modules)
└── libs/ # Shared libraries
├── domain/ # Entities, errors (Property, MaintenanceWork, User)
├── validators/ # Zod schemas (shared validation)
└── auth/ # JWT utilities
Key Principle: Backend and frontend share validation schemas and domain entities from libs/ for maximum code reuse.
Capabilities
- Build new features following Clean Architecture with inversify DI
- Implement JWT + bcrypt authentication
- Create comprehensive unit tests with mocked repositories
- Set up production logging for Railway deployment
- Configure Prisma repositories with type transformations
- Implement shared validation using Zod schemas
Creating a New Feature
Follow this 8-step workflow that matches the actual project structure:
1. Define Domain Entity (if needed)
// libs/domain/src/entities/Resource.ts
export interface CreateResourceData {
userId: string;
name: string;
description?: string;
}
export interface Resource extends CreateResourceData {
id: string;
createdAt: Date;
updatedAt: Date;
}
2. Create Validation Schema
// libs/validators/src/resource.ts
import { z } from 'zod';
export const createResourceSchema = z.object({
userId: z.string().uuid(),
name: z.string().min(1).max(255),
description: z.string().max(1000).optional()
});
export type CreateResourceInput = z.infer<typeof createResourceSchema>;
3. Create Repository Interface
// apps/backend/src/domain/repositories/IResourceRepository.ts
import { Resource, CreateResourceData } from '@domain/entities';
export interface IResourceRepository {
create(data: CreateResourceData): Promise<Resource>;
findById(id: string): Promise<Resource | null>;
findByUserId(userId: string): Promise<Resource[]>;
update(id: string, data: Partial<Resource>): Promise<Resource>;
delete(id: string): Promise<void>;
}
4. Implement Use Case
// apps/backend/src/application/resource/CreateResourceUseCase.ts
import { injectable, inject } from 'inversify';
import { IResourceRepository } from '../../domain/repositories';
import { ILogger } from '../../domain/services';
import { ValidationError } from '@domain/errors';
import { createResourceSchema } from '@validators/resource';
import { Resource } from '@domain/entities';
interface CreateResourceInput {
userId: string;
name: string;
description?: string;
}
@injectable()
export class CreateResourceUseCase {
constructor(
@inject('IResourceRepository') private repository: IResourceRepository,
@inject('ILogger') private logger: ILogger
) {}
async execute(input: CreateResourceInput): Promise<Resource> {
// Validate with shared schema
const validation = createResourceSchema.safeParse(input);
if (!validation.success) {
throw new ValidationError(validation.error.errors[0].message);
}
// Execute business logic
const resource = await this.repository.create(validation.data);
this.logger.info('Resource created', { resourceId: resource.id, userId: input.userId });
return resource;
}
}
5. Create Prisma Repository
// apps/backend/src/infrastructure/repositories/PrismaResourceRepository.ts
import { injectable } from 'inversify';
import { PrismaClient } from '@prisma/client';
import { IResourceRepository } from '../../domain/repositories';
import { Resource, CreateResourceData } from '@domain/entities';
@injectable()
export class PrismaResourceRepository implements IResourceRepository {
private prisma: PrismaClient;
constructor() {
this.prisma = new PrismaClient();
}
async create(data: CreateResourceData): Promise<Resource> {
const result = await this.prisma.resource.create({ data });
// Transform Prisma nulls to undefined for domain entity
return {
...result,
description: result.description ?? undefined
};
}
async findById(id: string): Promise<Resource | null> {
const result = await this.prisma.resource.findUnique({ where: { id } });
if (!result) return null;
return {
...result,
description: result.description ?? undefined
};
}
async findByUserId(userId: string): Promise<Resource[]> {
const results = await this.prisma.resource.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
});
return results.map(r => ({
...r,
description: r.description ?? undefined
}));
}
async update(id: string, data: Partial<Resource>): Promise<Resource> {
const result = await this.prisma.resource.update({ where: { id }, data });
return {
...result,
description: result.description ?? undefined
};
}
async delete(id: string): Promise<void> {
await this.prisma.resource.delete({ where: { id } });
}
}
6. Register in Container
// apps/backend/src/container.ts
import { IResourceRepository } from './domain/repositories';
import { PrismaResourceRepository } from './infrastructure/repositories';
import { CreateResourceUseCase } from './application/resource';
import { ResourceController } from './presentation/controllers';
export function createContainer(): Container {
const container = new Container();
// ... existing bindings ...
// Repository
container
.bind<IResourceRepository>('IResourceRepository')
.to(PrismaResourceRepository)
.inTransientScope();
// Use Case
container.bind(CreateResourceUseCase).toSelf().inTransientScope();
// Controller
container.bind(ResourceController).toSelf().inTransientScope();
return container;
}
7. Create Controller
// apps/backend/src/presentation/controllers/ResourceController.ts
import { injectable, inject } from 'inversify';
import { Response, NextFunction } from 'express';
import { CreateResourceUseCase } from '../../application/resource';
import { AuthRequest } from '../middleware';
@injectable()
export class ResourceController {
constructor(
@inject(CreateResourceUseCase) private createUseCase: CreateResourceUseCase
) {}
async create(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const resource = await this.createUseCase.execute({
...req.body,
userId: req.user.userId
});
res.status(201).json(resource);
} catch (error) {
next(error);
}
}
}
8. Write Unit Tests
// apps/backend/src/application/resource/CreateResourceUseCase.unit.test.ts
import { CreateResourceUseCase } from './CreateResourceUseCase';
import { IResourceRepository } from '../../domain/repositories';
import { ILogger } from '../../domain/services';
import { ValidationError } from '@domain/errors';
import { Resource } from '@domain/entities';
describe('CreateResourceUseCase', () => {
let useCase: CreateResourceUseCase;
let mockRepository: jest.Mocked<IResourceRepository>;
let mockLogger: jest.Mocked<ILogger>;
beforeEach(() => {
mockRepository = {
create: jest.fn(),
findById: jest.fn(),
findByUserId: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn()
};
useCase = new CreateResourceUseCase(mockRepository, mockLogger);
});
it('should create resource with valid input', async () => {
const input = {
userId: 'user-123',
name: 'Test Resource',
description: 'Test description'
};
const expected: Resource = {
id: 'resource-456',
...input,
createdAt: new Date(),
updatedAt: new Date()
};
mockRepository.create.mockResolvedValue(expected);
const result = await useCase.execute(input);
expect(result).toEqual(expected);
expect(mockRepository.create).toHaveBeenCalledWith(input);
expect(mockLogger.info).toHaveBeenCalledWith('Resource created', {
resourceId: expected.id,
userId: input.userId
});
});
it('should throw ValidationError when name is empty', async () => {
const input = {
userId: 'user-123',
name: '' // Invalid
};
await expect(useCase.execute(input)).rejects.toThrow(ValidationError);
});
});
Authentication Pattern (JWT + bcrypt)
This project uses JWT tokens with bcrypt password hashing, not OAuth.
Signup Flow
// apps/backend/src/application/auth/CreateUserUseCase.ts
@injectable()
export class CreateUserUseCase {
constructor(
@inject('IUserRepository') private userRepository: IUserRepository,
@inject('IPasswordHasher') private passwordHasher: IPasswordHasher,
@inject('ITokenGenerator') private tokenGenerator: ITokenGenerator
) {}
async execute(input: CreateUserInput): Promise<CreateUserOutput> {
// 1. Validate with shared schema
const validation = signupSchema.safeParse(input);
if (!validation.success) {
throw new ValidationError(validation.error.errors[0].message);
}
// 2. Check for existing user
const existingUser = await this.userRepository.findByEmail(input.email);
if (existingUser) {
throw new ValidationError('User already exists');
}
// 3. Hash password
const passwordHash = await this.passwordHasher.hash(input.password);
// 4. Create user
const user = await this.userRepository.create({
email: input.email,
passwordHash,
name: input.name
});
// 5. Generate JWT
const token = this.tokenGenerator.generate({
userId: user.id,
email: user.email
});
return { user, token };
}
}
JWT Middleware
// apps/backend/src/presentation/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface AuthRequest extends Request {
user?: {
userId: string;
email: string;
};
}
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string;
email: string;
};
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
Production Logging for Railway (Next Feature)
You're about to deploy to Railway and need diagnostic logging. Here's how to implement it:
Option 1: Pino (Recommended for Railway)
Pros:
- Fastest JSON logger (optimized for stdout)
- Railway-friendly (structured JSON output)
- Low overhead, great for high-throughput APIs
- Built-in request correlation IDs
Installation:
npm install pino pino-pretty
Setup:
// apps/backend/src/infrastructure/services/PinoLogger.ts
import pino from 'pino';
import { ILogger } from '../../domain/services';
export function createPinoLogger(): ILogger {
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV === 'development' ? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname'
}
} : undefined,
// Railway captures these fields for log aggregation
base: {
service: 'upkeep-api',
environment: process.env.NODE_ENV || 'development'
}
});
return {
info: (message: string, context?: object) => logger.info(context, message),
warn: (message: string, context?: object) => logger.warn(context, message),
error: (message: string, context?: object) => logger.error(context, message),
debug: (message: string, context?: object) => logger.debug(context, message)
};
}
Option 2: Winston
Pros:
- More features (multiple transports, custom formats)
- Better for complex logging requirements
- Larger ecosystem
Cons:
- Heavier than Pino
- More configuration needed
Option 3: Enhanced Console Logger
Keep it simple if you don't need advanced features:
// apps/backend/src/infrastructure/services/StructuredConsoleLogger.ts
import { ILogger } from '../../domain/services';
export class StructuredConsoleLogger implements ILogger {
info(message: string, context?: object): void {
console.log(JSON.stringify({
level: 'info',
message,
timestamp: new Date().toISOString(),
...context
}));
}
error(message: string, context?: object): void {
console.error(JSON.stringify({
level: 'error',
message,
timestamp: new Date().toISOString(),
...context
}));
}
warn(message: string, context?: object): void {
console.warn(JSON.stringify({
level: 'warn',
message,
timestamp: new Date().toISOString(),
...context
}));
}
debug(message: string, context?: object): void {
if (process.env.LOG_LEVEL === 'debug') {
console.debug(JSON.stringify({
level: 'debug',
message,
timestamp: new Date().toISOString(),
...context
}));
}
}
}
Request Correlation IDs
Track requests across use cases and repositories:
// apps/backend/src/presentation/middleware/requestId.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
const requestId = uuidv4();
req.headers['x-request-id'] = requestId;
res.setHeader('x-request-id', requestId);
next();
}
// Use in use cases:
this.logger.info('Creating resource', {
requestId: req.headers['x-request-id'],
userId: input.userId
});
Performance Logging
Track slow operations:
async execute(input: CreateResourceInput): Promise<Resource> {
const start = Date.now();
try {
// ... business logic ...
const duration = Date.now() - start;
this.logger.info('Resource created', {
resourceId: resource.id,
duration
});
if (duration > 1000) {
this.logger.warn('Slow operation detected', {
operation: 'CreateResource',
duration
});
}
return resource;
} catch (error) {
this.logger.error('Failed to create resource', {
error: error.message,
stack: error.stack,
userId: input.userId
});
throw error;
}
}
Railway Deployment
Required Configuration
railway.json:
{
"build": {
"builder": "NIXPACKS",
"buildCommand": "npm ci && npm run build && npx prisma generate"
},
"deploy": {
"startCommand": "npm run start",
"healthcheckPath": "/api/health",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 3
}
}
Environment Variables
Set in Railway dashboard:
# Database (Railway provides this)
DATABASE_URL=${{Postgres.DATABASE_URL}}
# Server
PORT=${{PORT}}
NODE_ENV=production
LOG_LEVEL=info
# Authentication
JWT_SECRET=<generate-secure-random-string>
JWT_EXPIRY=7d
# Frontend (for CORS)
FRONTEND_URL=https://your-frontend.railway.app
Database Migrations
Development (Prisma):
npm run migrate:dev # Create and apply migration
npm run generate # Regenerate Prisma client
Production (Flyway):
- Prisma generates SQL in
prisma/migrations/ - Copy to
migrations/V{number}__{name}.sql - GitHub Actions runs Flyway before deployment
- Atomic, transactional migrations with rollback
Health Check Endpoint
// apps/backend/src/presentation/routes/health.ts
router.get('/api/health', async (req, res) => {
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`;
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message
});
}
});
Key Patterns
inversify Dependency Injection
// ALWAYS required in tsconfig.json:
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
// MUST be first import in server.ts:
import 'reflect-metadata';
Shared Validation (DRY)
// libs/validators/src/property.ts - SINGLE SOURCE OF TRUTH
export const createPropertySchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/)
});
// Backend use case imports it
import { createPropertySchema } from '@validators/property';
// Frontend form imports THE SAME schema
import { toTypedSchema } from '@vee-validate/zod';
import { createPropertySchema } from '@validators/property';
const schema = toTypedSchema(createPropertySchema);
Type Transformations (Prisma → Domain)
// Prisma returns Decimal and nulls, domain expects number and undefined
return {
...property,
address2: property.address2 ?? undefined,
purchasePrice: property.purchasePrice ? property.purchasePrice.toNumber() : undefined
};
References
See reference.md for:
- Clean Architecture layer details
- Testing strategy and mock factories
- Railway deployment configuration
- API design patterns
- Security middleware setup
See examples.md for:
- Complete feature implementation (MaintenanceWork)
- Full use case examples with tests
- Repository patterns with Prisma
- Controller and routing setup