Skip to content

Circular Dependencies

Circular dependencies occur when module A imports module B, and module B imports module A (directly or through a chain).

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ User β”‚
β”‚ Service β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β”‚ imports
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Product β”‚
β”‚ Service β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β”‚ imports
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Order β”‚
β”‚ Service β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β”‚ imports (back to User!)
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ User β”‚ ← CIRCULAR!
β”‚ Service β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
src/features/users/users.feature.js
src/features/users/users.feature.js
import { db } from '../../core/database/database.service.js'
import { helpers } from '../../helpers.js'
// ANTIPATTERN: User imports Product and Order
import { ProductService } from '../products/products.feature.js'
import { OrderService } from '../orders/orders.feature.js'
export class UserService {
constructor() {
// Creates instances of other services
this.productService = new ProductService()
this.orderService = new OrderService()
}
getUserWithOrders(userId) {
const user = this.getUser(userId)
// Uses OrderService which uses UserService!
user.orders = this.orderService.getOrdersByUser(userId)
return user
}
}
src/features/orders/orders.feature.js
src/features/orders/orders.feature.js
import { db } from '../../core/database/database.service.js'
// ANTIPATTERN: Order imports User and Product (circular!)
import { UserService } from '../users/users.feature.js'
import { ProductService } from '../products/products.feature.js'
export class OrderService {
constructor() {
// Creates UserService which creates OrderService!
this.userService = new UserService()
this.productService = new ProductService()
}
getOrdersByUser(userId) {
// Uses UserService to get user details
const user = this.userService.getUser(userId)
return db.query(`SELECT * FROM orders WHERE user_id = ${userId}`)
}
}
// Which runs first?
// UserService needs OrderService
// OrderService needs UserService
// Result: One of them gets undefined!
const userService = new UserService()
// Inside UserService:
// this.orderService = new OrderService()
// Inside OrderService:
// this.userService = new UserService() // INFINITE LOOP!
// In ES Modules with circular deps
import { UserService } from './users.js' // Might be undefined!
console.log(UserService) // undefined (not initialized yet)
// Each creates instances of the other
const user = new UserService()
β†’ creates OrderService
β†’ creates UserService
β†’ creates OrderService
β†’ ... (stack overflow or memory exhaustion)
// To test UserService, you need OrderService
// To test OrderService, you need UserService
// You can never test just one!

Instead of concrete dependencies, depend on interfaces/abstractions:

src/services/user.service.js
// UserService doesn't know about OrderService
export class UserService {
constructor(db) {
this.db = db
}
async getUser(id) {
return this.db.users.findUnique({ where: { id } })
}
async getUsersByIds(ids) {
return this.db.users.findMany({ where: { id: { in: ids } } })
}
}
src/services/order.service.js
// OrderService receives UserService, doesn't import it
export class OrderService {
constructor(db, userService) {
this.db = db
this.userService = userService
}
async getOrdersWithUsers(orderId) {
const orders = await this.db.orders.findMany()
const userIds = [...new Set(orders.map(o => o.userId))]
const users = await this.userService.getUsersByIds(userIds)
// ...
}
}
src/composition-root.js
import { UserService } from './services/user.service.js'
import { OrderService } from './services/order.service.js'
export function createServices(db) {
// Create in proper order - no circular deps!
const userService = new UserService(db)
const orderService = new OrderService(db, userService)
return { userService, orderService }
}

If two services need the same logic, extract it:

src/services/user-order.service.js
// Combined operations that need both
export class UserOrderService {
constructor(userService, orderService) {
this.userService = userService
this.orderService = orderService
}
async getUserWithOrders(userId) {
const [user, orders] = await Promise.all([
this.userService.getUser(userId),
this.orderService.getOrdersByUser(userId),
])
return { ...user, orders }
}
}
src/services/order.service.js
export class OrderService {
constructor(db, eventBus) {
this.db = db
this.eventBus = eventBus
}
async createOrder(order) {
const created = await this.db.orders.create({ data: order })
// Emit event instead of calling UserService directly
this.eventBus.emit('order.created', {
orderId: created.id,
userId: order.userId
})
return created
}
}
src/handlers/user-order.handler.js
// Separate handler listens for events
eventBus.on('order.created', async ({ userId }) => {
await userService.incrementOrderCount(userId)
})

UserService ──────────► OrderService
β–² β”‚
β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
CIRCULAR!
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Composition Root β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
β–Ό β–Ό
UserService OrderService
β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β–Ό
Database

  • madge - npx madge --circular src/
  • dependency-cruiser - Detects and visualizes cycles
  • ESLint plugin import - import/no-cycle rule
  1. Draw the import graph
  2. If you can follow arrows back to the starting point, you have a cycle
Terminal window
# Find circular dependencies
npx madge --circular --extensions js src/