Services
Services in Medusa encapsulate business logic and data access. They follow the MedusaService pattern, which provides automatic transaction management, event emission, and context injection through decorators.
MedusaService Pattern
All module services extend MedusaService, which provides:
Automatic CRUD operations - Generated list, retrieve, create, update, delete methods
Transaction management - Decorators for database transactions
Event emission - Automatic domain event publishing
Context injection - Shared context across operations
Type safety - Strongly typed DTOs and entities
The MedusaService pattern standardizes how services are built across all Medusa modules, ensuring consistency and reducing boilerplate.
Creating a Service
Basic Service Structure
import type {
Context ,
DAL ,
InternalModuleDeclaration ,
ModulesSdkTypes ,
} from "@medusajs/framework/types"
import {
InjectManager ,
InjectTransactionManager ,
MedusaContext ,
MedusaService ,
EmitEvents ,
} from "@medusajs/framework/utils"
import { ApiKey } from "@models"
import type { ApiKeyTypes } from "@medusajs/framework/types"
type InjectedDependencies = {
baseRepository : DAL . RepositoryService
apiKeyService : ModulesSdkTypes . IMedusaInternalService < any >
}
export class ApiKeyModuleService
extends MedusaService <{
ApiKey : { dto : ApiKeyTypes . ApiKeyDTO }
}>({ ApiKey })
implements IApiKeyModuleService
{
protected baseRepository_ : DAL . RepositoryService
protected readonly apiKeyService_ : ModulesSdkTypes . IMedusaInternalService
constructor (
{ baseRepository , apiKeyService } : InjectedDependencies ,
protected readonly moduleDeclaration : InternalModuleDeclaration
) {
super ( ... arguments )
this . baseRepository_ = baseRepository
this . apiKeyService_ = apiKeyService
}
__joinerConfig () : ModuleJoinerConfig {
return joinerConfig
}
}
Source: packages/modules/api-key/src/services/api-key-module-service.ts:44-67
Service Type Definitions
Define entity-to-DTO mappings in the generic:
export default class ProductModuleService
extends MedusaService <{
Product : { dto : ProductTypes . ProductDTO }
ProductCategory : { dto : ProductTypes . ProductCategoryDTO }
ProductCollection : { dto : ProductTypes . ProductCollectionDTO }
ProductOption : { dto : ProductTypes . ProductOptionDTO }
ProductVariant : { dto : ProductTypes . ProductVariantDTO }
}>({
Product ,
ProductCategory ,
ProductCollection ,
ProductOption ,
ProductVariant ,
})
{
// Service implementation
}
Source: packages/modules/product/src/services/product-module-service.ts:82-120
Service Decorators
Medusa provides four key decorators for cross-cutting concerns:
@InjectManager
Injects an entity manager for database operations. Use on public methods .
import { InjectManager , MedusaContext } from "@medusajs/framework/utils"
import type { Context } from "@medusajs/framework/types"
export class ApiKeyModuleService extends MedusaService {
@ InjectManager ()
async retrieveApiKey (
id : string ,
config ?: FindConfig < ApiKeyDTO >,
@ MedusaContext () sharedContext : Context = {}
) : Promise < ApiKeyDTO > {
const apiKey = await this . apiKeyService_ . retrieve (
id ,
config ,
sharedContext
)
return await this . baseRepository_ . serialize < ApiKeyDTO >( apiKey )
}
}
Always use @InjectManager() on public service methods that perform database operations. This ensures a proper entity manager is available.
@InjectTransactionManager
Injects a transaction manager for atomic operations. Use on protected methods .
import {
InjectManager ,
InjectTransactionManager ,
MedusaContext ,
EmitEvents ,
} from "@medusajs/framework/utils"
import type { Context } from "@medusajs/framework/types"
export class ApiKeyModuleService extends MedusaService {
@ InjectManager ()
@ EmitEvents ()
async deleteApiKeys (
ids : string | string [],
@ MedusaContext () sharedContext : Context = {}
) {
return await this . deleteApiKeys_ ( ids , sharedContext )
}
@ InjectTransactionManager ()
protected async deleteApiKeys_ (
ids : string | string [],
@ MedusaContext () sharedContext : Context = {}
) {
const apiKeyIds = Array . isArray ( ids ) ? ids : [ ids ]
const unrevokedApiKeys = await this . apiKeyService_ . list (
{
id: ids ,
$or: [
{ revoked_at: { $eq: null } },
{ revoked_at: { $gt: new Date () } },
],
},
{ select: [ "id" ] },
sharedContext
)
if ( unrevokedApiKeys . length ) {
throw new MedusaError (
MedusaError . Types . NOT_ALLOWED ,
`Cannot delete api keys that are not revoked`
)
}
return await super . deleteApiKeys ( apiKeyIds , sharedContext )
}
}
Source: packages/modules/api-key/src/services/api-key-module-service.ts:69-110
Use the convention of public methods calling protected methods with a _ suffix. The public method has @InjectManager and @EmitEvents, while the protected method has @InjectTransactionManager.
@MedusaContext
Injects shared context into the decorated parameter. Always use as the last parameter.
import { MedusaContext , InjectManager } from "@medusajs/framework/utils"
import type { Context } from "@medusajs/framework/types"
export class MyService extends MedusaService {
@ InjectManager ()
async myMethod (
id : string ,
data : UpdateData ,
@ MedusaContext () sharedContext : Context = {}
) {
// sharedContext contains:
// - transactionManager (if in transaction)
// - manager (entity manager)
// - isolationLevel
// - enableNestedTransactions
// - eventGroupId (for grouped events)
// - Custom context data
return await this . internalService_ . update (
id ,
data ,
sharedContext
)
}
}
The @MedusaContext() decorator must always be applied to the last parameter of the method, and that parameter should default to an empty object {}.
@EmitEvents
Automatically emits domain events after the method completes successfully.
import { InjectManager , EmitEvents , MedusaContext } from "@medusajs/framework/utils"
import type { Context } from "@medusajs/framework/types"
export class ApiKeyModuleService extends MedusaService {
@ InjectManager ()
@ EmitEvents ()
async createApiKeys (
data : ApiKeyTypes . CreateApiKeyDTO | ApiKeyTypes . CreateApiKeyDTO [],
@ MedusaContext () sharedContext : Context = {}
) : Promise < ApiKeyTypes . ApiKeyDTO | ApiKeyTypes . ApiKeyDTO []> {
const [ createdApiKeys , generatedTokens ] = await this . createApiKeys_ (
Array . isArray ( data ) ? data : [ data ],
sharedContext
)
const serializedResponse = await this . baseRepository_ . serialize (
createdApiKeys ,
{ populate: true }
)
return Array . isArray ( data ) ? serializedResponse : serializedResponse [ 0 ]
}
}
Source: packages/modules/api-key/src/services/api-key-module-service.ts:123-150
Events are automatically emitted based on the entity name and operation:
apiKey.created - After creating API keys
apiKey.updated - After updating API keys
apiKey.deleted - After deleting API keys
Decorator Combinations
Common decorator patterns:
Public Method Pattern
Protected Method Pattern
Read-Only Method
@ InjectManager ()
@ EmitEvents ()
async createEntity (
data : CreateDTO ,
@ MedusaContext () sharedContext : Context = {}
) {
return await this . createEntity_ ( data , sharedContext )
}
Transaction Management
Services handle transactions automatically through decorators:
Automatic Transactions
export class OrderModuleService extends MedusaService {
@ InjectManager ()
@ EmitEvents ()
async createOrder (
data : CreateOrderDTO ,
@ MedusaContext () sharedContext : Context = {}
) {
// Public method delegates to protected method
return await this . createOrder_ ( data , sharedContext )
}
@ InjectTransactionManager ()
protected async createOrder_ (
data : CreateOrderDTO ,
@ MedusaContext () sharedContext : Context = {}
) {
// All operations in this method run in a transaction
const order = await this . orderService_ . create ( data , sharedContext )
await this . orderLineItemService_ . create (
data . items ,
sharedContext
)
// If any operation fails, entire transaction rolls back
return order
}
}
Manual Transactions
For complex scenarios, manually manage transactions:
import { MedusaError } from "@medusajs/framework/utils"
export class MyService extends MedusaService {
async complexOperation ( data : ComplexData ) {
const manager = this . activeManager_
return await manager . transaction ( async ( transactionManager ) => {
const context = { transactionManager }
const result1 = await this . step1 ( data , context )
const result2 = await this . step2 ( result1 , context )
if ( ! result2 . isValid ) {
throw new MedusaError (
MedusaError . Types . INVALID_DATA ,
"Operation failed validation"
)
}
return result2
})
}
}
Event Emission
Services emit events automatically with @EmitEvents:
Automatic Event Names
Events follow the pattern {entityName}.{operation}:
// API Key operations
apiKey . created
apiKey . updated
apiKey . deleted
// Product operations
product . created
product . updated
product . deleted
// Order operations
order . created
order . updated
order . canceled
Event Data
Event data includes the entity IDs:
{
id : "apikey_123" , // Single entity
// or
ids : [ "apikey_123" , "apikey_456" ] // Multiple entities
}
Subscribing to Service Events
Create subscribers to handle service events:
import type { SubscriberArgs , SubscriberConfig } from "@medusajs/framework"
import { Modules } from "@medusajs/framework/utils"
export default async function handleApiKeyCreated ({
event : { data },
container ,
} : SubscriberArgs <{ id : string }>) {
const apiKeyModule = container . resolve ( Modules . API_KEY )
const apiKey = await apiKeyModule . retrieveApiKey ( data . id )
// Send notification, log audit trail, etc.
console . log ( `API key created: ${ apiKey . title } ` )
}
export const config : SubscriberConfig = {
event: "apiKey.created" ,
}
Context Propagation
The shared context flows through all service calls:
export class OrderService extends MedusaService {
@ InjectManager ()
@ EmitEvents ()
async createOrder (
data : CreateOrderDTO ,
@ MedusaContext () sharedContext : Context = {}
) {
// Context is passed to all nested calls
return await this . createOrder_ ( data , sharedContext )
}
@ InjectTransactionManager ()
protected async createOrder_ (
data : CreateOrderDTO ,
@ MedusaContext () sharedContext : Context = {}
) {
// Same context propagates to child services
const order = await this . orderService_ . create ( data , sharedContext )
// Transaction manager is shared via context
await this . lineItemService_ . create (
data . items ,
sharedContext // Contains transactionManager
)
return order
}
}
Custom Context Data
Add custom data to the context:
export const POST = async (
req : AuthenticatedMedusaRequest ,
res : MedusaResponse
) => {
const orderModule = req . scope . resolve ( Modules . ORDER )
const order = await orderModule . createOrder ( data , {
// Custom context data
actor_id: req . auth_context . actor_id ,
actor_type: req . auth_context . actor_type ,
})
res . json ({ order })
}
Error Handling
Services use MedusaError for consistent error handling:
import { MedusaError , MedusaContext , InjectManager } from "@medusajs/framework/utils"
import type { Context } from "@medusajs/framework/types"
export class ApiKeyModuleService extends MedusaService {
@ InjectManager ()
async revokeApiKey (
id : string ,
@ MedusaContext () sharedContext : Context = {}
) {
const apiKey = await this . apiKeyService_ . retrieve (
id ,
{ select: [ "id" , "revoked_at" ] },
sharedContext
)
if ( apiKey . revoked_at && apiKey . revoked_at < new Date ()) {
throw new MedusaError (
MedusaError . Types . NOT_ALLOWED ,
`API key ${ id } is already revoked`
)
}
return await this . apiKeyService_ . update (
id ,
{ revoked_at: new Date () },
sharedContext
)
}
}
Common Error Types
NOT_FOUND
INVALID_DATA
NOT_ALLOWED
throw new MedusaError (
MedusaError . Types . NOT_FOUND ,
`Product with id ${ id } not found`
)
Source: packages/modules/api-key/src/services/api-key-module-service.ts:100-107
Best Practices
Use Public/Protected Pattern
Public methods have @InjectManager and @EmitEvents. Protected methods (suffixed with _) have @InjectTransactionManager.
Always Pass Context
Always pass sharedContext to nested service calls to maintain transaction scope.
Validate Early
Validate input data before starting database operations to fail fast.
Return Serialized DTOs
Use baseRepository_.serialize() to return properly typed DTOs.
Never access this.manager_ or this.transactionManager_ directly. Always use decorators and pass context to maintain proper transaction boundaries.
Testing Services
Services are testable through dependency injection:
import { createContainer , asValue , asClass } from "@medusajs/deps/awilix"
import { ApiKeyModuleService } from "../api-key-module-service"
describe ( "ApiKeyModuleService" , () => {
let container
let service
beforeEach (() => {
container = createContainer ()
container . register ({
baseRepository: asValue ({
serialize: jest . fn (( data ) => data ),
}),
apiKeyService: asValue ({
create: jest . fn (),
retrieve: jest . fn (),
list: jest . fn (),
delete: jest . fn (),
}),
})
container . register ({
apiKeyModuleService: asClass ( ApiKeyModuleService ). singleton (),
})
service = container . resolve ( "apiKeyModuleService" )
})
it ( "should create an API key" , async () => {
const mockApiKey = { id: "apikey_123" , title: "Test Key" }
container . resolve ( "apiKeyService" ). create . mockResolvedValue ( mockApiKey )
const result = await service . createApiKeys ({
title: "Test Key" ,
type: "secret" ,
})
expect ( result ). toEqual ( mockApiKey )
})
})
Next Steps
Events Learn how to subscribe to service events
Workflows Use services in workflow steps