Разработка кастомного сервиса Medusa.js
В Medusa 2.x сервис — это TypeScript-класс, зарегистрированный в IoC-контейнере и доступный через container.resolve(). Кастомные сервисы могут инкапсулировать бизнес-логику, интегрировать внешние API, оркестрировать несколько встроенных модулей и использоваться в Workflows, Subscribers, API-роутах.
Типы сервисов в Medusa 2.x
| Тип | Использование | Регистрация |
|---|---|---|
| Module Service | CRUD для модульных сущностей | Module() декоратор |
| Custom Service | Бизнес-логика, оркестрация | src/modules/*/service.ts |
| Workflow Step | Повторно-используемая step-логика | createStep() |
| Provider Service | Платёж, доставка, файлы | Extends Abstract класс |
Кастомный сервис в src/modules
// src/modules/loyalty/service.ts
import { MedusaContainer } from '@medusajs/framework/types';
import { Logger } from '@medusajs/framework/types';
type LoyaltyServiceDeps = {
logger: Logger;
// Встроенные модули доступны через container
};
export type LoyaltyPoint = {
customerId: string;
points: number;
reason: string;
orderId?: string;
};
export default class LoyaltyService {
protected logger: Logger;
constructor({ logger }: LoyaltyServiceDeps) {
this.logger = logger;
}
async getCustomerPoints(customerId: string): Promise<number> {
// Запрос к таблице loyalty_points
const db = // получить connection через MikroORM
const result = await db.query<{ total: number }>(
`SELECT COALESCE(SUM(points), 0) as total
FROM loyalty_points
WHERE customer_id = $1 AND expires_at > NOW()`,
[customerId]
);
return result[0]?.total ?? 0;
}
async addPoints(data: LoyaltyPoint): Promise<void> {
this.logger.info(`Adding ${data.points} points to customer ${data.customerId}`);
await db.query(
`INSERT INTO loyalty_points (customer_id, points, reason, order_id, created_at, expires_at)
VALUES ($1, $2, $3, $4, NOW(), NOW() + INTERVAL '1 year')`,
[data.customerId, data.points, data.reason, data.orderId ?? null]
);
}
async redeemPoints(customerId: string, pointsToRedeem: number): Promise<number> {
const available = await this.getCustomerPoints(customerId);
if (available < pointsToRedeem) {
throw new Error(`Недостаточно баллов: доступно ${available}, запрошено ${pointsToRedeem}`);
}
await this.addPoints({
customerId,
points: -pointsToRedeem,
reason: 'redemption',
});
// Конвертация баллов в скидку: 1 балл = 1 рубль
return pointsToRedeem;
}
}
Регистрация сервиса в IoC-контейнере
// src/modules/loyalty/index.ts
import { Module } from '@medusajs/framework/utils';
import LoyaltyService from './service';
export const LOYALTY_MODULE = 'loyaltyModuleService';
export default Module(LOYALTY_MODULE, {
service: LoyaltyService,
});
// medusa-config.ts
export default defineConfig({
modules: [
{ resolve: './src/modules/loyalty' },
],
});
Использование сервиса в Workflow
// src/workflows/steps/add-loyalty-points.ts
import { createStep, StepResponse } from '@medusajs/framework/workflows-sdk';
import { LOYALTY_MODULE } from '../../modules/loyalty';
import LoyaltyService from '../../modules/loyalty/service';
export const addLoyaltyPointsStep = createStep(
'add-loyalty-points',
async (input: { orderId: string; customerId: string; orderTotal: number }, context) => {
const loyaltyService: LoyaltyService = context.container.resolve(LOYALTY_MODULE);
// 1 балл за каждые 100 рублей заказа
const pointsToAdd = Math.floor(input.orderTotal / 100);
await loyaltyService.addPoints({
customerId: input.customerId,
points: pointsToAdd,
reason: 'order_completed',
orderId: input.orderId,
});
return new StepResponse(
{ pointsAdded: pointsToAdd },
{ customerId: input.customerId, pointsToAdd } // данные для rollback
);
},
// Компенсация при ошибке в следующих шагах
async ({ customerId, pointsToAdd }, context) => {
const loyaltyService: LoyaltyService = context.container.resolve(LOYALTY_MODULE);
await loyaltyService.addPoints({
customerId,
points: -pointsToAdd,
reason: 'order_completed_rollback',
});
}
);
Использование в API-роуте
// src/api/store/loyalty/route.ts
import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http';
import { LOYALTY_MODULE } from '../../../modules/loyalty';
import LoyaltyService from '../../../modules/loyalty/service';
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const session = req.session as { customer_id?: string };
if (!session.customer_id) {
return res.status(401).json({ message: 'Не авторизован' });
}
const loyaltyService: LoyaltyService = req.scope.resolve(LOYALTY_MODULE);
const points = await loyaltyService.getCustomerPoints(session.customer_id);
res.json({ points, customer_id: session.customer_id });
};
Интеграция с внешним API в сервисе
// src/modules/erp-sync/service.ts
import { Logger } from '@medusajs/framework/types';
import axios, { AxiosInstance } from 'axios';
export default class ErpSyncService {
private http: AxiosInstance;
constructor({ logger }: { logger: Logger }) {
this.http = axios.create({
baseURL: process.env.ERP_API_URL,
headers: { 'X-Api-Key': process.env.ERP_API_KEY },
timeout: 10000,
});
// Retry логика
this.http.interceptors.response.use(
response => response,
async error => {
if (error.response?.status >= 500 && error.config._retryCount < 3) {
error.config._retryCount = (error.config._retryCount || 0) + 1;
await new Promise(r => setTimeout(r, 1000 * error.config._retryCount));
return this.http.request(error.config);
}
return Promise.reject(error);
}
);
}
async syncInventory(variantSku: string): Promise<number> {
const { data } = await this.http.get(`/inventory/${variantSku}`);
return data.quantity;
}
async pushOrderToErp(order: Record<string, unknown>): Promise<string> {
const { data } = await this.http.post('/orders', {
external_id: order.id,
items: order.items,
total: order.total,
customer_email: order.email,
});
return data.erp_order_id;
}
}
Тестирование сервисов
// src/modules/loyalty/__tests__/service.test.ts
import LoyaltyService from '../service';
const mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() };
const mockDb = { query: jest.fn() };
describe('LoyaltyService', () => {
let service: LoyaltyService;
beforeEach(() => {
service = new LoyaltyService({ logger: mockLogger as any });
(service as any).db = mockDb;
});
it('should calculate points correctly', async () => {
mockDb.query.mockResolvedValueOnce([{ total: 150 }]);
const points = await service.getCustomerPoints('cust_123');
expect(points).toBe(150);
});
it('should throw when redeeming more points than available', async () => {
mockDb.query.mockResolvedValueOnce([{ total: 50 }]);
await expect(service.redeemPoints('cust_123', 100))
.rejects.toThrow('Недостаточно баллов');
});
});
Сроки разработки
- Простой сервис (получение/запись данных, 1–2 операции): 1–2 дня
- Сервис с интеграцией внешнего API, retry логикой, тестами: 3–5 дней
- Сложный сервис (loyalty-программа, B2B pricing, кастомный инвентарь): 1–3 недели







