@jsfsi-core/ts-nodejs 1.1.3 → 1.1.6
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/README.md +820 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
# @jsfsi-core/ts-nodejs
|
|
2
|
+
|
|
3
|
+
Node.js-specific utilities for database management, logging, and environment configuration following hexagonal architecture principles.
|
|
4
|
+
|
|
5
|
+
## 📦 Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @jsfsi-core/ts-nodejs
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Dependencies:**
|
|
12
|
+
|
|
13
|
+
- `typeorm` - TypeORM for database management
|
|
14
|
+
- `dotenv` - Environment variable loading
|
|
15
|
+
|
|
16
|
+
## 🏗️ Architecture
|
|
17
|
+
|
|
18
|
+
This package provides Node.js-specific implementations for:
|
|
19
|
+
|
|
20
|
+
- **Database**: Transactional repositories with TypeORM integration
|
|
21
|
+
- **Logging**: Structured logging interface with multiple implementations
|
|
22
|
+
- **Environment**: Type-safe environment variable loading
|
|
23
|
+
|
|
24
|
+
### Package Structure
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
src/
|
|
28
|
+
├── database/
|
|
29
|
+
│ ├── TransactionalRepository.ts # Base transactional repository
|
|
30
|
+
│ ├── TransactionalEntity.ts # Entity interface
|
|
31
|
+
│ └── postgres/ # PostgreSQL utilities
|
|
32
|
+
├── logger/
|
|
33
|
+
│ ├── Logger.ts # Logger interface
|
|
34
|
+
│ ├── GCPLogger.ts # Google Cloud Platform logger
|
|
35
|
+
│ └── MockLogger.ts # Test logger
|
|
36
|
+
└── env/
|
|
37
|
+
└── env.loader.ts # Environment loader
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 📋 Features
|
|
41
|
+
|
|
42
|
+
### Transactional Repository
|
|
43
|
+
|
|
44
|
+
Type-safe transactional repository base class for database operations:
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { TransactionalRepository } from '@jsfsi-core/ts-nodejs';
|
|
48
|
+
import { DataSource } from 'typeorm';
|
|
49
|
+
import { UserEntity } from './entities/UserEntity';
|
|
50
|
+
|
|
51
|
+
export class UserRepository extends TransactionalRepository {
|
|
52
|
+
constructor(dataSource: DataSource) {
|
|
53
|
+
super(dataSource);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async findById(id: string): Promise<UserEntity | null> {
|
|
57
|
+
const repository = this.getRepository(UserEntity);
|
|
58
|
+
return repository.findOne({ where: { id } });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async save(user: UserEntity): Promise<UserEntity> {
|
|
62
|
+
const repository = this.getRepository(UserEntity);
|
|
63
|
+
return repository.save(user);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Transactions
|
|
69
|
+
|
|
70
|
+
Execute operations within a transaction:
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
async function createUserWithProfile(
|
|
74
|
+
userData: CreateUserData,
|
|
75
|
+
profileData: CreateProfileData,
|
|
76
|
+
): Promise<User> {
|
|
77
|
+
return this.userRepository.withTransaction(async (userRepo) => {
|
|
78
|
+
// All operations within this callback run in a single transaction
|
|
79
|
+
const user = await userRepo.save(createUserEntity(userData));
|
|
80
|
+
|
|
81
|
+
const profileRepo = this.profileRepository.withRepositoryManager(userRepo);
|
|
82
|
+
const profile = await profileRepo.save(createProfileEntity(user.id, profileData));
|
|
83
|
+
|
|
84
|
+
return { user, profile };
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Transactions as Domain Concepts
|
|
90
|
+
|
|
91
|
+
**Transactions are domain concepts, not persistence concepts.**
|
|
92
|
+
|
|
93
|
+
A transaction represents a **business operation** that must be atomic - it either completes entirely or fails entirely. The transactional repository allows you to move this concept to the domain layer, abstracting the persistence implementation.
|
|
94
|
+
|
|
95
|
+
#### Why Transactions Belong to Domain
|
|
96
|
+
|
|
97
|
+
Transactions express business rules about consistency and atomicity:
|
|
98
|
+
|
|
99
|
+
- **Business Rules**: "When creating an order, both the order and payment must succeed together"
|
|
100
|
+
- **Consistency**: "User registration includes creating a profile and sending a welcome email - all must succeed or all must fail"
|
|
101
|
+
- **Atomicity**: "Inventory deduction and order creation must happen together"
|
|
102
|
+
|
|
103
|
+
The transactional repository abstraction allows domain services to express these business rules without being tied to a specific persistence technology (TypeORM, Prisma, etc.).
|
|
104
|
+
|
|
105
|
+
#### Transactions with External Services
|
|
106
|
+
|
|
107
|
+
Transactions can include **any operations** that should be part of an atomic business operation, including external API calls. If an external service fails, the transaction should rollback:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// Domain service expressing a business operation
|
|
111
|
+
export class OrderService {
|
|
112
|
+
constructor(
|
|
113
|
+
private readonly orderRepository: OrderRepository,
|
|
114
|
+
private readonly inventoryRepository: InventoryRepository,
|
|
115
|
+
private readonly paymentService: PaymentService, // External service adapter
|
|
116
|
+
) {}
|
|
117
|
+
|
|
118
|
+
async createOrder(orderData: CreateOrderData): Promise<Result<Order, CreateOrderFailure>> {
|
|
119
|
+
// This is a domain concept: "Create order" is a single atomic business operation
|
|
120
|
+
return this.orderRepository.withTransaction(async (orderRepo) => {
|
|
121
|
+
// Step 1: Create order in database
|
|
122
|
+
const [order, orderFailure] = await orderRepo.save(createOrderEntity(orderData));
|
|
123
|
+
if (isFailure(SaveOrderFailure)(orderFailure)) {
|
|
124
|
+
return Fail(orderFailure);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Step 2: Deduct inventory in database
|
|
128
|
+
const inventoryRepo = this.inventoryRepository.withRepositoryManager(orderRepo);
|
|
129
|
+
const [inventory, inventoryFailure] = await inventoryRepo.deductStock(orderData.items);
|
|
130
|
+
if (isFailure(DeductInventoryFailure)(inventoryFailure)) {
|
|
131
|
+
// Transaction automatically rolls back order creation
|
|
132
|
+
return Fail(inventoryFailure);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Step 3: Charge payment via external API
|
|
136
|
+
// This is part of the same business transaction!
|
|
137
|
+
const [payment, paymentFailure] = await this.paymentService.chargePayment({
|
|
138
|
+
orderId: order.id,
|
|
139
|
+
amount: order.total,
|
|
140
|
+
customerId: order.customerId,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (isFailure(PaymentFailure)(paymentFailure)) {
|
|
144
|
+
// If payment fails, the transaction rolls back:
|
|
145
|
+
// - Order is NOT created
|
|
146
|
+
// - Inventory is NOT deducted
|
|
147
|
+
// - Payment is NOT charged
|
|
148
|
+
// All operations are atomic
|
|
149
|
+
return Fail(paymentFailure);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// All operations succeeded - transaction commits:
|
|
153
|
+
// - Order is created
|
|
154
|
+
// - Inventory is deducted
|
|
155
|
+
// - Payment is charged
|
|
156
|
+
return Ok(order);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
#### Example: User Registration with External Service
|
|
163
|
+
|
|
164
|
+
Another example showing how transactions abstract persistence and include external operations:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
export class UserService {
|
|
168
|
+
constructor(
|
|
169
|
+
private readonly userRepository: UserRepository,
|
|
170
|
+
private readonly profileRepository: ProfileRepository,
|
|
171
|
+
private readonly emailService: EmailService, // External service
|
|
172
|
+
private readonly auditService: AuditService, // External service
|
|
173
|
+
) {}
|
|
174
|
+
|
|
175
|
+
async registerUser(
|
|
176
|
+
registrationData: RegistrationData,
|
|
177
|
+
): Promise<Result<User, RegistrationFailure>> {
|
|
178
|
+
// Domain concept: "User registration" is an atomic business operation
|
|
179
|
+
return this.userRepository.withTransaction(async (userRepo) => {
|
|
180
|
+
// Step 1: Create user in database
|
|
181
|
+
const [user, userFailure] = await userRepo.save(createUserEntity(registrationData));
|
|
182
|
+
if (isFailure(SaveUserFailure)(userFailure)) {
|
|
183
|
+
return Fail(userFailure);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Step 2: Create profile in database
|
|
187
|
+
const profileRepo = this.profileRepository.withRepositoryManager(userRepo);
|
|
188
|
+
const [profile, profileFailure] = await profileRepo.save(
|
|
189
|
+
createProfileEntity(user.id, registrationData.profile),
|
|
190
|
+
);
|
|
191
|
+
if (isFailure(SaveProfileFailure)(profileFailure)) {
|
|
192
|
+
// Transaction rolls back user creation
|
|
193
|
+
return Fail(profileFailure);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Step 3: Send welcome email via external API
|
|
197
|
+
const [emailSent, emailFailure] = await this.emailService.sendWelcomeEmail(user.email);
|
|
198
|
+
if (isFailure(EmailServiceFailure)(emailFailure)) {
|
|
199
|
+
// If email fails, rollback entire registration:
|
|
200
|
+
// - User is NOT created
|
|
201
|
+
// - Profile is NOT created
|
|
202
|
+
// - Email is NOT sent
|
|
203
|
+
return Fail(emailFailure);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Step 4: Log audit event to external audit service
|
|
207
|
+
const [auditLogged, auditFailure] = await this.auditService.logEvent({
|
|
208
|
+
event: 'USER_REGISTERED',
|
|
209
|
+
userId: user.id,
|
|
210
|
+
timestamp: new Date(),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (isFailure(AuditServiceFailure)(auditFailure)) {
|
|
214
|
+
// If audit logging fails, rollback everything
|
|
215
|
+
return Fail(auditFailure);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// All operations succeeded - transaction commits
|
|
219
|
+
return Ok(user);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
#### Key Benefits
|
|
226
|
+
|
|
227
|
+
1. **Domain Abstraction**: Transactions are expressed as domain concepts, not database concepts
|
|
228
|
+
2. **Persistence Independence**: Can switch database implementations without changing domain logic
|
|
229
|
+
3. **Atomic Business Operations**: Express business rules about what operations must succeed together
|
|
230
|
+
4. **External Service Integration**: Include external API calls as part of atomic business operations
|
|
231
|
+
5. **Consistency**: Ensure all operations in a business transaction succeed or all fail
|
|
232
|
+
|
|
233
|
+
### Transaction Propagation
|
|
234
|
+
|
|
235
|
+
Share transactions across repositories:
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
async function updateUserAndOrders(userId: string, updates: UserUpdates): Promise<void> {
|
|
239
|
+
return this.userRepository.withTransaction(async (userRepo) => {
|
|
240
|
+
// Update user
|
|
241
|
+
await userRepo.save(updatedUser);
|
|
242
|
+
|
|
243
|
+
// Use same transaction for order repository
|
|
244
|
+
const orderRepo = this.orderRepository.withRepositoryManager(userRepo);
|
|
245
|
+
await orderRepo.updateOrdersForUser(userId, updates);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Locking
|
|
251
|
+
|
|
252
|
+
Use pessimistic locking for concurrent operations:
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
async function findByIdWithLock(id: string): Promise<UserEntity | null> {
|
|
256
|
+
const repository = this.getRepository(UserEntity);
|
|
257
|
+
return repository.findOne({
|
|
258
|
+
where: { id },
|
|
259
|
+
lock: this.lockInTransaction('pessimistic_write'),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Logger
|
|
265
|
+
|
|
266
|
+
Structured logging interface:
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
import { Logger } from '@jsfsi-core/ts-nodejs';
|
|
270
|
+
|
|
271
|
+
export class MyService {
|
|
272
|
+
constructor(private readonly logger: Logger) {}
|
|
273
|
+
|
|
274
|
+
async processOrder(orderId: string) {
|
|
275
|
+
this.logger.log('Processing order', { orderId });
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
// Process order
|
|
279
|
+
this.logger.verbose('Order processed successfully', { orderId });
|
|
280
|
+
} catch (error) {
|
|
281
|
+
this.logger.error('Failed to process order', { orderId, error });
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Log Levels
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
import { Logger, LogLevel } from '@jsfsi-core/ts-nodejs';
|
|
292
|
+
|
|
293
|
+
// Available log levels
|
|
294
|
+
type LogLevel = 'debug' | 'verbose' | 'log' | 'warn' | 'error' | 'fatal';
|
|
295
|
+
|
|
296
|
+
// Set log levels
|
|
297
|
+
logger.setLogLevels(['log', 'warn', 'error']);
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Logger Implementations
|
|
301
|
+
|
|
302
|
+
#### Console Logger
|
|
303
|
+
|
|
304
|
+
Basic console logger (for development):
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
import { ConsoleLogger } from './logger/ConsoleLogger';
|
|
308
|
+
|
|
309
|
+
const logger = new ConsoleLogger();
|
|
310
|
+
logger.log('Hello world');
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
#### GCP Logger
|
|
314
|
+
|
|
315
|
+
Google Cloud Platform structured logger compatible with **NestJS LoggerService interface**.
|
|
316
|
+
|
|
317
|
+
The GCP Logger automatically performs **data sanitization and redaction** for sensitive keys, ensuring that sensitive information (passwords, tokens, API keys, etc.) is never logged:
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
import { GCPLogger } from '@jsfsi-core/ts-nodejs';
|
|
321
|
+
|
|
322
|
+
// Initialize with module name (like NestJS Logger)
|
|
323
|
+
const logger = new GCPLogger('UserService');
|
|
324
|
+
|
|
325
|
+
// Sensitive keys are automatically redacted
|
|
326
|
+
logger.log('User login attempt', {
|
|
327
|
+
userId: '123',
|
|
328
|
+
email: 'user@example.com',
|
|
329
|
+
password: 'secret123', // Will be redacted as [HIDDEN BY LOGGER]
|
|
330
|
+
token: 'abc123xyz', // Will be redacted as [HIDDEN BY LOGGER]
|
|
331
|
+
authorization: 'Bearer token', // Will be redacted as [HIDDEN BY LOGGER]
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Output: Sensitive fields are automatically sanitized
|
|
335
|
+
// {
|
|
336
|
+
// "severity": "INFO",
|
|
337
|
+
// "message": {
|
|
338
|
+
// "textPayload": "User login attempt",
|
|
339
|
+
// "metadata": {
|
|
340
|
+
// "userId": "123",
|
|
341
|
+
// "email": "user@example.com",
|
|
342
|
+
// "password": "[HIDDEN BY LOGGER]",
|
|
343
|
+
// "token": "[HIDDEN BY LOGGER]",
|
|
344
|
+
// "authorization": "[HIDDEN BY LOGGER]"
|
|
345
|
+
// }
|
|
346
|
+
// }
|
|
347
|
+
// }
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
**Automatically redacted sensitive keys include:**
|
|
351
|
+
|
|
352
|
+
- `password`, `pass`, `psw`
|
|
353
|
+
- `token`, `access_token`
|
|
354
|
+
- `authorization`, `authentication`, `auth`
|
|
355
|
+
- `x-api-key`, `x-api-token`, `x-key`, `x-token`
|
|
356
|
+
- `cookie`
|
|
357
|
+
- `secret`, `client-secret`
|
|
358
|
+
- `credentials`
|
|
359
|
+
|
|
360
|
+
**Features:**
|
|
361
|
+
|
|
362
|
+
- ✅ Compatible with **NestJS LoggerService** interface - can be used directly in NestJS applications
|
|
363
|
+
- ✅ **Automatic data sanitization** - sensitive keys are automatically redacted
|
|
364
|
+
- ✅ **Structured logging** - logs formatted for Google Cloud Platform
|
|
365
|
+
- ✅ **Safe stringification** - handles circular references safely
|
|
366
|
+
- ✅ **Severity mapping** - maps log levels to GCP severity levels
|
|
367
|
+
|
|
368
|
+
#### Mock Logger
|
|
369
|
+
|
|
370
|
+
For testing:
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
import { MockLogger } from '@jsfsi-core/ts-nodejs';
|
|
374
|
+
|
|
375
|
+
const logger = new MockLogger();
|
|
376
|
+
logger.log('Hello world');
|
|
377
|
+
|
|
378
|
+
// Assertions
|
|
379
|
+
expect(logger.logs).toContainEqual({ level: 'log', message: 'Hello world' });
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Environment Loader
|
|
383
|
+
|
|
384
|
+
Type-safe environment variable loading:
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
import { loadEnv } from '@jsfsi-core/ts-nodejs';
|
|
388
|
+
|
|
389
|
+
// Load .env file
|
|
390
|
+
loadEnv();
|
|
391
|
+
|
|
392
|
+
// Access environment variables
|
|
393
|
+
const port = process.env.PORT;
|
|
394
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**Note**: For type-safe configuration with validation, use `@jsfsi-core/ts-crossplatform`'s `parseConfig` with Zod schemas.
|
|
398
|
+
|
|
399
|
+
## 📝 Naming Conventions
|
|
400
|
+
|
|
401
|
+
### Repositories
|
|
402
|
+
|
|
403
|
+
- **Repositories**: PascalCase suffix with `Repository` (e.g., `UserRepository`, `OrderRepository`)
|
|
404
|
+
- **Methods**: Use descriptive names (`findById`, `save`, `delete`)
|
|
405
|
+
|
|
406
|
+
### Entities
|
|
407
|
+
|
|
408
|
+
- **Entities**: PascalCase suffix with `Entity` (e.g., `UserEntity`, `OrderEntity`)
|
|
409
|
+
|
|
410
|
+
### Services
|
|
411
|
+
|
|
412
|
+
- **Services**: PascalCase suffix with `Service` (e.g., `UserService`, `OrderService`)
|
|
413
|
+
|
|
414
|
+
## 🧪 Testing Principles
|
|
415
|
+
|
|
416
|
+
### Testing Repositories
|
|
417
|
+
|
|
418
|
+
Use `TransactionalRepositoryMock` for testing:
|
|
419
|
+
|
|
420
|
+
```typescript
|
|
421
|
+
import { TransactionalRepositoryMock } from '@jsfsi-core/ts-nodejs';
|
|
422
|
+
|
|
423
|
+
describe('UserRepository', () => {
|
|
424
|
+
let repository: UserRepository;
|
|
425
|
+
|
|
426
|
+
beforeEach(() => {
|
|
427
|
+
const mockDataSource = {} as DataSource;
|
|
428
|
+
repository = new UserRepository(mockDataSource);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('finds user by id', async () => {
|
|
432
|
+
const user = await repository.findById('123');
|
|
433
|
+
// Test implementation
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Testing with Transactions
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
describe('UserService', () => {
|
|
442
|
+
it('creates user within transaction', async () => {
|
|
443
|
+
const result = await userService.createUserWithProfile(userData, profileData);
|
|
444
|
+
|
|
445
|
+
// Verify both user and profile were created
|
|
446
|
+
expect(result.user).toBeDefined();
|
|
447
|
+
expect(result.profile).toBeDefined();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Testing Logging
|
|
453
|
+
|
|
454
|
+
Use `MockLogger` for testing:
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
import { MockLogger } from '@jsfsi-core/ts-nodejs';
|
|
458
|
+
|
|
459
|
+
describe('UserService', () => {
|
|
460
|
+
let logger: MockLogger;
|
|
461
|
+
let service: UserService;
|
|
462
|
+
|
|
463
|
+
beforeEach(() => {
|
|
464
|
+
logger = new MockLogger();
|
|
465
|
+
service = new UserService(logger);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('logs error on failure', async () => {
|
|
469
|
+
await service.processOrder('invalid-id');
|
|
470
|
+
|
|
471
|
+
expect(logger.error).toHaveBeenCalled();
|
|
472
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
473
|
+
expect.stringContaining('Failed'),
|
|
474
|
+
expect.any(Object),
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
## ⚠️ Error Handling Principles
|
|
481
|
+
|
|
482
|
+
### Result Types in Repository Methods
|
|
483
|
+
|
|
484
|
+
**Repositories should return Result types** when operations can fail:
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
import { Result, Ok, Fail, isFailure } from '@jsfsi-core/ts-crossplatform';
|
|
488
|
+
|
|
489
|
+
// ✅ Good - Return Result type
|
|
490
|
+
async findById(id: string): Promise<Result<UserEntity, UserNotFoundFailure>> {
|
|
491
|
+
const repository = this.getRepository(UserEntity);
|
|
492
|
+
const user = await repository.findOne({ where: { id } });
|
|
493
|
+
|
|
494
|
+
if (!user) {
|
|
495
|
+
return Fail(new UserNotFoundFailure(id));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return Ok(user);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ❌ Bad - Throwing exceptions
|
|
502
|
+
async findById(id: string): Promise<UserEntity> {
|
|
503
|
+
const repository = this.getRepository(UserEntity);
|
|
504
|
+
const user = await repository.findOne({ where: { id } });
|
|
505
|
+
|
|
506
|
+
if (!user) {
|
|
507
|
+
throw new Error('User not found'); // Don't throw in repository
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return user;
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### Transaction Error Handling
|
|
515
|
+
|
|
516
|
+
Transactions automatically rollback on errors:
|
|
517
|
+
|
|
518
|
+
```typescript
|
|
519
|
+
async function createUserWithProfile(
|
|
520
|
+
userData: CreateUserData,
|
|
521
|
+
profileData: CreateProfileData,
|
|
522
|
+
): Promise<Result<User, CreateUserFailure>> {
|
|
523
|
+
return this.userRepository.withTransaction(async (userRepo) => {
|
|
524
|
+
const [user, userFailure] = await userRepo.save(userData);
|
|
525
|
+
|
|
526
|
+
if (isFailure(CreateUserFailure)(userFailure)) {
|
|
527
|
+
// Transaction automatically rolls back
|
|
528
|
+
return Fail(userFailure);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const [profile, profileFailure] = await this.profileRepository
|
|
532
|
+
.withRepositoryManager(userRepo)
|
|
533
|
+
.save(profileData);
|
|
534
|
+
|
|
535
|
+
if (isFailure(CreateProfileFailure)(profileFailure)) {
|
|
536
|
+
// Transaction automatically rolls back
|
|
537
|
+
return Fail(profileFailure);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return Ok({ user, profile });
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
### Try-Catch at Edges
|
|
546
|
+
|
|
547
|
+
**Try-catch should only be used at the edge** (when interfacing with external systems):
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
// ✅ Good - In adapter (edge)
|
|
551
|
+
export class DatabaseAdapter implements IUserRepository {
|
|
552
|
+
async save(user: UserEntity): Promise<Result<UserEntity, DatabaseFailure>> {
|
|
553
|
+
try {
|
|
554
|
+
const saved = await this.repository.save(user);
|
|
555
|
+
return Ok(saved);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
return Fail(new DatabaseFailure(error));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ✅ Good - Domain service (no try-catch)
|
|
563
|
+
export class UserService {
|
|
564
|
+
async createUser(data: CreateUserData): Promise<Result<User, CreateUserFailure>> {
|
|
565
|
+
// No try-catch - errors are handled as Result types
|
|
566
|
+
return this.userRepository.save(data);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
## 🎯 Domain-Driven Design
|
|
572
|
+
|
|
573
|
+
### Repository Pattern
|
|
574
|
+
|
|
575
|
+
Repositories abstract database access:
|
|
576
|
+
|
|
577
|
+
```typescript
|
|
578
|
+
// Domain interface
|
|
579
|
+
export interface IUserRepository {
|
|
580
|
+
findById(id: string): Promise<Result<User, UserNotFoundFailure>>;
|
|
581
|
+
save(user: User): Promise<Result<User, SaveUserFailure>>;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Implementation in adapter
|
|
585
|
+
export class UserRepository extends TransactionalRepository implements IUserRepository {
|
|
586
|
+
async findById(id: string): Promise<Result<User, UserNotFoundFailure>> {
|
|
587
|
+
const repository = this.getRepository(UserEntity);
|
|
588
|
+
const entity = await repository.findOne({ where: { id } });
|
|
589
|
+
|
|
590
|
+
if (!entity) {
|
|
591
|
+
return Fail(new UserNotFoundFailure(id));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return Ok(mapEntityToDomain(entity));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### Entity Mapping
|
|
600
|
+
|
|
601
|
+
Map between database entities and domain models:
|
|
602
|
+
|
|
603
|
+
```typescript
|
|
604
|
+
// Domain model
|
|
605
|
+
export type User = {
|
|
606
|
+
id: string;
|
|
607
|
+
email: string;
|
|
608
|
+
name: string;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
// Database entity
|
|
612
|
+
@Entity('users')
|
|
613
|
+
export class UserEntity {
|
|
614
|
+
@PrimaryColumn('uuid')
|
|
615
|
+
id: string;
|
|
616
|
+
|
|
617
|
+
@Column()
|
|
618
|
+
email: string;
|
|
619
|
+
|
|
620
|
+
@Column()
|
|
621
|
+
name: string;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Mapping functions
|
|
625
|
+
function mapEntityToDomain(entity: UserEntity): User {
|
|
626
|
+
return {
|
|
627
|
+
id: entity.id,
|
|
628
|
+
email: entity.email,
|
|
629
|
+
name: entity.name,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function mapDomainToEntity(user: User): UserEntity {
|
|
634
|
+
const entity = new UserEntity();
|
|
635
|
+
entity.id = user.id;
|
|
636
|
+
entity.email = user.email;
|
|
637
|
+
entity.name = user.name;
|
|
638
|
+
return entity;
|
|
639
|
+
}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
## 🔄 Result Class Integration
|
|
643
|
+
|
|
644
|
+
### Repository Methods
|
|
645
|
+
|
|
646
|
+
Always return Result types from repository methods:
|
|
647
|
+
|
|
648
|
+
```typescript
|
|
649
|
+
export class UserRepository extends TransactionalRepository {
|
|
650
|
+
async findById(id: string): Promise<Result<UserEntity, UserNotFoundFailure>> {
|
|
651
|
+
const repository = this.getRepository(UserEntity);
|
|
652
|
+
const user = await repository.findOne({ where: { id } });
|
|
653
|
+
|
|
654
|
+
if (!user) {
|
|
655
|
+
return Fail(new UserNotFoundFailure(id));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return Ok(user);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async save(user: UserEntity): Promise<Result<UserEntity, SaveUserFailure>> {
|
|
662
|
+
try {
|
|
663
|
+
const repository = this.getRepository(UserEntity);
|
|
664
|
+
const saved = await repository.save(user);
|
|
665
|
+
return Ok(saved);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
return Fail(new SaveUserFailure(error));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### Service Layer
|
|
674
|
+
|
|
675
|
+
Chain Result types in service layer:
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
export class UserService {
|
|
679
|
+
async createUser(data: CreateUserData): Promise<Result<User, CreateUserFailure>> {
|
|
680
|
+
// Validate first
|
|
681
|
+
const [validated, validationFailure] = validateUserData(data);
|
|
682
|
+
if (isFailure(ValidationFailure)(validationFailure)) {
|
|
683
|
+
return Fail(validationFailure);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Save to database
|
|
687
|
+
const [user, saveFailure] = await this.userRepository.save(validated);
|
|
688
|
+
if (isFailure(SaveUserFailure)(saveFailure)) {
|
|
689
|
+
return Fail(saveFailure);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return Ok(user);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
## 📚 Best Practices
|
|
698
|
+
|
|
699
|
+
### 1. Transaction Boundaries
|
|
700
|
+
|
|
701
|
+
Keep transactions as short as possible:
|
|
702
|
+
|
|
703
|
+
```typescript
|
|
704
|
+
// ✅ Good - Short transaction
|
|
705
|
+
async function createUser(data: CreateUserData): Promise<Result<User>> {
|
|
706
|
+
return this.repository.withTransaction(async (repo) => {
|
|
707
|
+
return repo.save(data);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ❌ Bad - Long transaction with external calls
|
|
712
|
+
async function createUser(data: CreateUserData): Promise<Result<User>> {
|
|
713
|
+
return this.repository.withTransaction(async (repo) => {
|
|
714
|
+
const user = await repo.save(data);
|
|
715
|
+
await this.emailService.sendWelcomeEmail(user.email); // Don't do this in transaction
|
|
716
|
+
await this.cacheService.invalidate(user.id); // Don't do this in transaction
|
|
717
|
+
return Ok(user);
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
### 2. Repository Methods
|
|
723
|
+
|
|
724
|
+
Keep repository methods focused on data access:
|
|
725
|
+
|
|
726
|
+
```typescript
|
|
727
|
+
// ✅ Good - Focused data access
|
|
728
|
+
async findById(id: string): Promise<Result<UserEntity, UserNotFoundFailure>> {
|
|
729
|
+
const repository = this.getRepository(UserEntity);
|
|
730
|
+
const user = await repository.findOne({ where: { id } });
|
|
731
|
+
return user ? Ok(user) : Fail(new UserNotFoundFailure(id));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ❌ Bad - Business logic in repository
|
|
735
|
+
async findById(id: string): Promise<Result<UserEntity, UserNotFoundFailure>> {
|
|
736
|
+
const repository = this.getRepository(UserEntity);
|
|
737
|
+
const user = await repository.findOne({ where: { id } });
|
|
738
|
+
|
|
739
|
+
// Don't put business logic here
|
|
740
|
+
if (user && user.isActive) {
|
|
741
|
+
user.lastAccessed = new Date();
|
|
742
|
+
await repository.save(user);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return user ? Ok(user) : Fail(new UserNotFoundFailure(id));
|
|
746
|
+
}
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
### 3. Error Handling
|
|
750
|
+
|
|
751
|
+
Use Result types, not exceptions:
|
|
752
|
+
|
|
753
|
+
```typescript
|
|
754
|
+
// ✅ Good
|
|
755
|
+
async findById(id: string): Promise<Result<UserEntity, UserNotFoundFailure>> {
|
|
756
|
+
const user = await this.getRepository(UserEntity).findOne({ where: { id } });
|
|
757
|
+
return user ? Ok(user) : Fail(new UserNotFoundFailure(id));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ❌ Bad
|
|
761
|
+
async findById(id: string): Promise<UserEntity> {
|
|
762
|
+
const user = await this.getRepository(UserEntity).findOne({ where: { id } });
|
|
763
|
+
if (!user) {
|
|
764
|
+
throw new Error('User not found');
|
|
765
|
+
}
|
|
766
|
+
return user;
|
|
767
|
+
}
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### 4. Logging
|
|
771
|
+
|
|
772
|
+
Use structured logging:
|
|
773
|
+
|
|
774
|
+
```typescript
|
|
775
|
+
// ✅ Good - Structured logging
|
|
776
|
+
this.logger.log('User created', { userId: user.id, email: user.email });
|
|
777
|
+
|
|
778
|
+
// ❌ Bad - String interpolation
|
|
779
|
+
this.logger.log(`User ${user.id} created with email ${user.email}`);
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
### 5. Environment Variables
|
|
783
|
+
|
|
784
|
+
Use type-safe configuration:
|
|
785
|
+
|
|
786
|
+
```typescript
|
|
787
|
+
// ✅ Good - Type-safe with Zod
|
|
788
|
+
import { parseConfig } from '@jsfsi-core/ts-crossplatform';
|
|
789
|
+
import { z } from 'zod';
|
|
790
|
+
|
|
791
|
+
const ConfigSchema = z.object({
|
|
792
|
+
DATABASE_URL: z.string().url(),
|
|
793
|
+
PORT: z.string().transform(Number),
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
export const config = parseConfig(ConfigSchema);
|
|
797
|
+
|
|
798
|
+
// ❌ Bad - Direct environment access
|
|
799
|
+
const dbUrl = process.env.DATABASE_URL; // Not type-safe
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
## 🔗 Additional Resources
|
|
803
|
+
|
|
804
|
+
### TypeORM
|
|
805
|
+
|
|
806
|
+
- [TypeORM Documentation](https://typeorm.io/)
|
|
807
|
+
- [TypeORM Transactions](https://typeorm.io/transactions)
|
|
808
|
+
|
|
809
|
+
### Architecture
|
|
810
|
+
|
|
811
|
+
- [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html)
|
|
812
|
+
- [Domain-Driven Design](https://www.domainlanguage.com/ddd/)
|
|
813
|
+
|
|
814
|
+
### Error Handling
|
|
815
|
+
|
|
816
|
+
- [Result Type Pattern](https://enterprisecraftsmanship.com/posts/functional-c-handling-failures-input-errors/)
|
|
817
|
+
|
|
818
|
+
## 📄 License
|
|
819
|
+
|
|
820
|
+
ISC
|