Custom Medusa.js Service Development
Imagine your Medusa store processing 1000 orders per day. The first complex task: a loyalty program with points accrual for purchases and redemption for discounts. No ready-made module exists for such logic. Implementing it in API routes leads to spaghetti code and N+1 queries that slow down the response to 2 seconds. Custom services in Medusa are TypeScript classes that live in the IoC container and can do everything: from CRUD to integration with external services. We have accumulated sufficient experience in designing services for Medusa and are ready to share proven solutions. Below is an analysis using the loyalty module as an example.
Problems a Custom Service Solves
Placing business logic directly in controllers leads to N+1 queries, lack of reusability, and difficulties with unit testing. A custom service encapsulates everything: database operations, API calls, calculations. You get a module that can be called from workflows, subscribers, or the admin panel. This reduces maintenance time by 2–3 times compared to the "monolithic" approach. We guarantee the service will be designed with typical failure and retry scenarios in mind. According to Medusa documentation, custom services are the recommended way to organize complex logic.
How a Custom Service Eliminates N+1 Problem
N+1 queries are a common issue when working with related entities. Instead of making one query with JOIN, the controller executes a loop of N queries. A custom service solves this through repositories and data aggregation. For example, a loyalty service can get the sum of points for all customers in one SQL query, not one by one. This reduces response time by 60% and database load.
How We Do It (Technical Proof)
Below is a full example of a loyalty service, including module registration and usage in a Workflow.
// src/modules/loyalty/service.ts import { MedusaContainer, Logger } from '@medusajs/framework/types'; type LoyaltyPoint = { customerId: string; points: number; reason: string; orderId?: string; }; export default class LoyaltyService { protected logger: Logger; private db: any; // MikroORM or raw query constructor({ logger }: { logger: Logger }) { this.logger = logger; } async getCustomerPoints(customerId: string): Promise<number> { const result = await this.db.query( `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 this.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] ); } } // 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 defineConfig({ modules: [{ resolve: './src/modules/loyalty' }] }); Now we use the service in a workflow (step to add points after an order):
import { createStep, StepResponse } from '@medusajs/framework/workflows-sdk'; const addLoyaltyPointsStep = createStep( 'add-loyalty-points', async (input: { orderId: string; customerId: string; orderTotal: number }, ctx) => { const service: LoyaltyService = ctx.container.resolve(LOYALTY_MODULE); const pointsToAdd = Math.floor(input.orderTotal / 100); await service.addPoints({ customerId: input.customerId, points: pointsToAdd, reason: 'order_completed', orderId: input.orderId, }); return new StepResponse({ pointsAdded: pointsToAdd }, { customerId: input.customerId, pointsToAdd }); }, async ({ customerId, pointsToAdd }, ctx) => { const service: LoyaltyService = ctx.container.resolve(LOYALTY_MODULE); await service.addPoints({ customerId, points: -pointsToAdd, reason: 'rollback' }); } ); Type Comparison
| Type | Purpose | When to Use |
|---|---|---|
| Module Service | CRUD for module entity | Entity with standard operations (e.g., products, carts) |
| Custom Service | Arbitrary business logic | Specific logic needed (loyalty, ERP sync) |
| Workflow Step | Workflow step | Reusable operation across multiple scenarios |
Checklist for custom service development
- Define dependencies (logger, database, API)
- Implement the service interface
- Register the module in
index.tsandmedusa-config.ts - Write unit tests for critical logic
- Add JSDoc with method descriptions
- Test integration with Workflow and API routes
Process and Estimation
Process: Requirement analysis → Service design and dependencies → Implementation → Testing → Deployment. Takes from 1 day for simple solutions to 3 weeks for complex ones. Cost is calculated individually based on complexity. Project budget is discussed at the start. Order development and get stable logic without extra costs.
Time Estimates
| Service Type | Approximate Time |
|---|---|
| Simple (1–2 operations, one data source) | 1–2 days |
| With external API integration and retry logic | 3–5 days |
| Complex (loyalty logic, B2B pricing, custom inventory) | 1–3 weeks |
What's Included in Custom Service Development
We provide a complete package:
- Architecture and service design
- TypeScript implementation following Medusa best practices
- Testing (unit + integration)
- Documentation (README, JSDoc, call examples)
- Assistance with deployment and CI setup
- Training your team on working with the service
Contact us — we will evaluate your project and propose the optimal solution. This is an investment in the stability and growth of your e-commerce.
How a Custom Service Solves the Transactionality Problem
In addition to N+1, atomicity of operations is important. Medusa has a built-in TransactionService that allows combining multiple steps into one transaction. A custom service uses it to guarantee data integrity: for example, accruing points and applying a discount are performed in a single transaction. This eliminates desynchronization and simplifies debugging.







