メインコンテンツまでスキップ
バージョン: 0.13.0 (Previous)

🔧 合成ユーティリティ

Nodeblocks SDK は、ハンドラーの合成、非同期操作の処理、複雑なビジネスロジックパイプラインの構築に不可欠なユーティリティを提供します。これらのユーティリティにより、単純な関数を予測可能な方法で組み合わせ、複雑でエラーセーフなパイプラインを構築できます。


🎯 概要

合成ユーティリティは、堅牢で保守しやすいビジネスロジックの構成要素を提供します。関数合成とエラー伝播を扱うため、操作を安全かつ効率的に連鎖できます。

主な機能

  • 関数合成: 複数の関数を 1 つのパイプラインに結合
  • エラーセーフな操作: エラーの自動伝播と処理
  • 非同期サポート: 非同期操作のシームレスな処理
  • 型安全性: 適切な型付けを備えた完全な TypeScript サポート

🔗 基本的な合成

合成、カリー化、Result 型の概念的背景については、関数型プログラミング »を参照してください。

compose

複数の関数を 1 つのパイプラインに結合します。SDK では、compose は Ramda の pipe の別名です。関数は左から右へ実行されます(右から左へ実行される標準の Ramda compose とは異なります)。

import { primitives } from '@nodeblocks/backend-sdk';

const { compose } = primitives;

const processUser = compose(
validateUser, // 1. runs first
saveUser, // 2. runs second
formatResponse // 3. runs last
);

// Equivalent to: formatResponse(saveUser(validateUser(input)))

lift

ルートハンドラーチェーンでは、liftターミネーターステップを適合させます。前の合成ステップの Promise を待機し、アンラップされた値を関数に渡します。通常は、ブロックエラーを HTTP ステータスコードに対応付けてレスポンスを整形する orThrow です。

import { primitives, blocks } from '@nodeblocks/backend-sdk';

const { compose, flatMapAsync, lift, applyPayloadArgs, orThrow } = primitives;
const { getProductById, normalizeProduct, ProductNotFoundBlockError } = blocks;

// lift wraps orThrow — the standard terminator pattern
const handler = compose(
applyPayloadArgs(getProductById, [
['context', 'db', 'products'],
['params', 'requestParams', 'productId'],
], 'product'),
flatMapAsync(
applyPayloadArgs(normalizeProduct, [['context', 'data', 'product']], 'normalizedProduct')
),
lift(
orThrow(
[[ProductNotFoundBlockError, 404]],
[['context', 'data', 'normalizedProduct']]
)
)
);

lift はプレーン関数を Result ファンクターへリフトしません。合成ハンドラーステップ間の非同期 Promise と同期ターミネーターを橋渡しします。

mergeData

合成パイプライン内の後続ハンドラーで使用するため、データをペイロードコンテキストにマージします。

import { ok } from 'neverthrow';
import { handlers, primitives } from '@nodeblocks/backend-sdk';

const { mergeData } = handlers;
const { compose, flatMapAsync, lift, orThrow } = primitives;

const enrichUserData = async (payload: RouteHandlerPayload) => {
const profile = await profileService.getProfile(payload.context.data.userId);
return ok(mergeData(payload, { profile }));
};

const getUserHandler = compose(
fetchUserFromDb,
flatMapAsync(enrichUserData), // profile is now available in payload.context.data
lift(orThrow([[UserNotFoundError, 404]], [['context', 'data', 'profile']]))
);

パラメーター:

  • payload: 現在のペイロードオブジェクト
  • data: payload.context.data にマージするデータを含むオブジェクト

戻り値: マージ済みデータを含む更新後のペイロードオブジェクト

注: mergeDataResult ではなくペイロードを返します。flatMapAsync で使用するハンドラーは、戻り値を ok()(失敗時は err())でラップする必要があります。

使用例:

import { ok } from 'neverthrow';

// Merge single value (inside a flatMapAsync handler)
return ok(mergeData(payload, { userId: '123' }));

// Merge multiple values
return ok(mergeData(payload, {
user: userData,
profile: profileData,
settings: settingsData,
}));

// Merge in async handlers
const createUser = async (payload: RouteHandlerPayload) => {
const user = await db.users.create(payload.params.requestBody);
return ok(mergeData(payload, { user }));
};

