Skip to content
ExponaBLOG
NESTJS · DAY 085 / 100

Designing module boundaries: what developers should know

Good boundaries make features easy to change and delete.

Expona Admin7 min read

Designing module boundaries

After 84 days of building with NestJS, you've probably written services, controllers, and repositories. But the real skill emerges when you know where to draw the line between modules. Good boundaries make features easy to change and delete, whereas poor ones trap you in a web of circular dependencies and tight coupling that makes every refactor a nightmare.

I learned this the hard way. Early in my career, I built an e-commerce platform where the order module directly queried the payment module's Prisma models, which queried inventory, which reached back into order. Six months later, when we needed to swap payment providers, we touched code in five different places. With proper boundaries, that change would have lived in one module.

Export the minimum: one service per module

The simplest rule is also the most effective. Each module should export exactly one service—or as close to one as you can reasonably get. That service becomes the public API. Everything else stays internal.

Here's what I mean. In a ride-hailing platform, the driver module needs to manage driver profiles, ratings, and availability. You might be tempted to export DriverService, RatingService, and AvailabilityService. Resist that urge.

typescript
// Bad: exposing implementation details
@Module({
  providers: [DriverService, RatingService, AvailabilityService],
  exports: [DriverService, RatingService, AvailabilityService],
})
export class DriverModule {}

Now any other module can call RatingService.updateRating() directly, bypassing your driver business logic. When you need to log rating changes or trigger notifications, you have to add that logic in multiple places.

Instead, create a facade service:

typescript
@Injectable()
export class DriverFacadeService {
  constructor(
    private driverService: DriverService,
    private ratingService: RatingService,
    private availabilityService: AvailabilityService,
    private logger: LoggerService,
  ) {}

  async rateDriver(driverId: string, score: number, comment: string) {
    this.logger.info(`Rating driver ${driverId}: ${score}`);
    const driver = await this.driverService.findById(driverId);
    const updated = await this.ratingService.updateRating(driver, score, comment);
    
    // Business logic lives here—not in the caller's code
    if (updated.averageRating < 3.5) {
      await this.availabilityService.suspendTemporarily(driverId);
    }
    
    return updated;
  }

  async getDriverSummary(driverId: string) {
    const driver = await this.driverService.findById(driverId);
    const rating = await this.ratingService.getByDriver(driverId);
    const availability = await this.availabilityService.getStatus(driverId);
    
    return { driver, rating, availability };
  }
}

@Module({
  providers: [DriverService, RatingService, AvailabilityService, DriverFacadeService],
  exports: [DriverFacadeService],
})
export class DriverModule {}

Now the rest of your application calls one service, and your driver logic is centralized. When rules change, you change one place.

No module reaches into another's Prisma models

This is the boundary that saves the most pain. If module A queries module B's Prisma models directly, you've created a hidden dependency. It's invisible in your imports—the compiler won't warn you. But it's real.

Consider a travel booking system. The booking module needs to know if a destination has available accommodations. A junior developer might write:

typescript
// Bad: booking module directly queries accommodation schema
@Injectable()
export class BookingService {
  constructor(private prisma: PrismaService) {}

  async createBooking(userId: string, destinationId: string, dates: DateRange) {
    // Direct query of accommodation schema
    const available = await this.prisma.accommodation.findMany({
      where: {
        destinationId,
        availability: { some: { date: { gte: dates.start, lte: dates.end } } },
      },
    });

    if (available.length === 0) {
      throw new BadRequestException('No availability');
    }

    return this.prisma.booking.create({
      data: { userId, destinationId, dates: JSON.stringify(dates) },
    });
  }
}

The problem: if the accommodation team changes their schema—renames a field, splits it into two tables, adds a new status—the booking module breaks. You've accidentally made their schema evolution your concern.

The solution: the accommodation module exports a service that booking calls:

typescript
// In accommodation module
@Injectable()
export class AccommodationFacadeService {
  constructor(private prisma: PrismaService) {}

  async findAvailable(destinationId: string, dates: DateRange): Promise {
    const raw = await this.prisma.accommodation.findMany({
      where: {
        destinationId,
        availability: { some: { date: { gte: dates.start, lte: dates.end } } },
      },
      select: { id: true, name: true, pricePerNight: true },
    });

    return raw.map(acc => new AccommodationDto(acc));
  }
}

