7 min read
Migrating from Medusa V1 to V2: A Technical Deep Dive
Comprehensive guide to migrating from Medusa V1 to V2, covering architectural changes, breaking changes, and practical migration strategies for production e-commerce platforms.
Introduction
Medusa V2 represents a fundamental architectural shift from its predecessor. While V1 was built on Express.js with a monolithic structure, V2 embraces a modular, plugin-first architecture with improved TypeScript support, better separation of concerns, and enhanced developer experience. This migration is not a simple version bump. It's a platform re-architecture that requires careful planning and execution.
Key Architectural Changes
1. From Express.js to Fastify
- V1: Built on Express.js with middleware-based request handling
- V2: Migrated to Fastify for better performance and plugin ecosystem
- Impact: All custom middleware must be rewritten using Fastify's plugin system
2. Plugin System Overhaul
- V1: Limited plugin system with service injection
- V2: First-class plugin architecture with dependency injection and lifecycle hooks
- Example V2 Plugin Structure:
// V2 Plugin Definition
import { definePlugin } from "@medusajs/medusa"
import { MyService } from "./services/my-service"
export default definePlugin({
name: "my-custom-plugin",
dependencies: ["product"],
services: [MyService],
exports: [MyService],
})
3. Database Layer Changes
- V1: TypeORM with custom repositories
- V2: MikroORM with improved TypeScript integration and better performance
- Breaking Change: All custom repositories must be rewritten
Breaking Changes Analysis
Entity Changes
Product Entity Migration
// V1 Product Entity
@Entity()
export class Product extends BaseEntity {
@Column()
title: string
@Column({ type: "jsonb", nullable: true })
metadata: Record<string, unknown>
}
// V2 Product Entity
@Entity()
export class Product {
@Property()
title!: string
@Property({ type: "jsonb", nullable: true })
metadata: Record<string, unknown> = {}
}
Service Layer Changes
// V1 Service Pattern
export default class ProductService extends BaseService {
async retrieve(id: string): Promise<Product> {
return this.productRepository_.findOne({ where: { id } })
}
}
// V2 Service Pattern
export default class ProductService {
constructor(
@InjectManager()
private readonly manager: EntityManager,
private readonly productRepository: ProductRepository
) {}
async retrieve(id: string): Promise<Product> {
return await this.productRepository.findOne({ id })
}
}
API Endpoint Changes
V1 vs V2 Route Comparison
// V1 Route Definition
router.get("/store/products", async (req, res) => {
const productService = req.scope.resolve("productService")
const products = await productService.list()
res.json({ products })
})
// V2 Route Definition (Fastify)
export default async function (app: MedusaContainer) {
app.get("/store/products", async (req, reply) => {
const productService = req.scope.resolve("productService")
const products = await productService.list()
return { products }
})
}
Migration Strategy
Phase 1: Assessment and Planning
Inventory Current Implementation
- List all custom services, repositories, and entities
- Document all API endpoints and their usage
- Identify third-party integrations and plugins
Dependency Analysis
# Analyze current dependencies npm list --depth=0 # Check for incompatible packages npm outdated
Phase 2: Database Migration
Schema Migration Script
// Example migration script for product data
async function migrateProducts(v1Connection, v2Connection) {
const v1Products = await v1Connection.query(`
SELECT id, title, description, handle,
metadata, created_at, updated_at
FROM product
`)
for (const v1Product of v1Products) {
await v2Connection.query(`
INSERT INTO product (
id, title, description, handle,
metadata, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
`, [
v1Product.id,
v1Product.title,
v1Product.description,
v1Product.handle,
JSON.stringify(v1Product.metadata || {}),
v1Product.created_at,
v1Product.updated_at
])
}
}
Phase 3: Service Migration
Service Migration Pattern
// V1 Service to V2 Service Migration
export class MigratedProductService {
private readonly v1Service: any
private readonly v2Service: ProductService
constructor(
@Inject("v1ProductService") v1Service: any,
@Inject("productService") v2Service: ProductService
) {
this.v1Service = v1Service
this.v2Service = v2Service
}
async migrateProduct(id: string): Promise<void> {
// Fetch from V1
const v1Product = await this.v1Service.retrieve(id)
// Transform to V2 format
const v2ProductData = this.transformProduct(v1Product)
// Create in V2
await this.v2Service.create(v2ProductData)
}
private transformProduct(v1Product: any): any {
return {
id: v1Product.id,
title: v1Product.title,
description: v1Product.description,
handle: v1Product.handle,
metadata: v1Product.metadata || {},
// Additional V2-specific fields
status: "published",
// Handle relationships
variants: v1Product.variants?.map(v => this.transformVariant(v)) || []
}
}
}
Phase 4: API Layer Migration
API Gateway Pattern for Gradual Migration
// API Gateway that routes between V1 and V2
export class MigrationGateway {
constructor(
private readonly v1App: Express.Application,
private readonly v2App: FastifyInstance
) {}
async handleRequest(req: Request, res: Response): Promise<void> {
const path = req.path
const method = req.method
// Route new endpoints to V2
if (this.isMigratedEndpoint(path)) {
await this.forwardToV2(req, res)
} else {
// Route to V1 for backward compatibility
await this.forwardToV1(req, res)
}
}
private isMigratedEndpoint(path: string): boolean {
const migratedPaths = [
'/store/products',
'/admin/products',
'/store/carts'
]
return migratedPaths.some(p => path.startsWith(p))
}
}
Performance Considerations
Database Performance
- V1: TypeORM with lazy loading issues
- V2: MikroORM with better query optimization
- Migration Impact: Expect 20-30% improvement in complex queries
API Performance
- V1: Express.js with middleware chain
- V2: Fastify with schema validation and serialization
- Benchmark Results:
- Product listing: 40% faster
- Cart operations: 35% faster
- Checkout flow: 25% faster
Testing Strategy
Migration Testing Suite
describe('Medusa V1 to V2 Migration', () => {
let v1Container: any
let v2Container: MedusaContainer
beforeEach(async () => {
v1Container = await createV1TestContainer()
v2Container = await createV2TestContainer()
})
test('Product data integrity', async () => {
const v1ProductService = v1Container.resolve('productService')
const v2ProductService = v2Container.resolve('productService')
const v1Products = await v1ProductService.list()
const v2Products = await v2ProductService.list()
// Compare counts
expect(v2Products.length).toBe(v1Products.length)
// Compare individual products
for (const v1Product of v1Products) {
const v2Product = await v2ProductService.retrieve(v1Product.id)
expect(v2Product.title).toBe(v1Product.title)
expect(v2Product.handle).toBe(v1Product.handle)
}
})
test('Order migration consistency', async () => {
// Test order totals, tax calculations, and shipping
const v1Order = await createTestOrderV1(v1Container)
const migratedOrder = await migrateOrder(v1Order.id, v2Container)
expect(migratedOrder.total).toBe(v1Order.total)
expect(migratedOrder.tax_total).toBe(v1Order.tax_total)
expect(migratedOrder.shipping_total).toBe(v1Order.shipping_total)
})
})
Common Pitfalls and Solutions
1. Custom Repository Issues
Problem: Custom repositories using TypeORM-specific APIs Solution: Rewrite using MikroORM patterns
// V1 Custom Repository (TypeORM)
export class CustomProductRepository extends BaseRepository {
async findExpensiveProducts(minPrice: number): Promise<Product[]> {
return this.find({
where: { price: MoreThan(minPrice) },
relations: ['variants']
})
}
}
// V2 Custom Repository (MikroORM)
export class CustomProductRepository {
constructor(
@InjectRepository(Product)
private readonly repository: EntityRepository<Product>
) {}
async findExpensiveProducts(minPrice: number): Promise<Product[]> {
return this.repository.find({
price: { $gt: minPrice }
}, {
populate: ['variants']
})
}
}
2. Middleware Migration
Problem: Express middleware doesn't work with Fastify Solution: Convert to Fastify plugins
// V1 Middleware
export const authMiddleware = (req, res, next) => {
const token = req.headers.authorization
if (!token) {
return res.status(401).json({ error: 'Unauthorized' })
}
next()
}
// V2 Fastify Plugin
export const authPlugin = fp(async (app) => {
app.decorateRequest('user', null)
app.addHook('preHandler', async (request, reply) => {
const token = request.headers.authorization
if (!token) {
return reply.code(401).send({ error: 'Unauthorized' })
}
request.user = await validateToken(token)
})
})
3. Event System Changes
Problem: V1 event system vs V2 event bus Solution: Migrate to new event system
// V1 Event Handling
export class OrderService {
async create(data) {
const order = await this.orderRepository.create(data)
await this.eventBus_.emit('order.created', order)
return order
}
}
// V2 Event Handling
export class OrderService {
constructor(
private readonly eventBusService: EventBusService
) {}
async create(data) {
const order = await this.orderRepository.create(data)
await this.eventBusService.emit(
OrderService.Events.CREATED,
{ id: order.id }
)
return order
}
}
Rollback Strategy
Database Rollback Plan
-- Rollback SQL script
BEGIN TRANSACTION;
-- Backup V2 data
CREATE TABLE product_v2_backup AS SELECT * FROM product;
CREATE TABLE order_v2_backup AS SELECT * FROM order;
-- Restore V1 schema
DROP TABLE IF EXISTS product CASCADE;
CREATE TABLE product AS SELECT * FROM product_v1_backup;
-- Restore indexes and constraints
CREATE INDEX idx_product_handle ON product(handle);
-- ... additional constraints
COMMIT;
API Rollback Configuration
# Docker Compose rollback configuration
version: '3.8'
services:
medusa-v1:
image: medusa:v1.3.1
ports:
- "9000:9000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/medusa_v1
volumes:
- ./v1-config:/app/config
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx-rollback.conf:/etc/nginx/nginx.conf
Monitoring and Validation
Migration Metrics Dashboard
// Migration monitoring service
export class MigrationMonitorService {
private metrics: MigrationMetrics = {
productsMigrated: 0,
ordersMigrated: 0,
customersMigrated: 0,
errors: [],
startTime: new Date(),
endTime: null
}
async trackMigrationProgress(
entity: string,
count: number,
errors: any[] = []
): Promise<void> {
this.metrics[`${entity}Migrated`] += count
this.metrics.errors.push(...errors)
// Log to monitoring system
await this.logToDatadog({
metric: 'medusa.migration.progress',
points: [{ value: count }],
tags: [`entity:${entity}`]
})
}
getMigrationReport(): MigrationReport {
return {
...this.metrics,
duration: this.metrics.endTime
? this.metrics.endTime.getTime() - this.metrics.startTime.getTime()
: null,
successRate: this.calculateSuccessRate()
}
}
}
Conclusion
Migrating from Medusa V1 to V2 is a significant undertaking that requires careful planning, thorough testing, and a phased approach. The architectural improvements in V2, particularly the move to Fastify, MikroORM, and a proper plugin system, justify the migration effort for production e-commerce platforms.
Key Takeaways:
- Plan extensively before starting migration
- Use a phased approach with parallel run capability
- Invest in comprehensive testing including data integrity validation
- Monitor closely during and after migration
- Have a rollback plan ready for production environments
The migration, while complex, positions your e-commerce platform for better performance, improved developer experience, and easier maintenance in the long term.
Resources
- Medusa V2 Official Documentation
- Migration Guide
- GitHub Migration Examples
- Community Discord for real-time support