applyPayloadArgs

ペイロードから引数を抽出して純粋関数に適用し、純粋なビジネスロジックをルートハンドラーへシームレスに統合します。

目的: 特定のパスを抽出して関数引数として適用し、純粋関数とルートペイロードコンテキストを橋渡しします

ハンドラー処理:

  • 入力: コンテキスト、パラメーター、本文データを含む RouteHandlerPayload
  • 処理: ペイロードから指定パスを抽出し、純粋関数(非同期または同期)に適用します
  • 出力: 関数結果をペイロードにマージした Result<RouteHandlerPayload, Error>
  • エラー: 関数が Result.err を返すときにエラーを伝播します

Parameters:

  • func: 抽出した引数で実行する純粋関数(非同期と同期の両方をサポート)。Result 型を使用できます。
  • funcArgs: ペイロードから抽出するパスの配列(直接キーまたはネストパス)
  • key: 関数結果を payload.context.data[key] に保存する任意のプロパティ名(mergeData 経由)

戻り値: ペイロードから抽出した引数で純粋関数を適用する AsyncRouteHandler

Usage Examples:

import { primitives } from '@nodeblocks/backend-sdk';

const { applyPayloadArgs } = primitives;

// Used in route composition with async function:
const updateRoute = withRoute({
handler: compose(
applyPayloadArgs(updateIdentityPure, [
['params', 'requestParams', 'identityId'],
['params', 'requestBody'],
['context', 'db', 'identities']
], 'identityId')
)
});

// Use flatMapAsync when combining:
const updateRoute = withRoute({
handler: compose(
applyPayloadArgs(updateItem, [
['params', 'requestParams', 'itemId'],
['params', 'requestBody'],
['context', 'db', 'items']
], 'itemId'),
flatMapAsync(
applyPayloadArgs(
getItemById,
[
['context', 'data', 'itemId'],
['context', 'db', 'items'],
],
'item'
)
),
flatMapAsync(
applyPayloadArgs(
getOtherItemById,
[
['context', 'data', 'itemId'],
['context', 'db', 'otherItems'],
],
'item'
)
),
lift(
orThrow([[ItemNotFoundError, 404]], [['context', 'data', 'item']])
)
)
});

// Works with sync functions too:
applyPayloadArgs(someSyncFunction, [
['params', 'requestParams', 'id'],
['body']
], 'result')

// Handles Result types automatically:
applyPayloadArgs((x: number, y: number) => ok(x + y), [
['params', 'requestParams', 'x'],
['context', 'data', 'y']
], 'result')

orThrow

カスタムエラー対応付けと任意の成功データ抽出を用いて Result の成功/エラーケースを処理するターミネーター関数です。

目的: 合成パイプライン向けに制御されたエラー処理とレスポンス整形を提供します

レスポンス整形:

  • 入力: RouteHandlerPayload または Error を含む Result
  • 処理: 特定のエラータイプを HTTP ステータスコードまたはカスタムエラーに対応付け、任意で成功ペイロードからデータを抽出します
  • 出力: successMap がある場合は抽出した成功値と任意のステータスコードを返し、ない場合は元の RouteHandlerPayload を返します。失敗時は適切なステータスコードで対応付けられたエラーをスローします。
  • エラー: ステータスコード付きの NodeblocksError またはカスタムエラーインスタンスをスローします

Parameters:

  • errorMap: エラー対応付け用の [CustomError, HttpStatusCode | Error] タプル配列
  • successMap: 成功データ抽出用の任意の [ObjectPath, HttpStatusCode] タプル

戻り値: Result を処理し、設定時は成功データを抽出し、それ以外では対応付けられたエラーをスローする関数

Usage Examples:

import { primitives } from '@nodeblocks/backend-sdk';

const { compose, lift, orThrow } = primitives;

// Used in route composition with error mapping:
const createUserRoute = withRoute({
handler: compose(
createUserHandler,
lift(orThrow([
[ValidationError, 400],
[DuplicateError, 409],
[DatabaseError, 500]
]))
)
});

// With success data extraction:
const getUserRoute = withRoute({
handler: compose(
getUserHandler,
lift(orThrow(
[[UserNotFoundError, 404]],
[['context', 'data', 'user'], 200]
))
)
});

