Node.js-specific utilities for database management, logging, and environment configuration following hexagonal architecture principles.
npm install @jsfsi-core/ts-nodejsNode.js-specific utilities for database management, logging, and environment configuration following hexagonal architecture principles.
``bash`
npm install @jsfsi-core/ts-nodejs
Dependencies:
- typeorm - TypeORM for database managementdotenv
- - Environment variable loading
This package provides Node.js-specific implementations for:
- Database: Transactional repositories with TypeORM integration
- Logging: Structured logging interface with multiple implementations
- Environment: Type-safe environment variable loading
``
src/
โโโ database/
โ โโโ TransactionalRepository.ts # Base transactional repository
โ โโโ TransactionalEntity.ts # Entity interface
โ โโโ postgres/ # PostgreSQL utilities
โโโ logger/
โ โโโ Logger.ts # Logger interface
โ โโโ GCPLogger.ts # Google Cloud Platform logger
โ โโโ MockLogger.ts # Test logger
โโโ env/
โโโ env.loader.ts # Environment loader
Type-safe transactional repository base class for database operations:
`typescript
import { TransactionalRepository } from '@jsfsi-core/ts-nodejs';
import { DataSource } from 'typeorm';
import { UserEntity } from './entities/UserEntity';
export class UserRepository extends TransactionalRepository {
constructor(dataSource: DataSource) {
super(dataSource);
}
async findById(id: string): Promise
const repository = this.getRepository(UserEntity);
return repository.findOne({ where: { id } });
}
async save(user: UserEntity): Promise
const repository = this.getRepository(UserEntity);
return repository.save(user);
}
}
`
Execute operations within a transaction:
`typescript
async function createUserWithProfile(
userData: CreateUserData,
profileData: CreateProfileData,
): Promise
return this.userRepository.withTransaction(async (userRepo) => {
// All operations within this callback run in a single transaction
const user = await userRepo.save(createUserEntity(userData));
const profileRepo = this.profileRepository.withRepositoryManager(userRepo);
const profile = await profileRepo.save(createProfileEntity(user.id, profileData));
return { user, profile };
});
}
`
Transactions are domain concepts, not persistence concepts.
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.
#### Why Transactions Belong to Domain
Transactions express business rules about consistency and atomicity:
- Business Rules: "When creating an order, both the order and payment must succeed together"
- Consistency: "User registration includes creating a profile and sending a welcome email - all must succeed or all must fail"
- Atomicity: "Inventory deduction and order creation must happen together"
The transactional repository abstraction allows domain services to express these business rules without being tied to a specific persistence technology (TypeORM, Prisma, etc.).
#### Transactions with External Services
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:
`typescript
// Domain service expressing a business operation
export class OrderService {
constructor(
private readonly orderRepository: OrderRepository,
private readonly inventoryRepository: InventoryRepository,
private readonly paymentService: PaymentService, // External service adapter
) {}
async createOrder(orderData: CreateOrderData): Promise
// This is a domain concept: "Create order" is a single atomic business operation
return this.orderRepository.withTransaction(async (orderRepo) => {
// Step 1: Create order in database
const [order, orderFailure] = await orderRepo.save(createOrderEntity(orderData));
if (isFailure(SaveOrderFailure)(orderFailure)) {
return Fail(orderFailure);
}
// Step 2: Deduct inventory in database
const inventoryRepo = this.inventoryRepository.withRepositoryManager(orderRepo);
const [inventory, inventoryFailure] = await inventoryRepo.deductStock(orderData.items);
if (isFailure(DeductInventoryFailure)(inventoryFailure)) {
// Transaction automatically rolls back order creation
return Fail(inventoryFailure);
}
// Step 3: Charge payment via external API
// This is part of the same business transaction!
const [payment, paymentFailure] = await this.paymentService.chargePayment({
orderId: order.id,
amount: order.total,
customerId: order.customerId,
});
if (isFailure(PaymentFailure)(paymentFailure)) {
// If payment fails, the transaction rolls back:
// - Order is NOT created
// - Inventory is NOT deducted
// - Payment is NOT charged
// All operations are atomic
return Fail(paymentFailure);
}
// All operations succeeded - transaction commits:
// - Order is created
// - Inventory is deducted
// - Payment is charged
return Ok(order);
});
}
}
`
#### Example: User Registration with External Service
Another example showing how transactions abstract persistence and include external operations:
`typescript
export class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly profileRepository: ProfileRepository,
private readonly emailService: EmailService, // External service
private readonly auditService: AuditService, // External service
) {}
async registerUser(
registrationData: RegistrationData,
): Promise
// Domain concept: "User registration" is an atomic business operation
return this.userRepository.withTransaction(async (userRepo) => {
// Step 1: Create user in database
const [user, userFailure] = await userRepo.save(createUserEntity(registrationData));
if (isFailure(SaveUserFailure)(userFailure)) {
return Fail(userFailure);
}
// Step 2: Create profile in database
const profileRepo = this.profileRepository.withRepositoryManager(userRepo);
const [profile, profileFailure] = await profileRepo.save(
createProfileEntity(user.id, registrationData.profile),
);
if (isFailure(SaveProfileFailure)(profileFailure)) {
// Transaction rolls back user creation
return Fail(profileFailure);
}
// Step 3: Send welcome email via external API
const [emailSent, emailFailure] = await this.emailService.sendWelcomeEmail(user.email);
if (isFailure(EmailServiceFailure)(emailFailure)) {
// If email fails, rollback entire registration:
// - User is NOT created
// - Profile is NOT created
// - Email is NOT sent
return Fail(emailFailure);
}
// Step 4: Log audit event to external audit service
const [auditLogged, auditFailure] = await this.auditService.logEvent({
event: 'USER_REGISTERED',
userId: user.id,
timestamp: new Date(),
});
if (isFailure(AuditServiceFailure)(auditFailure)) {
// If audit logging fails, rollback everything
return Fail(auditFailure);
}
// All operations succeeded - transaction commits
return Ok(user);
});
}
}
`
#### Key Benefits
1. Domain Abstraction: Transactions are expressed as domain concepts, not database concepts
2. Persistence Independence: Can switch database implementations without changing domain logic
3. Atomic Business Operations: Express business rules about what operations must succeed together
4. External Service Integration: Include external API calls as part of atomic business operations
5. Consistency: Ensure all operations in a business transaction succeed or all fail
Share transactions across repositories:
`typescript
async function updateUserAndOrders(userId: string, updates: UserUpdates): Promise
return this.userRepository.withTransaction(async (userRepo) => {
// Update user
await userRepo.save(updatedUser);
// Use same transaction for order repository
const orderRepo = this.orderRepository.withRepositoryManager(userRepo);
await orderRepo.updateOrdersForUser(userId, updates);
});
}
`
Use pessimistic locking for concurrent operations:
`typescript`
async function findByIdWithLock(id: string): Promise
const repository = this.getRepository(UserEntity);
return repository.findOne({
where: { id },
lock: this.lockInTransaction('pessimistic_write'),
});
}
Structured logging interface:
`typescript
import { Logger } from '@jsfsi-core/ts-nodejs';
export class MyService {
constructor(private readonly logger: Logger) {}
async processOrder(orderId: string) {
this.logger.log('Processing order', { orderId });
try {
// Process order
this.logger.verbose('Order processed successfully', { orderId });
} catch (error) {
this.logger.error('Failed to process order', { orderId, error });
throw error;
}
}
}
`
`typescript
import { Logger, LogLevel } from '@jsfsi-core/ts-nodejs';
// Available log levels
type LogLevel = 'debug' | 'verbose' | 'log' | 'warn' | 'error' | 'fatal';
// Set log levels
logger.setLogLevels(['log', 'warn', 'error']);
`
#### Console Logger
Basic console logger (for development):
`typescript
import { ConsoleLogger } from './logger/ConsoleLogger';
const logger = new ConsoleLogger();
logger.log('Hello world');
`
#### GCP Logger
Google Cloud Platform structured logger compatible with NestJS LoggerService interface.
The GCP Logger automatically performs data sanitization and redaction for sensitive keys, ensuring that sensitive information (passwords, tokens, API keys, etc.) is never logged:
`typescript
import { GCPLogger } from '@jsfsi-core/ts-nodejs';
// Initialize with module name (like NestJS Logger)
const logger = new GCPLogger('UserService');
// Sensitive keys are automatically redacted
logger.log('User login attempt', {
userId: '123',
email: 'user@example.com',
password: 'secret123', // Will be redacted as [HIDDEN BY LOGGER]
token: 'abc123xyz', // Will be redacted as [HIDDEN BY LOGGER]
authorization: 'Bearer token', // Will be redacted as [HIDDEN BY LOGGER]
});
// Output: Sensitive fields are automatically sanitized
// {
// "severity": "INFO",
// "message": {
// "textPayload": "User login attempt",
// "metadata": {
// "userId": "123",
// "email": "user@example.com",
// "password": "[HIDDEN BY LOGGER]",
// "token": "[HIDDEN BY LOGGER]",
// "authorization": "[HIDDEN BY LOGGER]"
// }
// }
// }
`
Automatically redacted sensitive keys include:
- password, pass, pswtoken
- , access_tokenauthorization
- , authentication, authx-api-key
- , x-api-token, x-key, x-tokencookie
- secret
- , client-secretcredentials
-
Features:
- โ
Compatible with NestJS LoggerService interface - can be used directly in NestJS applications
- โ
Automatic data sanitization - sensitive keys are automatically redacted
- โ
Structured logging - logs formatted for Google Cloud Platform
- โ
Safe stringification - handles circular references safely
- โ
Severity mapping - maps log levels to GCP severity levels
#### Mock Logger
For testing:
`typescript
import { MockLogger } from '@jsfsi-core/ts-nodejs';
const logger = new MockLogger();
logger.log('Hello world');
// Assertions
expect(logger.logs).toContainEqual({ level: 'log', message: 'Hello world' });
`
Type-safe environment variable loading:
`typescript
import { loadEnvConfig } from '@jsfsi-core/ts-nodejs';
// Load .env file from configuration directory
loadEnvConfig({
configPath: './configuration',
env: 'development', // optional, defaults to no suffix
});
// Access environment variables
const port = process.env.PORT;
const dbUrl = process.env.DATABASE_URL;
`
Note: For type-safe configuration with validation, use @jsfsi-core/ts-crossplatform's parseConfig with Zod schemas.
- Repositories: PascalCase suffix with Repository (e.g., UserRepository, OrderRepository)findById
- Methods: Use descriptive names (, save, delete)
- Entities: PascalCase suffix with Entity (e.g., UserEntity, OrderEntity)
- Services: PascalCase suffix with Service (e.g., UserService, OrderService)
Use buildTransactionalRepositoryMock for testing:
`typescript
import { buildTransactionalRepositoryMock } from '@jsfsi-core/ts-nodejs';
describe('UserRepository', () => {
let repository: UserRepository;
beforeEach(() => {
const mockDataSource = {} as DataSource;
const repositoryInstance = new UserRepository(mockDataSource);
repository = buildTransactionalRepositoryMock(repositoryInstance);
});
it('finds user by id', async () => {
const user = await repository.findById('123');
// Test implementation
});
});
`
`typescript
describe('UserService', () => {
it('creates user within transaction', async () => {
const result = await userService.createUserWithProfile(userData, profileData);
// Verify both user and profile were created
expect(result.user).toBeDefined();
expect(result.profile).toBeDefined();
});
});
`
Use MockLogger for testing (it provides no-op implementations of all logging methods):
`typescript
import { MockLogger } from '@jsfsi-core/ts-nodejs';
describe('UserService', () => {
let logger: MockLogger;
let service: UserService;
beforeEach(() => {
logger = new MockLogger();
service = new UserService(logger);
});
it('processes order without throwing', async () => {
// MockLogger silently absorbs all logs, making tests cleaner
await expect(service.processOrder('invalid-id')).resolves.not.toThrow();
});
});
`
Repositories should return Result types when operations can fail:
`typescript
import { Result, Ok, Fail, isFailure } from '@jsfsi-core/ts-crossplatform';
// โ
Good - Return Result type
async findById(id: string): Promise
const repository = this.getRepository(UserEntity);
const user = await repository.findOne({ where: { id } });
if (!user) {
return Fail(new UserNotFoundFailure(id));
}
return Ok(user);
}
// โ Bad - Throwing exceptions
async findById(id: string): Promise
const repository = this.getRepository(UserEntity);
const user = await repository.findOne({ where: { id } });
if (!user) {
throw new Error('User not found'); // Don't throw in repository
}
return user;
}
`
Transactions automatically rollback on errors:
`typescript
async function createUserWithProfile(
userData: CreateUserData,
profileData: CreateProfileData,
): Promise
return this.userRepository.withTransaction(async (userRepo) => {
const [user, userFailure] = await userRepo.save(userData);
if (isFailure(CreateUserFailure)(userFailure)) {
// Transaction automatically rolls back
return Fail(userFailure);
}
const [profile, profileFailure] = await this.profileRepository
.withRepositoryManager(userRepo)
.save(profileData);
if (isFailure(CreateProfileFailure)(profileFailure)) {
// Transaction automatically rolls back
return Fail(profileFailure);
}
return Ok({ user, profile });
});
}
`
Try-catch should only be used at the edge (when interfacing with external systems):
`typescript
// โ
Good - In adapter (edge)
export class DatabaseAdapter implements IUserRepository {
async save(user: UserEntity): Promise
try {
const saved = await this.repository.save(user);
return Ok(saved);
} catch (error) {
return Fail(new DatabaseFailure(error));
}
}
}
// โ
Good - Domain service (no try-catch)
export class UserService {
async createUser(data: CreateUserData): Promise
// No try-catch - errors are handled as Result types
return this.userRepository.save(data);
}
}
`
Repositories abstract database access:
`typescript
// Domain interface
export interface IUserRepository {
findById(id: string): Promise
save(user: User): Promise
}
// Implementation in adapter
export class UserRepository extends TransactionalRepository implements IUserRepository {
async findById(id: string): Promise
const repository = this.getRepository(UserEntity);
const entity = await repository.findOne({ where: { id } });
if (!entity) {
return Fail(new UserNotFoundFailure(id));
}
return Ok(mapEntityToDomain(entity));
}
}
`
Map between database entities and domain models:
`typescript
// Domain model
export type User = {
id: string;
email: string;
name: string;
};
// Database entity
@Entity('users')
export class UserEntity {
@PrimaryColumn('uuid')
id: string;
@Column()
email: string;
@Column()
name: string;
}
// Mapping functions
function mapEntityToDomain(entity: UserEntity): User {
return {
id: entity.id,
email: entity.email,
name: entity.name,
};
}
function mapDomainToEntity(user: User): UserEntity {
const entity = new UserEntity();
entity.id = user.id;
entity.email = user.email;
entity.name = user.name;
return entity;
}
`
Always return Result types from repository methods:
`typescript
export class UserRepository extends TransactionalRepository {
async findById(id: string): Promise
const repository = this.getRepository(UserEntity);
const user = await repository.findOne({ where: { id } });
if (!user) {
return Fail(new UserNotFoundFailure(id));
}
return Ok(user);
}
async save(user: UserEntity): Promise
try {
const repository = this.getRepository(UserEntity);
const saved = await repository.save(user);
return Ok(saved);
} catch (error) {
return Fail(new SaveUserFailure(error));
}
}
}
`
Chain Result types in service layer:
`typescript
export class UserService {
async createUser(data: CreateUserData): Promise
// Validate first
const [validated, validationFailure] = validateUserData(data);
if (isFailure(ValidationFailure)(validationFailure)) {
return Fail(validationFailure);
}
// Save to database
const [user, saveFailure] = await this.userRepository.save(validated);
if (isFailure(SaveUserFailure)(saveFailure)) {
return Fail(saveFailure);
}
return Ok(user);
}
}
`
Keep transactions as short as possible:
`typescript
// โ
Good - Short transaction
async function createUser(data: CreateUserData): Promise
return this.repository.withTransaction(async (repo) => {
return repo.save(data);
});
}
// โ Bad - Long transaction with external calls
async function createUser(data: CreateUserData): Promise
return this.repository.withTransaction(async (repo) => {
const user = await repo.save(data);
await this.emailService.sendWelcomeEmail(user.email); // Don't do this in transaction
await this.cacheService.invalidate(user.id); // Don't do this in transaction
return Ok(user);
});
}
`
Keep repository methods focused on data access:
`typescript
// โ
Good - Focused data access
async findById(id: string): Promise
const repository = this.getRepository(UserEntity);
const user = await repository.findOne({ where: { id } });
return user ? Ok(user) : Fail(new UserNotFoundFailure(id));
}
// โ Bad - Business logic in repository
async findById(id: string): Promise
const repository = this.getRepository(UserEntity);
const user = await repository.findOne({ where: { id } });
// Don't put business logic here
if (user && user.isActive) {
user.lastAccessed = new Date();
await repository.save(user);
}
return user ? Ok(user) : Fail(new UserNotFoundFailure(id));
}
`
Use Result types, not exceptions:
`typescript
// โ
Good
async findById(id: string): Promise
const user = await this.getRepository(UserEntity).findOne({ where: { id } });
return user ? Ok(user) : Fail(new UserNotFoundFailure(id));
}
// โ Bad
async findById(id: string): Promise
const user = await this.getRepository(UserEntity).findOne({ where: { id } });
if (!user) {
throw new Error('User not found');
}
return user;
}
`
Use structured logging:
`typescript
// โ
Good - Structured logging
this.logger.log('User created', { userId: user.id, email: user.email });
// โ Bad - String interpolation
this.logger.log(User ${user.id} created with email ${user.email});`
Use type-safe configuration:
`typescript
// โ
Good - Type-safe with Zod
import { parseConfig } from '@jsfsi-core/ts-crossplatform';
import { z } from 'zod';
const ConfigSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.string().transform(Number),
});
export const config = parseConfig(ConfigSchema);
// โ Bad - Direct environment access
const dbUrl = process.env.DATABASE_URL; // Not type-safe
``
- TypeORM Documentation
- TypeORM Transactions
- Repository Pattern
- Domain-Driven Design
ISC