@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.
@@ -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 |