🔎 ヘルパー

match

Ramda の pathSatisfies を通じてネストパスを確認する述語ユーティリティです。

import { primitives } from '@nodeblocks/backend-sdk';

const { match } = primitives;

// Curried usage (2 args): returns a predicate that expects the object later
const isPositiveLimit = match(
(x: unknown) => Number(x) > 0,
['params', 'requestQuery', 'limit']
);
// later, supply the object
const ok = isPositiveLimit(payload); // boolean

// Direct usage (3 args): pass the object immediately
const okImmediate = match(
(x: unknown) => Number(x) > 0,
['params', 'requestQuery', 'limit'],
payload
); // boolean

ifElse

述語に基づいて 2 つの関数の一方を選択する関数型条件分岐です。

import { primitives } from '@nodeblocks/backend-sdk';

const { ifElse } = primitives;

const normalizeName = ifElse(
(x: any) => !!x.nickname,
(x: any) => x.nickname,
(x: any) => `${x.firstName} ${x.lastName}`
);

hasValue

値が null でなく空でもない場合に true を返す複合述語です。

import { primitives } from '@nodeblocks/backend-sdk';

const { hasValue } = primitives;

hasValue('hello'); // true
hasValue(''); // false
hasValue(null); // false
hasValue(undefined); // false
hasValue([]); // false
hasValue({}); // false
hasValue({ a: 1 }); // true
hasValue([1, 2, 3]); // true

either

Ramda の either 再エクスポートです。述語の論理 OR を提供します。どちらかの引数述語が true の場合に true を返す述語を返します。

import { utils, primitives } from '@nodeblocks/backend-sdk';

const { isCookieMode } = utils;
const { either, match } = primitives;

const isCookieAuth = either(
match(isCookieMode, ['context', 'authMode']),
match(isCookieMode, ['context', 'configuration', 'authMode'])
);

コンテキストまたは構成から Cookie モードを検出するため、whenCookieAuth 内部で使用されます。

mapMatchingErrorToFalse

Promise<Result<T, Error>> を返す関数をラップし、一致するブロックエラーを ok(false) に変換します。その他のエラーは変更せず伝播します。

import { ok } from 'neverthrow';
import { primitives, blocks } from '@nodeblocks/backend-sdk';

const { mapMatchingErrorToFalse } = primitives;
const { checkEmailIsUniqueInIdentities, AuthenticationConflictError } = blocks;

const checkUnique = mapMatchingErrorToFalse(checkEmailIsUniqueInIdentities, [
AuthenticationConflictError,
]);

const result = await checkUnique(identities, 'taken@example.com');
// Matching conflict: result.isOk() === true, result.value === false
// Other error: result.isErr() === true
// Success: result.isOk() === true, result.value unchanged

パラメーター:

  • fn: Promise<Result<T, Error>> を返す関数
  • errorTypes: instanceof により false へ変換するブロックエラーのコンストラクター

戻り値: Promise<Result<T | boolean, Error>> を返すラップ済み関数

RxJS エミッターヘルパー

これらのヘルパーは primitives からエクスポートされ、RxJS または WebSocket パイプラインでメッセージをフィルターおよびタグ付けするためのものです。

notFromEmitter

カリー化された述語ファクトリーです。notFromEmitter(emitterId) はオブジェクトデータを受け取る述語を返します。emitterId が存在しない、または指定 ID と異なる場合に true を返します。オブジェクト以外の値は false を返します。

import { primitives } from '@nodeblocks/backend-sdk';
import { filter } from 'rxjs';

const { notFromEmitter } = primitives;

observable.pipe(filter(notFromEmitter('socket-123')));

markAsFromEmitter

指定した emitterId フィールドを含むオブジェクトのシャローコピーを返すカリー化ヘルパーです。

import { primitives } from '@nodeblocks/backend-sdk';

const { markAsFromEmitter } = primitives;
const tagged = markAsFromEmitter('socket-123')({ event: 'updated' });
// { event: 'updated', emitterId: 'socket-123' }

🔄 エラーセーフな合成

flatMap

Result 型を返す同期操作を連鎖し、エラーセーフな合成を可能にします。

