Skip to main content

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:
@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

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

1

Use Public/Protected Pattern

Public methods have @InjectManager and @EmitEvents. Protected methods (suffixed with _) have @InjectTransactionManager.
2

Always Pass Context

Always pass sharedContext to nested service calls to maintain transaction scope.
3

Validate Early

Validate input data before starting database operations to fail fast.
4

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

Build docs developers (and LLMs) love