medusa-development について
medusa-development は、Medusa.js でヘッドレス EC バックエンドを構築・拡張する開発者向けスキルです。Medusa v2 のモジュール、サービス、サブスクライバー、API ルート、ワークフロー、外部システム連携を扱います。
主な機能
- 01Medusa v2 プロジェクトのセットアップ
- 02カスタムモジュール、サービス、モデルの作成
- 03API ルートとイベントサブスクライバーの追加
- 04ワークフローと定期ジョブの構築
- 05管理画面の拡張と外部システム連携
活用シーン
- 01ヘッドレスストアのバックエンド構築
- 02独自の業務ロジックと API の実装
- 03注文イベントを起点とする自動化
- 04ERP、CMS、決済プロバイダーとの連携
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ finsilabs/ awesome-ecommerce-skills --skill medusa-developmentインストール後、Claude Code で/medusa-developmentを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
tile.jsonevals/custom-api-routes-with-validation/criteria.jsontask.mdevals/custom-module-architecture/criteria.jsontask.mdevals/event-subscribers-and-workflows/criteria.jsontask.md詳細
Medusa.js 開発
概要
カスタムサービス、サブスクライバー(イベントハンドラー)、API ルートの拡張、マイグレーションを伴うカスタムエンティティ、モジュールアーキテクチャを使用して、ヘッドレス E コマースバックエンドを Medusa.js で構築、拡張します。このスキルでは、Medusa v2 のプロジェクト設定、依存関係注入コンテナ、カスタムワークフロー、管理画面の UI 拡張、ストアフロント、ERP、決済プロバイダーに Medusa を接続するための統合パターンを扱います。
このスキルを使用する場面
- Medusa で新しいヘッドレス E コマースバックエンドを設定するとき
- Medusa のサービスやワークフローとしてカスタムビジネスロジックを構築するとき
- ストアフロントや管理画面向けのカスタムエンドポイントで Medusa API を拡張するとき
- サブスクライバーを介してイベント駆動型の自動化(例:注文時のメール送信)を実装するとき
- Medusa を外部システム(ERP、CMS、フルフィルメント)と統合するとき
前提条件とプラットフォームに関する注意事項
このスキルはカスタム/ヘッドレスストアフロント向けです(Node.js、Python、または類似のバックエンド)。コード例は TypeScript/Node.js を使用していますが、どのようなスタックにも適応できます。
Shopify:Shopify Hydrogen は Shopify のヘッドレスフレームワークです。Shopify をコマースバックエンドとしてカスタムフロントエンドと組み合わせる場合や、Shopify を他の各分野で優れたサービスと組み合わせる場合に、MACH/コンポーザブルのパターンを適用できます。 WooCommerce:WooCommerce は REST API と WPGraphQL を介してヘッドレスバックエンドとして機能します。フロントエンドを WordPress から切り離す場合に、これらのパターンを適用できます。 Magento:Magento の GraphQL API と PWA Studio は、ヘッドレスアーキテクチャをサポートしています。MACH スタックで Magento をバックエンドサービスとして使用する際に、これらのコンポーザブルパターンを適用できます。
必要なもの:
- Node.js 18+(またはバックエンドの言語に合わせて調整してください)
- PostgreSQL(または任意のリレーショナルデータベース)
- キャッシュ/キュー用の Redis
- Stripe アカウントと API キー
- メール送信サービス(SendGrid、AWS SES、または Postmark)
基本手順
-
Medusa プロジェクトをセットアップする
# Create a new Medusa project npx create-medusa-app@latest my-store # Project structure (Medusa v2) # my-store/ # ├── src/ # │ ├── api/ # Custom API routes # │ ├── jobs/ # Scheduled jobs # │ ├── links/ # Module links # │ ├── modules/ # Custom modules # │ ├── subscribers/ # Event subscribers # │ └── workflows/ # Custom workflows # ├── medusa-config.ts # └── package.json # Start the development server npx medusa developmedusa-config.tsを設定します:import { defineConfig, loadEnv } from '@medusajs/framework/utils'; loadEnv(process.env.NODE_ENV || 'development', process.cwd()); export default defineConfig({ projectConfig: { databaseUrl: process.env.DATABASE_URL, redisUrl: process.env.REDIS_URL, http: { storeCors: process.env.STORE_CORS || 'http://localhost:8000', adminCors: process.env.ADMIN_CORS || 'http://localhost:9000', authCors: process.env.AUTH_CORS || 'http://localhost:8000,http://localhost:9000', }, }, modules: [ // Register custom modules here ], }); -
カスタムモジュールとサービスを作成する
// src/modules/loyalty/service.ts import { MedusaService } from '@medusajs/framework/utils'; import { LoyaltyPoints } from './models/loyalty-points'; class LoyaltyModuleService extends MedusaService({ LoyaltyPoints, }) { async awardPoints(customerId: string, points: number, reason: string) { return await this.createLoyaltyPointss({ customer_id: customerId, points, reason, type: 'earned', }); } async redeemPoints(customerId: string, points: number) { const balance = await this.getBalance(customerId); if (balance < points) { throw new Error(`Insufficient points. Balance: ${balance}, requested: ${points}`); } return await this.createLoyaltyPointss({ customer_id: customerId, points: -points, reason: 'redeemed', type: 'redeemed', }); } async getBalance(customerId: string): Promise<number> { const records = await this.listLoyaltyPointss({ customer_id: customerId, }); return records.reduce((sum, r) => sum + r.points, 0); } } export default LoyaltyModuleService;データモデルを定義します:
// src/modules/loyalty/models/loyalty-points.ts import { model } from '@medusajs/framework/utils'; export const LoyaltyPoints = model.define('loyalty_points', { id: model.id().primaryKey(), customer_id: model.text(), points: model.number(), reason: model.text(), type: model.enum(['earned', 'redeemed', 'adjusted']), });モジュールを登録します:
// src/modules/loyalty/index.ts import LoyaltyModuleService from './service'; import { Module } from '@medusajs/framework/utils'; export const LOYALTY_MODULE = 'loyaltyModuleService'; export default Module(LOYALTY_MODULE, { service: LoyaltyModuleService, }); -
イベントサブスクライバーを作成する
// src/subscribers/order-placed.ts import type { SubscriberArgs, SubscriberConfig } from '@medusajs/framework'; import { Modules } from '@medusajs/framework/utils'; import { LOYALTY_MODULE } from '../modules/loyalty'; export default async function orderPlacedHandler({ event, container, }: SubscriberArgs<{ id: string }>) { const orderId = event.data.id; const orderService = container.resolve(Modules.ORDER); const loyaltyService = container.resolve(LOYALTY_MODULE); const logger = container.resolve('logger'); try { const order = await orderService.retrieveOrder(orderId, { relations: ['items'], }); // Award 1 point per dollar spent const pointsToAward = Math.floor(order.total / 100); if (order.customer_id && pointsToAward > 0) { await loyaltyService.awardPoints( order.customer_id, pointsToAward, `Order ${order.display_id}` ); logger.info(`Awarded ${pointsToAward} loyalty points for order ${order.display_id}`); } } catch (error) { logger.error(`Failed to award loyalty points for order ${orderId}: ${error.message}`); } } export const config: SubscriberConfig = { event: 'order.placed', }; -
カスタム API ルートを追加する
// src/api/store/loyalty/route.ts import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http'; import { LOYALTY_MODULE } from '../../../modules/loyalty'; // GET /store/loyalty — get current customer's loyalty balance export async function GET(req: MedusaRequest, res: MedusaResponse) { const customerId = req.auth_context?.actor_id; if (!customerId) { return res.status(401).json({ message: 'Authentication required' }); } const loyaltyService = req.scope.resolve(LOYALTY_MODULE); const balance = await loyaltyService.getBalance(customerId); const history = await loyaltyService.listLoyaltyPointss( { customer_id: customerId }, { order: { created_at: 'DESC' }, take: 20 } ); res.json({ balance, history }); } // POST /store/loyalty/redeem — redeem points for a discount export async function POST(req: MedusaRequest, res: MedusaResponse) { const customerId = req.auth_context?.actor_id; if (!customerId) { return res.status(401).json({ message: 'Authentication required' }); } const { points } = req.body as { points: number }; if (!points || points <= 0) { return res.status(400).json({ message: 'Invalid points amount' }); } const loyaltyService = req.scope.resolve(LOYALTY_MODULE); try { const record = await loyaltyService.redeemPoints(customerId, points); const newBalance = await loyaltyService.getBalance(customerId); res.json({ redeemed: points, newBalance, record }); } catch (error) { res.status(400).json({ message: error.message }); } } -
カスタムワークフローを構築する
// src/workflows/award-loyalty-points.ts import { createWorkflow, createStep, StepResponse, } from '@medusajs/framework/workflows-sdk'; import { LOYALTY_MODULE } from '../modules/loyalty'; const validatePointsStep = createStep( 'validate-points', async ({ customerId, points }: { customerId: string; points: number }) => { if (!customerId) throw new Error('Customer ID required'); if (points <= 0) throw new Error('Points must be positive'); return new StepResponse({ customerId, points }); } ); const awardPointsStep = createStep( 'award-points', async ( { customerId, points, reason }: { customerId: string; points: number; reason: string }, { container } ) => { const loyaltyService = container.resolve(LOYALTY_MODULE); const record = await loyaltyService.awardPoints(customerId, points, reason); return new StepResponse(record, { recordId: record.id }); }, // Compensation function for rollback async ({ recordId }, { container }) => { const loyaltyService = container.resolve(LOYALTY_MODULE); await loyaltyService.deleteLoyaltyPoints(recordId); } ); export const awardLoyaltyPointsWorkflow = createWorkflow( 'award-loyalty-points', (input: { customerId: string; points: number; reason: string }) => { const validated = validatePointsStep(input); const record = awardPointsStep({ customerId: validated.customerId, points: validated.points, reason: input.reason, }); return record; } ); -
スケジュールされたジョブを作成する
// src/jobs/expire-loyalty-points.ts import type { MedusaContainer } from '@medusajs/framework/types'; import { LOYALTY_MODULE } from '../modules/loyalty'; export default async function expireLoyaltyPointsJob(container: MedusaContainer) { const loyaltyService = container.resolve(LOYALTY_MODULE); const logger = container.resolve('logger'); // Find points older than 12 months const expirationDate = new Date(); expirationDate.setFullYear(expirationDate.getFullYear() - 1); const expiredRecords = await loyaltyService.listLoyaltyPointss({ type: 'earned', created_at: { $lt: expirationDate }, }); let expiredCount = 0; for (const record of expiredRecords) { if (record.points > 0) { await loyaltyService.createLoyaltyPointss({ customer_id: record.customer_id, points: -record.points, reason: `Expired: original from ${record.created_at}`, type: 'adjusted', }); expiredCount++; } } logger.info(`Expired ${expiredCount} loyalty point records.`); } export const config = { name: 'expire-loyalty-points', schedule: '0 2 * * *', // Daily at 2 AM };
例
Next.js ストアフロントへの接続
// storefront/lib/medusa-client.ts
import Medusa from '@medusajs/js-sdk';
const medusa = new Medusa({
baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL || 'http://localhost:9000',
auth: {
type: 'session',
},
});
// Fetch products for a collection page
export async function getProducts(collectionId?: string) {
const { products, count } = await medusa.store.product.list({
collection_id: collectionId ? [collectionId] : undefined,
limit: 24,
fields: '+variants.calculated_price',
});
return { products, count };
}
// Add item to cart
export async function addToCart(cartId: string, variantId: string, quantity: number) {
const { cart } = await medusa.store.cart.createLineItem(cartId, {
variant_id: variantId,
quantity,
});
return cart;
}
// Fetch loyalty balance (custom endpoint)
export async function getLoyaltyBalance() {
const response = await fetch(
`${process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL}/store/loyalty`,
{ credentials: 'include' }
);
if (!response.ok) throw new Error('Failed to fetch loyalty balance');
return response.json();
}
カスタム決済プロバイダーモジュール
// src/modules/custom-payment/service.ts
import {
AbstractPaymentProvider,
} from '@medusajs/framework/utils';
import type {
CreatePaymentProviderSession,
UpdatePaymentProviderSession,
ProviderWebhookPayload,
WebhookActionResult,
} from '@medusajs/framework/types';
class CustomPaymentProviderService extends AbstractPaymentProvider<{}> {
static identifier = 'custom-payment';
async initiatePayment(
data: CreatePaymentProviderSession
): Promise<Record<string, unknown>> {
// Call your payment gateway's API to create a payment session
const response = await fetch('https://api.custompay.com/v1/sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CUSTOM_PAYMENT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: data.amount,
currency: data.currency_code,
metadata: { medusa_cart_id: data.context.cart_id },
}),
});
const session = await response.json();
return { session_id: session.id, client_token: session.client_token };
}
async authorizePayment(
paymentSessionData: Record<string, unknown>
): Promise<{ status: string; data: Record<string, unknown> }> {
const sessionId = paymentSessionData.session_id as string;
const response = await fetch(
`https://api.custompay.com/v1/sessions/${sessionId}`,
{
headers: { 'Authorization': `Bearer ${process.env.CUSTOM_PAYMENT_API_KEY}` },
}
);
const session = await response.json();
return {
status: session.status === 'paid' ? 'authorized' : 'pending',
data: { ...paymentSessionData, gateway_status: session.status },
};
}
async capturePayment(
paymentSessionData: Record<string, unknown>
): Promise<Record<string, unknown>> {
const sessionId = paymentSessionData.session_id as string;
await fetch(`https://api.custompay.com/v1/sessions/${sessionId}/capture`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.CUSTOM_PAYMENT_API_KEY}` },
});
return { ...paymentSessionData, captured: true };
}
async refundPayment(
paymentSessionData: Record<string, unknown>,
refundAmount: number
): Promise<Record<string, unknown>> {
const sessionId = paymentSessionData.session_id as string;
await fetch(`https://api.custompay.com/v1/sessions/${sessionId}/refund`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CUSTOM_PAYMENT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount: refundAmount }),
});
return { ...paymentSessionData, refunded_amount: refundAmount };
}
async cancelPayment(
paymentSessionData: Record<string, unknown>
): Promise<Record<string, unknown>> {
return { ...paymentSessionData, cancelled: true };
}
async deletePayment(
paymentSessionData: Record<string, unknown>
): Promise<Record<string, unknown>> {
return {};
}
async getPaymentStatus(
paymentSessionData: Record<string, unknown>
): Promise<string> {
return (paymentSessionData.gateway_status as string) || 'pending';
}
async getWebhookActionAndData(
payload: ProviderWebhookPayload
): Promise<WebhookActionResult> {
const event = JSON.parse(payload.rawData as string);
switch (event.type) {
case 'payment.captured':
return { action: 'captured', data: { session_id: event.session_id } };
case 'payment.failed':
return { action: 'failed', data: { session_id: event.session_id } };
default:
return { action: 'not_supported' };
}
}
}
export default CustomPaymentProviderService;
ベストプラクティス
- カプセル化にはモジュールシステムを使用する -- 各ドメイン(ロイヤルティ、カスタムフルフィルメント、分析)は、それぞれ独自のサービス、モデル、マイグレーションを持つモジュールにします
- ワークフローのステップには必ず補償関数を追加する -- ステップが失敗する可能性がある場合、補償関数が前のステップの副作用をロールバックし、適切にエラーを復旧できるようにします
- インポートではなく、コンテナから依存関係を解決する -- サービスには
container.resolve()を使用し、DI 設定を尊重するとともに、テストを可能にします - サブスクライバーは副作用に使用し、コアロジックには使用しない -- サブスクライバーは通知の送信、外部システムとの同期、イベントのログ記録に使用します。注文処理はワークフローに含めてください
- Zod で API 入力を検証する -- リクエストボディの Zod スキーマを定義し、ハンドラーの実行前にミドルウェアで検証します
- スキーマの変更にはマイグレーションを作成する -- データベースを手作業で変更せず、Medusa のマイグレーションシステムを使用して、環境間で変更を再現できるようにします
- 管理画面の拡張には Medusa Admin SDK を使用する -- 別の UI を構築するのではなく、
@medusajs/admin-sdkパッケージを使用してカスタムウィジェットで管理ダッシュボードを拡張します - Medusa のバージョンを固定する -- Medusa v2 は急速に進化しているため、バージョンを
package.jsonで固定し、アップグレード前にテストします
よくある問題
| 問題 | 解決策 |
|---|---|
| 実行時にカスタムモジュールが見つからない | medusa-config.ts の modules 配列に登録し、npx medusa db:migrate を実行してモデルの変更を適用します |
| サブスクライバーは実行されるが、データが古い | サブスクライバーは非同期で実行されます。イベントペイロードのデータに依存せず、サブスクライバーハンドラー内でエンティティを再取得します |
| API ルートが 404 を返す | ファイルパスが URL パターンと一致していることを確認します。src/api/store/loyalty/route.ts は /store/loyalty に対応します。また、ハンドラー関数の export が抜けていないか確認します |
| ワークフローステップでロールバックされないまま失敗する | 副作用のある各ステップでは、createStep の第 2 引数として補償関数が必要です |
| マージ後にデータベースマイグレーションが競合する | 変更を取得した後に npx medusa db:migrate を実行します。マイグレーションが競合する場合は、差分を解消する新しいマイグレーションを生成します |
| ストアフロントから CORS エラーが発生する | medusa-config.ts の storeCors を設定し、ポートを含むストアフロントのオリジン URL を追加します |
関連スキル
- @product-data-modeling
- @stripe-integration
- @ecommerce-caching
- @ecommerce-seo
- @erp-integration