import { primitives } from '@nodeblocks/backend-sdk';

const { flatMap } = primitives;

const processUserData = compose(
validateUserInput,
flatMap(enrichUserData), // Only runs if validation succeeds
flatMap(formatUserData)
);

flatMapAsync

Result 型を返す非同期操作を連鎖し、エラーセーフな合成を可能にします。

import { primitives } from '@nodeblocks/backend-sdk';

const { flatMapAsync } = primitives;

const createAndFetchUser = compose(
createUserInDb,
flatMapAsync(fetchUserById), // Only runs if createUserInDb succeeds
lift(orThrow([[UserNotFoundError, 404]], [['context', 'data', 'user']]))
);

withSoftDelete

deletedAt タイムスタンプを自動管理してハード削除をソフト削除に変換し、データ安全性と監査機能を提供します。

目的: レコードを削除する代わりに削除済みとしてマークして安全なデータ削除を実現し、クエリからソフト削除済みレコードを自動的に除外します

データベース操作:

  • 読み取り: findfindOnecountDocuments はソフト削除済みレコード(deletedAt が存在するレコード)を自動的に除外します
  • 削除: deleteOnedeleteManydeletedAt: new Date() を設定する更新に変換されます
  • 更新: updateOneupdateManyfindOneAndUpdate はソフト削除フィルターを尊重します(すでに削除済みのレコードは更新しません)
  • 挿入: 影響を受けず、通常どおり動作します

ハンドラー処理:

  • 入力: データベースコレクションを使用する任意の AsyncRouteHandler
  • 処理: 操作をインターセプトするソフト削除プロキシでデータベースコレクションをラップします
  • 出力: 同じように動作しますが、ソフト削除セマンティクスを持つハンドラー
  • データ安全性: 削除済みレコードは監査目的で deletedAt タイムスタンプとともにデータベースに残ります

パラメーター:

  • handler: ソフト削除機能でラップする非同期ルートハンドラー

戻り値: すべてのデータベース操作にソフト削除セマンティクスを適用する AsyncRouteHandler

使用例:

import { primitives } from '@nodeblocks/backend-sdk';

const { withSoftDelete } = primitives;

// Basic usage - wrap any handler for soft delete functionality
const softDeleteHandler = withSoftDelete(originalHandler);

// In route composition
const deleteUserRoute = withRoute({
method: 'DELETE',
path: '/users/:userId',
handler: withSoftDelete(deleteUserHandler)
});

// Works with all database operations automatically
const userOperations = compose(
withSoftDelete(createUserHandler), // Inserts work normally
withSoftDelete(findUserHandler), // Finds exclude soft-deleted users
withSoftDelete(updateUserHandler), // Updates respect soft delete filters
withSoftDelete(deleteUserHandler) // Deletes become soft deletes
);

// Example: Soft delete marks record instead of removing
// Before: db.users.deleteOne({ id: '123' }) — permanent removal
// After: db.users.deleteOne({ id: '123' }) — becomes updateOne with { $set: { deletedAt: new Date() } }

// Example: Queries automatically exclude soft-deleted records
// Before: db.users.find({ active: true })
// After: db.users.find({ active: true }) — merged filter adds deletedAt: { $exists: false }

利点:

  • データ安全性: 意図しない恒久的なデータ損失を防ぎます
  • 監査証跡: コンプライアンスのために履歴レコードを維持します
  • 復旧: 必要に応じてソフト削除済みレコードを復元できます
  • API 互換性: 既存ハンドラーは変更なしで動作します
  • パフォーマンス: オーバーヘッドは最小限で、deletedAt フィルターを追加するだけです

データ復旧例:

// To restore a soft-deleted record (bypass soft delete wrapper)
const restoreUser = async (payload: RouteHandlerPayload) => {
const { userId } = payload.params.requestParams;
await payload.context.db.users.updateOne(
{ id: userId },
{ $unset: { deletedAt: 1 } } // Remove the deletedAt field
);
return payload;
};

📝 実践例

複数ステップのデータ処理(ブロックパターン)

import { primitives, blocks } from '@nodeblocks/backend-sdk';

const { compose, flatMapAsync, lift, applyPayloadArgs, orThrow } = primitives;
const { getProductById, normalizeProduct, ProductNotFoundBlockError } = blocks;