// In booking module
@Injectable()
export class BookingService {
  constructor(private accommodationFacade: AccommodationFacadeService) {}

  async createBooking(userId: string, destinationId: string, dates: DateRange) {
    const available = await this.accommodationFacade.findAvailable(destinationId, dates);

    if (available.length === 0) {
      throw new BadRequestException('No availability');
    }

    return this.prisma.booking.create({
      data: { userId, destinationId, dates: JSON.stringify(dates) },
    });
  }
}

Now accommodation can reorganize their schema however they want, as long as the service interface stays stable. That's a much stronger boundary.

Communicate through services or events

Modules talk to each other in exactly two ways: synchronous service calls or asynchronous events. Nothing else.

Service calls work well for immediate, critical operations. If the notification module needs to send an email after a user signs up, and that email must succeed before the signup completes, call the notification service directly:

typescript
@Injectable()
export class UserService {
  constructor(
    private prisma: PrismaService,
    private notificationFacade: NotificationFacadeService,
  ) {}

  async signup(email: string, password: string) {
    const user = await this.prisma.user.create({
      data: { email, password: hashPassword(password) },
    });

    // Critical path—must succeed
    await this.notificationFacade.sendWelcomeEmail(user.email);

    return user;
  }
}

Events work well for side effects that don't block the main flow. If you want to log an analytics event, update a cache, or trigger a background job after a booking is confirmed, emit an event:

typescript
// In booking module
@Injectable()
export class BookingService {
  constructor(
    private prisma: PrismaService,
    private eventEmitter: EventEmitter2,
  ) {}

  async confirmBooking(bookingId: string) {
    const booking = await this.prisma.booking.update({
      where: { id: bookingId },
      data: { status: 'CONFIRMED' },
    });

    // Emit—don't wait
    this.eventEmitter.emit('booking.confirmed', { bookingId: booking.id, userId: booking.userId });

    return booking;
  }
}

// In analytics module—completely decoupled
@Injectable()
export class BookingAnalyticsService {
  @OnEvent('booking.confirmed')
  handleBookingConfirmed(payload: { bookingId: string; userId: string }) {
    // Log, aggregate, send to warehouse
    // If this fails, it doesn't break the booking flow
  }
}

The key difference: service calls are synchronous contracts. Events are one-way signals. Use events when the calling module doesn't need to know the result and failure is non-critical.

If two modules always change together, merge them

Sometimes you create a boundary, then realize you're moving between two modules in lockstep. Every time you change one, you change the other. The boundary is just friction.

In my CRM platform, we had a separate contact module and a company module. Seemed clean. But in practice, any business logic around companies involved contacts—filtering by contact role, checking contact permissions, validating contact-company relationships. We were constantly cross-wiring them.

After six months, I merged them into an organization module that contained both company and contact logic under one facade. Deployment got simpler, tests ran faster (fewer mocks), and the code was easier to reason about. Sometimes simplicity beats separation.

The heuristic: if you find yourself making coordinated changes across modules more than once a month, consider merging them. Module boundaries should reduce complexity, not multiply it.

Common mistakes

  • Circular dependencies via services. Module A calls Service B, Module B calls Service A. You've just made a cycle. Break it with events or rethink the boundary. Use NestJS's circular dependency detection (forwardRef) as a red flag, not a solution.
  • Exporting too many services. "We might need this someday" leads to bloated exports. Stick to one. If you need more later, refactor then.
  • Assuming DTOs are a substitute for boundaries. Wrapping a service call in a DTO doesn't hide tight coupling. The real boundary is behavioral—do modules own their data and logic?
  • Merging modules too early. Three features don't need one module yet. Wait until the coupling is painful before consolidating.
  • Using Kafka or events for everything. Async communication adds complexity. For core workflows, direct service calls are clearer and faster to debug.

Key takeaways

  • Export one service per module—everything else is internal implementation.
  • Never query another module's Prisma models directly. Go through their service.
  • Modules communicate via service calls (sync) or events (async). Nothing else.
  • Keep boundaries where coupling is high. Merge modules where coordination is constant.
  • Strong boundaries make features easy to change, test, and delete. Weak ones make everything harder.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles