# 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.

- Author: Amit Chhatbar, Forward Deployed Engineer · AI (https://amitchhatbar.com)
- Published: 2026-05-13
- Updated: 2026-05-13
- Canonical URL: https://amitchhatbar.com/blog/medusa-v1-to-v2-migration

## 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:**

```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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

1. **Inventory Current Implementation**
   - List all custom services, repositories, and entities
   - Document all API endpoints and their usage
   - Identify third-party integrations and plugins

2. **Dependency Analysis**
   ```bash
   # Analyze current dependencies
   npm list --depth=0
   # Check for incompatible packages
   npm outdated
   ```

### Phase 2: Database Migration

#### Schema Migration Script
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
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

```typescript
// 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

```typescript
// 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

```typescript
// 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
```sql
-- 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
```yaml
# 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
```typescript
// 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:**
1. **Plan extensively** before starting migration
2. **Use a phased approach** with parallel run capability
3. **Invest in comprehensive testing** including data integrity validation
4. **Monitor closely** during and after migration
5. **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](https://docs.medusajs.com/v2/)
- [Migration Guide](https://docs.medusajs.com/v2/migration-guide/)
- [GitHub Migration Examples](https://github.com/medusajs/medusa/tree/main/examples/migration)
- [Community Discord](https://discord.gg/medusajs) for real-time support