const getProductHandler = compose(
applyPayloadArgs(
getProductById,
[
['context', 'db', 'products'],
['params', 'requestParams', 'productId'],
],
'product'
),
flatMapAsync(
applyPayloadArgs(normalizeProduct, [['context', 'data', 'product']], 'normalizedProduct')
),
lift(
orThrow(
[[ProductNotFoundBlockError, 404]],
[['context', 'data', 'normalizedProduct']]
)
)
);

データ変換パイプライン

const { compose, flatMapAsync, lift, orThrow } = primitives;

const processOrder = compose(
applyPayloadArgs(validateOrderData, [['params', 'requestBody']], 'order'),
flatMapAsync(applyPayloadArgs(checkInventory, [['context', 'data', 'order']], 'inventory')),
flatMapAsync(applyPayloadArgs(createOrder, [['context', 'data', 'order']], 'createdOrder')),
lift(orThrow([[OrderError, 500]], [['context', 'data', 'createdOrder'], 201]))
);

const createOrderRoute = withRoute({
method: 'POST',
path: '/orders',
handler: processOrder,
});

エラーセーフな検証チェーン

import { Result, ok, err } from 'neverthrow';
import { primitives } from '@nodeblocks/backend-sdk';

const { flatMap, flatMapAsync, lift } = primitives;

// Step 1: Validate input
const validateUserInput = (data: any): Result<ValidatedUser, ValidationError> => {
if (!data.email || !data.name) {
return err(new ValidationError('Missing required fields'));
}
return ok(data);
};

// Step 2: Check if user exists
const checkUserExists = async (user: ValidatedUser): Promise<Result<User, DatabaseError>> => {
const existing = await db.users.findOne({ email: user.email });
if (existing) {
return err(new DatabaseError('User already exists'));
}
return ok(user);
};

// Step 3: Save user
const saveUser = async (user: ValidatedUser): Promise<Result<User, DatabaseError>> => {
try {
const saved = await db.users.create(user);
return ok(saved);
} catch (error) {
return err(new DatabaseError('Failed to save user'));
}
};

// Compose the error-safe pipeline
const createUserHandler = compose(
(payload) => validateUserInput(payload.params.requestBody),
flatMapAsync(checkUserExists),
flatMapAsync(saveUser),
lift(orThrow([[DatabaseError, 500]], [['context', 'data', 'user'], 201]))
);

📐️️ ベストプラクティス

1. 関数を小さく集中させる

// ✅ Good: Small, focused functions
const validateEmail = (email: string) => /* validation logic */;
const hashPassword = (password: string) => /* hashing logic */;
const saveUser = (user: User) => /* database logic */;

// ❌ Avoid: Large, multi-purpose functions
const createUserMegaFunction = (data: any) => {
// validation, hashing, saving, emailing all in one function
};

2. 非同期操作を適切に処理する

// ✅ Good: Use flatMapAsync for async operations
const enrichDataPipeline = compose(
fetchUserData,
flatMapAsync(fetchUserProfile), // Async operation
flatMapAsync(fetchUserSettings), // Another async operation
lift(orThrow([[UserError, 404]], [['context', 'data', 'settings']])) // Terminator
);

// ❌ Avoid: Mixing async/sync without proper utilities
const badPipeline = compose(
fetchUserData,
fetchUserProfile, // This won't work properly in composition
formatResponse
);

3. 適切なレベルで合成する

// ✅ Good: Compose related operations
const userRegistrationFlow = compose(
validateRegistration,
createUser,
sendWelcomeEmail
);

// ✅ Good: Keep unrelated operations separate
const userLoginFlow = compose(
validateCredentials,
authenticateUser,
generateToken
);

4. エラー処理に Result 型を使用する

// ✅ Good: Explicit error handling with Result types
const safeOperation = compose(
validateInput,
flatMapAsync(databaseOperation), // Only runs if validation succeeds
lift(orThrow([[DatabaseError, 500]], [['context', 'data', 'result']]))
);

// ❌ Avoid: Relying on thrown exceptions in composition
const unsafeOperation = compose(
validateInput,
databaseOperation, // Might throw, breaking composition
formatResponse
);

🔗 関連項目