@raftlabs/raftstack 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/backend/SKILL.md +802 -0
- package/.claude/skills/code-quality/SKILL.md +318 -0
- package/.claude/skills/database/SKILL.md +465 -0
- package/.claude/skills/react/SKILL.md +418 -0
- package/.claude/skills/seo/SKILL.md +446 -0
- package/LICENSE +21 -0
- package/README.md +291 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2009 -0
- package/dist/cli.js.map +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: backend
|
|
3
|
+
description: Use when writing serverless functions, API handlers, backend services, or when code has tight coupling to infrastructure, no dependency injection, or mixed concerns
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Backend Development
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Backend code should separate business logic from infrastructure. Use dependency injection for testability, Zod for validation, and layer your code so business rules don't know about HTTP or AWS.
|
|
11
|
+
|
|
12
|
+
## When to Use
|
|
13
|
+
|
|
14
|
+
- Writing Lambda handlers or serverless functions
|
|
15
|
+
- Creating API endpoints
|
|
16
|
+
- Designing service layer architecture
|
|
17
|
+
- Setting up validation
|
|
18
|
+
- Making code testable without mocking infrastructure
|
|
19
|
+
|
|
20
|
+
## The Iron Rules
|
|
21
|
+
|
|
22
|
+
### 1. Layer Separation: Handler → Service → Repository
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
Handler (HTTP) → Parses request, calls service, formats response
|
|
26
|
+
Service (Business) → Pure business logic, validates, orchestrates
|
|
27
|
+
Repository (Data) → Database operations only
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// ❌ BAD: Everything in handler
|
|
32
|
+
export const handler = async (event) => {
|
|
33
|
+
const body = JSON.parse(event.body);
|
|
34
|
+
// validation...
|
|
35
|
+
// business logic...
|
|
36
|
+
// database call...
|
|
37
|
+
// send email...
|
|
38
|
+
// format response...
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ✅ GOOD: Layered with injection
|
|
42
|
+
export const createHandler = (userService: UserService) => async (event) => {
|
|
43
|
+
const input = parseRequest(event);
|
|
44
|
+
const result = await userService.createUser(input);
|
|
45
|
+
return formatResponse(201, result);
|
|
46
|
+
};
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2. Dependency Injection for Serverless
|
|
50
|
+
|
|
51
|
+
Inject dependencies into handlers. This enables testing without AWS mocks.
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
// ✅ GOOD: Factory pattern for DI
|
|
55
|
+
// handler.ts
|
|
56
|
+
import { createUserService } from './services/user-service';
|
|
57
|
+
import { createUserRepository } from './repositories/user-repository';
|
|
58
|
+
import { createEmailService } from './services/email-service';
|
|
59
|
+
|
|
60
|
+
const userRepository = createUserRepository(docClient);
|
|
61
|
+
const emailService = createEmailService(sesClient);
|
|
62
|
+
const userService = createUserService(userRepository, emailService);
|
|
63
|
+
|
|
64
|
+
export const handler = createHandler(userService);
|
|
65
|
+
|
|
66
|
+
// In tests:
|
|
67
|
+
const mockRepo = { save: vi.fn(), findByEmail: vi.fn() };
|
|
68
|
+
const mockEmail = { send: vi.fn() };
|
|
69
|
+
const testService = createUserService(mockRepo, mockEmail);
|
|
70
|
+
// Test business logic without AWS!
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 3. Zod for Validation (Not Manual If/Else)
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// ❌ BAD: Manual validation
|
|
77
|
+
function validateRequest(body: unknown) {
|
|
78
|
+
const errors: string[] = [];
|
|
79
|
+
if (!body.email) errors.push('Email required');
|
|
80
|
+
if (!body.email.includes('@')) errors.push('Invalid email');
|
|
81
|
+
// ... 50 more lines
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ✅ GOOD: Zod schema
|
|
85
|
+
import { z } from 'zod';
|
|
86
|
+
|
|
87
|
+
const CreateUserSchema = z.object({
|
|
88
|
+
email: z.string().email('Invalid email format'),
|
|
89
|
+
password: z.string()
|
|
90
|
+
.min(8, 'Password must be 8+ characters')
|
|
91
|
+
.regex(/[A-Z]/, 'Must contain uppercase')
|
|
92
|
+
.regex(/[a-z]/, 'Must contain lowercase')
|
|
93
|
+
.regex(/[0-9]/, 'Must contain number'),
|
|
94
|
+
firstName: z.string().min(1, 'First name required'),
|
|
95
|
+
lastName: z.string().min(1, 'Last name required'),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
type CreateUserInput = z.infer<typeof CreateUserSchema>;
|
|
99
|
+
|
|
100
|
+
// In handler:
|
|
101
|
+
const result = CreateUserSchema.safeParse(body);
|
|
102
|
+
if (!result.success) {
|
|
103
|
+
return { statusCode: 400, body: JSON.stringify(result.error.flatten()) };
|
|
104
|
+
}
|
|
105
|
+
const validInput: CreateUserInput = result.data;
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Advanced Zod Patterns
|
|
109
|
+
|
|
110
|
+
#### Discriminated Unions for API Responses
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
import { z } from 'zod';
|
|
114
|
+
|
|
115
|
+
// ✅ GOOD: Type-safe response handling
|
|
116
|
+
const ApiResponse = z.discriminatedUnion('status', [
|
|
117
|
+
z.object({
|
|
118
|
+
status: z.literal('success'),
|
|
119
|
+
data: z.object({
|
|
120
|
+
id: z.string(),
|
|
121
|
+
email: z.string().email(),
|
|
122
|
+
}),
|
|
123
|
+
}),
|
|
124
|
+
z.object({
|
|
125
|
+
status: z.literal('error'),
|
|
126
|
+
error: z.string(),
|
|
127
|
+
code: z.enum(['VALIDATION_ERROR', 'CONFLICT', 'NOT_FOUND']),
|
|
128
|
+
}),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
type ApiResponse = z.infer<typeof ApiResponse>;
|
|
132
|
+
// { status: "success"; data: {...} } | { status: "error"; error: string; code: ... }
|
|
133
|
+
|
|
134
|
+
// In handler
|
|
135
|
+
const response = ApiResponse.parse({ status: 'success', data: user });
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
#### Transforms & Coercion
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
// ✅ GOOD: Coerce query params to numbers
|
|
142
|
+
const PaginationSchema = z.object({
|
|
143
|
+
page: z.coerce.number().min(1).default(1),
|
|
144
|
+
limit: z.coerce.number().min(1).max(100).default(20),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ?page=5&limit=50 → { page: 5, limit: 50 }
|
|
148
|
+
const params = PaginationSchema.parse(event.queryStringParameters);
|
|
149
|
+
|
|
150
|
+
// ✅ GOOD: Transform to normalize data
|
|
151
|
+
const EmailSchema = z.string()
|
|
152
|
+
.email()
|
|
153
|
+
.transform((val) => val.toLowerCase().trim());
|
|
154
|
+
|
|
155
|
+
// ✅ GOOD: Preprocess before validation
|
|
156
|
+
const DateSchema = z.preprocess(
|
|
157
|
+
(val) => (typeof val === 'string' ? new Date(val) : val),
|
|
158
|
+
z.date()
|
|
159
|
+
);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
#### Refinements for Complex Validation
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// ✅ GOOD: Cross-field validation
|
|
166
|
+
const PasswordUpdateSchema = z.object({
|
|
167
|
+
newPassword: z.string().min(8),
|
|
168
|
+
confirmPassword: z.string(),
|
|
169
|
+
}).refine(
|
|
170
|
+
(data) => data.newPassword === data.confirmPassword,
|
|
171
|
+
{
|
|
172
|
+
message: "Passwords don't match",
|
|
173
|
+
path: ['confirmPassword'], // Attach to specific field
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// ✅ GOOD: Async validation
|
|
178
|
+
const UniqueEmailSchema = z.string().email().refine(
|
|
179
|
+
async (email) => {
|
|
180
|
+
const exists = await userRepo.findByEmail(email);
|
|
181
|
+
return !exists;
|
|
182
|
+
},
|
|
183
|
+
{ message: 'Email already registered' }
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Use with parseAsync
|
|
187
|
+
const email = await UniqueEmailSchema.parseAsync('test@example.com');
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### Error Formatting for APIs
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
// ✅ GOOD: Flatten errors for form display
|
|
194
|
+
const result = CreateUserSchema.safeParse(body);
|
|
195
|
+
|
|
196
|
+
if (!result.success) {
|
|
197
|
+
const flattened = result.error.flatten();
|
|
198
|
+
/*
|
|
199
|
+
{
|
|
200
|
+
formErrors: [],
|
|
201
|
+
fieldErrors: {
|
|
202
|
+
email: ['Invalid email format'],
|
|
203
|
+
password: ['Password must be 8+ characters', 'Must contain uppercase']
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
*/
|
|
207
|
+
return {
|
|
208
|
+
statusCode: 400,
|
|
209
|
+
body: JSON.stringify({
|
|
210
|
+
error: 'Validation failed',
|
|
211
|
+
details: flattened.fieldErrors,
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ✅ GOOD: Format errors as nested object
|
|
217
|
+
const formatted = result.error.format();
|
|
218
|
+
/*
|
|
219
|
+
{
|
|
220
|
+
email: { _errors: ['Invalid email format'] },
|
|
221
|
+
password: {
|
|
222
|
+
_errors: ['Password must be 8+ characters', 'Must contain uppercase']
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
*/
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
#### Default & Catch for Fallbacks
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
// ✅ GOOD: Default for undefined input
|
|
232
|
+
const StringWithDefault = z.string().default('unknown');
|
|
233
|
+
StringWithDefault.parse(undefined); // => 'unknown'
|
|
234
|
+
|
|
235
|
+
// ✅ GOOD: Dynamic default
|
|
236
|
+
const TimestampSchema = z.date().default(() => new Date());
|
|
237
|
+
|
|
238
|
+
// ✅ GOOD: Catch for validation failures
|
|
239
|
+
const SafeNumber = z.number().catch(0);
|
|
240
|
+
SafeNumber.parse('invalid'); // => 0 (no throw)
|
|
241
|
+
|
|
242
|
+
// ✅ GOOD: Catch with error context
|
|
243
|
+
const SafeString = z.string().catch((ctx) => {
|
|
244
|
+
console.error('Validation failed:', ctx.error);
|
|
245
|
+
return 'fallback';
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ✅ GOOD: Prefault for pre-parse defaults (transformations apply)
|
|
249
|
+
const TrimmedDefault = z.string().trim().prefault(' hello ');
|
|
250
|
+
TrimmedDefault.parse(undefined); // => 'hello' (trimmed)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
#### Pipe for Schema Chaining
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
// ✅ GOOD: Chain transformations
|
|
257
|
+
const TrimmedUppercase = z.string()
|
|
258
|
+
.pipe(z.string().trim())
|
|
259
|
+
.pipe(z.string().toUpperCase());
|
|
260
|
+
|
|
261
|
+
TrimmedUppercase.parse(' hello '); // => 'HELLO'
|
|
262
|
+
|
|
263
|
+
// ✅ GOOD: Pipe with validation
|
|
264
|
+
const PositiveInt = z.string()
|
|
265
|
+
.pipe(z.coerce.number())
|
|
266
|
+
.pipe(z.number().int().positive());
|
|
267
|
+
|
|
268
|
+
PositiveInt.parse('42'); // => 42
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
#### Branded Types for IDs
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
// ✅ GOOD: Type-safe IDs prevent mixing
|
|
275
|
+
const UserId = z.string().uuid().brand<'UserId'>();
|
|
276
|
+
const OrderId = z.string().uuid().brand<'OrderId'>();
|
|
277
|
+
|
|
278
|
+
type UserId = z.infer<typeof UserId>; // string & Brand<'UserId'>
|
|
279
|
+
type OrderId = z.infer<typeof OrderId>; // string & Brand<'OrderId'>
|
|
280
|
+
|
|
281
|
+
function getUser(id: UserId) { /* ... */ }
|
|
282
|
+
function getOrder(id: OrderId) { /* ... */ }
|
|
283
|
+
|
|
284
|
+
const userId = UserId.parse('abc-123');
|
|
285
|
+
const orderId = OrderId.parse('def-456');
|
|
286
|
+
|
|
287
|
+
getUser(userId); // ✅ OK
|
|
288
|
+
getUser(orderId); // ❌ TypeScript error - wrong ID type
|
|
289
|
+
getOrder(orderId); // ✅ OK
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
#### Custom Error Maps
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
// ✅ GOOD: Global custom error messages
|
|
296
|
+
import { z } from 'zod';
|
|
297
|
+
|
|
298
|
+
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
|
|
299
|
+
switch (issue.code) {
|
|
300
|
+
case z.ZodIssueCode.invalid_type:
|
|
301
|
+
if (issue.expected === 'string') {
|
|
302
|
+
return { message: 'This field must be text' };
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
case z.ZodIssueCode.too_small:
|
|
306
|
+
if (issue.type === 'string') {
|
|
307
|
+
return { message: `Minimum ${issue.minimum} characters required` };
|
|
308
|
+
}
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
return { message: ctx.defaultError };
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Apply globally
|
|
315
|
+
z.setErrorMap(customErrorMap);
|
|
316
|
+
|
|
317
|
+
// Or per-schema
|
|
318
|
+
const schema = z.string();
|
|
319
|
+
schema.parse(12, { errorMap: customErrorMap });
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### 4. Service Layer: Pure Business Logic
|
|
323
|
+
|
|
324
|
+
Services contain business rules. They don't know about HTTP, AWS, or databases - only interfaces.
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
// services/user-service.ts
|
|
328
|
+
export interface UserRepository {
|
|
329
|
+
save(user: User): Promise<void>;
|
|
330
|
+
findByEmail(email: string): Promise<User | null>;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface EmailService {
|
|
334
|
+
sendWelcome(email: string, name: string): Promise<void>;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function createUserService(
|
|
338
|
+
userRepo: UserRepository,
|
|
339
|
+
emailService: EmailService
|
|
340
|
+
) {
|
|
341
|
+
return {
|
|
342
|
+
async createUser(input: CreateUserInput): Promise<User> {
|
|
343
|
+
// Business rule: check duplicate
|
|
344
|
+
const existing = await userRepo.findByEmail(input.email);
|
|
345
|
+
if (existing) {
|
|
346
|
+
throw new ConflictError('Email already registered');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Business rule: create user
|
|
350
|
+
const user = {
|
|
351
|
+
id: crypto.randomUUID(),
|
|
352
|
+
...input,
|
|
353
|
+
passwordHash: await hashPassword(input.password),
|
|
354
|
+
createdAt: new Date(),
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
await userRepo.save(user);
|
|
358
|
+
|
|
359
|
+
// Business rule: send welcome (fire-and-forget)
|
|
360
|
+
emailService.sendWelcome(user.email, user.firstName).catch(console.error);
|
|
361
|
+
|
|
362
|
+
return user;
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### 5. Custom Error Types for Control Flow
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
// errors.ts
|
|
372
|
+
export class AppError extends Error {
|
|
373
|
+
constructor(
|
|
374
|
+
message: string,
|
|
375
|
+
public readonly statusCode: number,
|
|
376
|
+
public readonly code: string
|
|
377
|
+
) {
|
|
378
|
+
super(message);
|
|
379
|
+
this.name = 'AppError';
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export class ValidationError extends AppError {
|
|
384
|
+
constructor(message: string) {
|
|
385
|
+
super(message, 400, 'VALIDATION_ERROR');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export class ConflictError extends AppError {
|
|
390
|
+
constructor(message: string) {
|
|
391
|
+
super(message, 409, 'CONFLICT');
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export class NotFoundError extends AppError {
|
|
396
|
+
constructor(message: string) {
|
|
397
|
+
super(message, 404, 'NOT_FOUND');
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// In handler:
|
|
402
|
+
try {
|
|
403
|
+
const result = await userService.createUser(input);
|
|
404
|
+
return { statusCode: 201, body: JSON.stringify(result) };
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (error instanceof AppError) {
|
|
407
|
+
return { statusCode: error.statusCode, body: JSON.stringify({ error: error.message, code: error.code }) };
|
|
408
|
+
}
|
|
409
|
+
console.error('Unexpected error:', error);
|
|
410
|
+
return { statusCode: 500, body: JSON.stringify({ error: 'Internal error' }) };
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
## Lambda Optimization
|
|
415
|
+
|
|
416
|
+
### Connection Pooling (Cold Start Critical)
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
// ❌ BAD: New connection per invocation
|
|
420
|
+
export const handler = async (event) => {
|
|
421
|
+
const client = new DatabaseClient(); // Cold start penalty
|
|
422
|
+
await client.connect();
|
|
423
|
+
// ... use client
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// ✅ GOOD: Connection at module level (reused)
|
|
427
|
+
import { Pool } from '@neondatabase/serverless';
|
|
428
|
+
import { drizzle } from 'drizzle-orm/neon-serverless';
|
|
429
|
+
|
|
430
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
431
|
+
const db = drizzle({ client: pool });
|
|
432
|
+
|
|
433
|
+
export const handler = async (event) => {
|
|
434
|
+
// Reuses existing connection across warm invocations
|
|
435
|
+
const users = await db.select().from(users);
|
|
436
|
+
return { statusCode: 200, body: JSON.stringify(users) };
|
|
437
|
+
};
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
**Key patterns:**
|
|
441
|
+
- Initialize clients **outside** handler (module level)
|
|
442
|
+
- Use connection pooling (RDS Proxy, Neon Pool)
|
|
443
|
+
- Reduces cold start by 80% for database operations
|
|
444
|
+
|
|
445
|
+
### SDK Client Initialization
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
// ✅ GOOD: Initialize AWS SDK clients at module level
|
|
449
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
450
|
+
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
451
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
452
|
+
|
|
453
|
+
const dynamoClient = DynamoDBDocumentClient.from(
|
|
454
|
+
new DynamoDBClient({ region: process.env.AWS_REGION })
|
|
455
|
+
);
|
|
456
|
+
const s3Client = new S3Client({ region: process.env.AWS_REGION });
|
|
457
|
+
|
|
458
|
+
export const handler = async (event) => {
|
|
459
|
+
// Clients already initialized (warm path)
|
|
460
|
+
};
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Lazy Loading for Heavy Dependencies
|
|
464
|
+
|
|
465
|
+
```typescript
|
|
466
|
+
// ✅ GOOD: Lazy load only when needed
|
|
467
|
+
export const handler = async (event) => {
|
|
468
|
+
if (event.path === '/pdf') {
|
|
469
|
+
const { generatePDF } = await import('./pdf-generator'); // Heavy lib
|
|
470
|
+
return generatePDF(event.body);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Fast path doesn't pay cold start cost
|
|
474
|
+
return { statusCode: 200, body: 'OK' };
|
|
475
|
+
};
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### Cold Start Optimization Checklist
|
|
479
|
+
|
|
480
|
+
- [ ] Database connections at module level
|
|
481
|
+
- [ ] AWS SDK clients initialized outside handler
|
|
482
|
+
- [ ] Small deployment package (< 10MB compressed)
|
|
483
|
+
- [ ] Minimal dependencies (tree-shake unused code)
|
|
484
|
+
- [ ] Use Node.js or Python for fastest cold starts
|
|
485
|
+
- [ ] Consider provisioned concurrency for critical paths
|
|
486
|
+
- [ ] Lazy load heavy libraries when possible
|
|
487
|
+
|
|
488
|
+
## Middleware Patterns (API Gateway)
|
|
489
|
+
|
|
490
|
+
### Request Validation Middleware
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
// ✅ GOOD: Reusable validation middleware
|
|
494
|
+
import { z } from 'zod';
|
|
495
|
+
|
|
496
|
+
export function withValidation<T extends z.ZodSchema>(
|
|
497
|
+
schema: T,
|
|
498
|
+
handler: (event: { body: z.infer<T> }) => Promise<any>
|
|
499
|
+
) {
|
|
500
|
+
return async (event: any) => {
|
|
501
|
+
const body = JSON.parse(event.body || '{}');
|
|
502
|
+
const result = schema.safeParse(body);
|
|
503
|
+
|
|
504
|
+
if (!result.success) {
|
|
505
|
+
return {
|
|
506
|
+
statusCode: 400,
|
|
507
|
+
body: JSON.stringify({
|
|
508
|
+
error: 'Validation failed',
|
|
509
|
+
details: result.error.flatten().fieldErrors,
|
|
510
|
+
}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return handler({ ...event, body: result.data });
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Usage
|
|
519
|
+
export const handler = withValidation(
|
|
520
|
+
CreateUserSchema,
|
|
521
|
+
async ({ body }) => {
|
|
522
|
+
// body is typed as CreateUserInput
|
|
523
|
+
const user = await userService.createUser(body);
|
|
524
|
+
return { statusCode: 201, body: JSON.stringify(user) };
|
|
525
|
+
}
|
|
526
|
+
);
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### Error Handling Middleware
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
// ✅ GOOD: Centralized error handling
|
|
533
|
+
export function withErrorHandling(
|
|
534
|
+
handler: (event: any) => Promise<any>
|
|
535
|
+
) {
|
|
536
|
+
return async (event: any) => {
|
|
537
|
+
try {
|
|
538
|
+
return await handler(event);
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if (error instanceof AppError) {
|
|
541
|
+
return {
|
|
542
|
+
statusCode: error.statusCode,
|
|
543
|
+
body: JSON.stringify({
|
|
544
|
+
error: error.message,
|
|
545
|
+
code: error.code,
|
|
546
|
+
}),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
console.error('Unexpected error:', error);
|
|
551
|
+
return {
|
|
552
|
+
statusCode: 500,
|
|
553
|
+
body: JSON.stringify({ error: 'Internal server error' }),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
### Compose Middleware
|
|
561
|
+
|
|
562
|
+
```typescript
|
|
563
|
+
// ✅ GOOD: Compose multiple middleware
|
|
564
|
+
function compose(...middlewares: Array<(handler: any) => any>) {
|
|
565
|
+
return (handler: any) =>
|
|
566
|
+
middlewares.reduceRight((acc, middleware) => middleware(acc), handler);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Usage
|
|
570
|
+
const baseHandler = async ({ body }: { body: CreateUserInput }) => {
|
|
571
|
+
const user = await userService.createUser(body);
|
|
572
|
+
return { statusCode: 201, body: JSON.stringify(user) };
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
export const handler = compose(
|
|
576
|
+
withErrorHandling,
|
|
577
|
+
withValidation(CreateUserSchema)
|
|
578
|
+
)(baseHandler);
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
## Testing Strategy
|
|
582
|
+
|
|
583
|
+
| What to Test | How |
|
|
584
|
+
|--------------|-----|
|
|
585
|
+
| Service layer | Unit tests with mock repositories |
|
|
586
|
+
| Validation | Test Zod schemas with valid/invalid inputs |
|
|
587
|
+
| Error handling | Verify custom errors map to status codes |
|
|
588
|
+
| Handlers | Integration tests with test event objects |
|
|
589
|
+
| Async refinements | Use parseAsync and await results |
|
|
590
|
+
|
|
591
|
+
```typescript
|
|
592
|
+
// Test service layer (pure business logic)
|
|
593
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
594
|
+
import { createUserService } from './user-service';
|
|
595
|
+
|
|
596
|
+
describe('UserService', () => {
|
|
597
|
+
it('creates user and sends welcome email', async () => {
|
|
598
|
+
const mockRepo = {
|
|
599
|
+
save: vi.fn(),
|
|
600
|
+
findByEmail: vi.fn().mockResolvedValue(null),
|
|
601
|
+
};
|
|
602
|
+
const mockEmail = {
|
|
603
|
+
sendWelcome: vi.fn().mockResolvedValue(undefined),
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const service = createUserService(mockRepo, mockEmail);
|
|
607
|
+
const user = await service.createUser({
|
|
608
|
+
email: 'test@example.com',
|
|
609
|
+
password: 'Password123',
|
|
610
|
+
firstName: 'John',
|
|
611
|
+
lastName: 'Doe',
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
expect(mockRepo.save).toHaveBeenCalledWith(
|
|
615
|
+
expect.objectContaining({
|
|
616
|
+
email: 'test@example.com',
|
|
617
|
+
firstName: 'John',
|
|
618
|
+
})
|
|
619
|
+
);
|
|
620
|
+
expect(mockEmail.sendWelcome).toHaveBeenCalledWith(
|
|
621
|
+
'test@example.com',
|
|
622
|
+
'John'
|
|
623
|
+
);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('throws ConflictError for duplicate email', async () => {
|
|
627
|
+
const mockRepo = {
|
|
628
|
+
save: vi.fn(),
|
|
629
|
+
findByEmail: vi.fn().mockResolvedValue({ id: '123' }),
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const service = createUserService(mockRepo, {} as any);
|
|
633
|
+
|
|
634
|
+
await expect(
|
|
635
|
+
service.createUser({
|
|
636
|
+
email: 'existing@example.com',
|
|
637
|
+
password: 'Password123',
|
|
638
|
+
firstName: 'John',
|
|
639
|
+
lastName: 'Doe',
|
|
640
|
+
})
|
|
641
|
+
).rejects.toThrow(ConflictError);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('handles repository errors gracefully', async () => {
|
|
645
|
+
const mockRepo = {
|
|
646
|
+
save: vi.fn().mockRejectedValue(new Error('DB connection failed')),
|
|
647
|
+
findByEmail: vi.fn().mockResolvedValue(null),
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const service = createUserService(mockRepo, {} as any);
|
|
651
|
+
|
|
652
|
+
await expect(
|
|
653
|
+
service.createUser({
|
|
654
|
+
email: 'test@example.com',
|
|
655
|
+
password: 'Password123',
|
|
656
|
+
firstName: 'John',
|
|
657
|
+
lastName: 'Doe',
|
|
658
|
+
})
|
|
659
|
+
).rejects.toThrow('DB connection failed');
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('uses vi.spyOn to track method calls', async () => {
|
|
663
|
+
const repo = {
|
|
664
|
+
save: async (user: any) => user,
|
|
665
|
+
findByEmail: async (email: string) => null,
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const saveSpy = vi.spyOn(repo, 'save');
|
|
669
|
+
const findSpy = vi.spyOn(repo, 'findByEmail');
|
|
670
|
+
|
|
671
|
+
const service = createUserService(repo, {} as any);
|
|
672
|
+
await service.createUser({
|
|
673
|
+
email: 'test@example.com',
|
|
674
|
+
password: 'Password123',
|
|
675
|
+
firstName: 'John',
|
|
676
|
+
lastName: 'Doe',
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
expect(findSpy).toHaveBeenCalledWith('test@example.com');
|
|
680
|
+
expect(saveSpy).toHaveBeenCalledOnce();
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// Test Zod validation
|
|
685
|
+
describe('CreateUserSchema', () => {
|
|
686
|
+
it('validates correct input', () => {
|
|
687
|
+
const result = CreateUserSchema.safeParse({
|
|
688
|
+
email: 'test@example.com',
|
|
689
|
+
password: 'Password123',
|
|
690
|
+
firstName: 'John',
|
|
691
|
+
lastName: 'Doe',
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
expect(result.success).toBe(true);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it('rejects weak password', () => {
|
|
698
|
+
const result = CreateUserSchema.safeParse({
|
|
699
|
+
email: 'test@example.com',
|
|
700
|
+
password: 'weak',
|
|
701
|
+
firstName: 'John',
|
|
702
|
+
lastName: 'Doe',
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
expect(result.success).toBe(false);
|
|
706
|
+
if (!result.success) {
|
|
707
|
+
expect(result.error.flatten().fieldErrors.password).toContain(
|
|
708
|
+
'Password must be 8+ characters'
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// Test handler error mapping
|
|
715
|
+
describe('Handler', () => {
|
|
716
|
+
it('returns 409 for ConflictError', async () => {
|
|
717
|
+
const mockService = {
|
|
718
|
+
createUser: vi.fn().mockRejectedValue(
|
|
719
|
+
new ConflictError('Email already exists')
|
|
720
|
+
),
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const handler = createHandler(mockService);
|
|
724
|
+
const response = await handler({
|
|
725
|
+
body: JSON.stringify({ email: 'test@example.com' }),
|
|
726
|
+
} as any);
|
|
727
|
+
|
|
728
|
+
expect(response.statusCode).toBe(409);
|
|
729
|
+
const body = JSON.parse(response.body);
|
|
730
|
+
expect(body.code).toBe('CONFLICT');
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
## References
|
|
736
|
+
|
|
737
|
+
- [Zod Documentation](https://zod.dev) - Validation, transforms, error formatting, branded types
|
|
738
|
+
- [Vitest Documentation](https://vitest.dev) - Testing, mocking, vi.fn(), vi.spyOn()
|
|
739
|
+
- [AWS Lambda Cold Starts](https://aws.amazon.com/blogs/compute/understanding-and-remediating-cold-starts-an-aws-lambda-perspective/) - Official optimization guide
|
|
740
|
+
- [AWS Lambda Performance](https://aws.amazon.com/blogs/compute/operating-lambda-performance-optimization-part-1/) - Best practices
|
|
741
|
+
|
|
742
|
+
**Version Notes:**
|
|
743
|
+
- Zod v3.24+: Improved error formatting, discriminated unions, branded types
|
|
744
|
+
- Zod v4.0+: prefault(), enhanced pipe(), performance improvements
|
|
745
|
+
- Vitest v3+: mockResolvedValue, mockRejectedValue patterns
|
|
746
|
+
- AWS Lambda: Node.js 20.x has faster cold starts than 18.x
|
|
747
|
+
|
|
748
|
+
## Quick Reference: Lambda Structure
|
|
749
|
+
|
|
750
|
+
```
|
|
751
|
+
src/
|
|
752
|
+
├── handlers/ # HTTP layer only
|
|
753
|
+
│ └── create-user.ts # Parse, call service, format response
|
|
754
|
+
├── services/ # Business logic only
|
|
755
|
+
│ └── user-service.ts # Rules, orchestration
|
|
756
|
+
├── repositories/ # Data access only
|
|
757
|
+
│ └── user-repository.ts # DynamoDB operations
|
|
758
|
+
├── schemas/ # Zod schemas
|
|
759
|
+
│ └── user.ts # CreateUserSchema, UpdateUserSchema
|
|
760
|
+
├── errors/ # Custom error types
|
|
761
|
+
│ └── index.ts # AppError, ConflictError, etc.
|
|
762
|
+
└── types/ # Shared types
|
|
763
|
+
└── user.ts # User, CreateUserInput
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
## Red Flags - STOP and Refactor
|
|
767
|
+
|
|
768
|
+
| Thought | Reality |
|
|
769
|
+
|---------|---------|
|
|
770
|
+
| "It's just a Lambda, no need for architecture" | Lambdas grow. Layering now saves pain later. |
|
|
771
|
+
| "I'll add DI if it gets complex" | It's already complex enough. Inject from day one. |
|
|
772
|
+
| "Manual validation is fine for this" | Zod is 5 lines and type-safe. Use it. |
|
|
773
|
+
| "Tests can mock AWS SDK" | Mocking AWS is painful. Inject interfaces instead. |
|
|
774
|
+
| "I'll separate later" | Later means never. Separate now. |
|
|
775
|
+
| "Connection pooling is premature" | Cold starts cost 80% more without pooling. Do it now. |
|
|
776
|
+
| "I'll initialize clients in the handler" | Module-level initialization reuses across invocations. |
|
|
777
|
+
| "flatten() and format() do the same thing" | flatten() for forms, format() for nested objects. |
|
|
778
|
+
| "Discriminated unions are overkill" | They make response handling type-safe and explicit. |
|
|
779
|
+
| "I don't need .catch() for this" | Catch prevents throws for invalid data. Use for fallbacks. |
|
|
780
|
+
| "Branded types are unnecessary" | They prevent mixing UserId with OrderId at compile time. |
|
|
781
|
+
| "I'll copy-paste validation in handlers" | Extract to middleware. Don't repeat yourself. |
|
|
782
|
+
| "prefault() is the same as default()" | prefault applies BEFORE transforms, default after. |
|
|
783
|
+
|
|
784
|
+
## Common Mistakes
|
|
785
|
+
|
|
786
|
+
| Mistake | Fix |
|
|
787
|
+
|---------|-----|
|
|
788
|
+
| Business logic in handler | Extract to service layer |
|
|
789
|
+
| AWS SDK calls scattered | Wrap in repository with interface |
|
|
790
|
+
| Manual validation | Use Zod schemas |
|
|
791
|
+
| Generic Error everywhere | Create domain-specific error classes |
|
|
792
|
+
| Can't test without AWS | Inject dependencies via factory functions |
|
|
793
|
+
| DB client in handler | Initialize at module level for connection reuse |
|
|
794
|
+
| Not using flatten() for errors | Use `.flatten().fieldErrors` for API responses |
|
|
795
|
+
| String validation without coercion | Use `z.coerce` for query params and form data |
|
|
796
|
+
| No discriminated unions | Use for type-safe API responses and request routing |
|
|
797
|
+
| Heavy deps loaded always | Lazy load with dynamic `import()` when needed |
|
|
798
|
+
| Not using .catch() for optional fields | Use `.catch()` for fallback values instead of throw |
|
|
799
|
+
| Mixing ID types | Use branded types (z.string().brand<'UserId'>()) |
|
|
800
|
+
| Duplicate validation in handlers | Extract to reusable middleware |
|
|
801
|
+
| Using default() expecting transforms | Use prefault() if transforms should apply to default |
|
|
802
|
+
| Not testing async rejections | Use mockRejectedValue() for error scenarios |
|