Skip to content
ExponaBLOG
NESTJS · DAY 100 / 100

100 days of NestJS: lessons — what developers should know

Wrapping up the series.

Expona Admin8 min read

We've reached day 100 of NestJS, and it's time to reflect on what we've learned building real systems with this framework. After 100 days of deep dives into middleware, dependency injection, microservices, testing and production deployments, a clear pattern emerges: the framework's real power isn't in any single feature, but in how it encourages teams to build systems that scale alongside their engineering organizations. This article captures the core lessons from 100 days of NestJS.

Structure is the feature — NestJS pays off when teams grow

NestJS enforces modularity from day one. Controllers live separate from services. Services are injected, not instantiated. Modules declare their dependencies. None of this is revolutionary, but the discipline it enforces prevents the common trajectory where a codebase starts clean and gradually becomes untangle-able.

We've seen this play out across teams. Early on, a single developer might hack a controller that directly queries the database, caches in memory, and formats responses. It works. Six months later, three new engineers join. They don't know where to add features. They copy patterns they see. Suddenly there are database queries scattered across five files. Caching logic is duplicated. Testing requires spinning up a real database. The codebase has calcified into something harder to work with, not easier.

NestJS prevents this by making the structured path the easiest path. Consider a simple example: a user service in a growing platform.

typescript
// Correct structure: separation of concerns
// services/user.service.ts
@Injectable()
export class UserService {
  constructor(private db: DatabaseService) {}

  async findById(id: string) {
    return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
  }

  async updateProfile(id: string, data: UpdateUserDto) {
    return this.db.query('UPDATE users SET ... WHERE id = $1', [id, ...]);
  }
}

// controllers/user.controller.ts
@Controller('users')
export class UserController {
  constructor(private users: UserService) {}

  @Get(':id')
  async getUser(@Param('id') id: string) {
    return this.users.findById(id);
  }

  @Patch(':id')
  async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) {
    return this.users.updateProfile(id, dto);
  }
}

// modules/user.module.ts
@Module({
  imports: [DatabaseModule],
  providers: [UserService],
  controllers: [UserController],
  exports: [UserService],
})
export class UserModule {}

Consistency beats cleverness. When every module follows this pattern, new engineers know where to look. When the requirements change—say, you need to add caching or audit logging—there's exactly one place to add it. The service layer. Consistency means less cognitive load. It means onboarding new team members in days, not weeks. It means pulling in a piece of code months later and immediately understanding what it does.

Boundaries matter most — Good modules made every later decision easier

The most valuable lesson from 100 days of NestJS is this: spending time on module design at the start saves enormous effort later. We're not talking about premature optimization. We're talking about logical boundaries.

Consider a payments feature. You could build it as a single monolithic module. But better: define a clear interface at the boundary.

typescript
// payments/interfaces/payment-processor.interface.ts
export interface IPaymentProcessor {
  charge(customerId: string, amountCents: number): Promise<{
    transactionId: string;
    status: 'pending' | 'success' | 'failed';
  }>;
  
  refund(transactionId: string): Promise<{
    status: 'success' | 'failed';
    reason?: string;
  }>;
}

// payments/stripe-processor.ts
@Injectable()
export class StripePaymentProcessor implements IPaymentProcessor {
  constructor(private stripe: Stripe) {}
  
  async charge(customerId: string, amountCents: number) {
    // Stripe-specific logic
  }
  
  async refund(transactionId: string) {
    // Stripe-specific logic
  }
}

// payments/payments.module.ts
@Module({
  providers: [
    {
      provide: 'IPaymentProcessor',
      useClass: StripePaymentProcessor,
    },
  ],
  exports: ['IPaymentProcessor'],
})
export class PaymentsModule {}

Why does this matter? Because six months from now, your pricing changes and you need to support multiple payment providers. Or you need to add a mock processor for tests. Or compliance requires a new provider. With clear module boundaries, each of these decisions is a one-line change to the providers array. Without them, the payment logic is tangled across controllers, services, and background jobs. Untangling it takes days.

Good modules make testing easier. You inject a mock processor. You inject a stub database. Your unit tests run in milliseconds. Good modules make scaling easier. When payment processing becomes a bottleneck, you extract it to a separate microservice. The interface stays the same. The consuming code doesn't change. Good modules make splitting teams easier. One team owns the payment module. Another owns orders. Neither needs to understand the other's internals.

Production is the real test — Logging, health checks and graceful shutdown separate demos from systems

A NestJS application that works in development is not a production system. The difference is in the details that nobody thinks about until 2 AM when your service has crashed and you can't debug why.

Logging is the first one. A development app might have console.log calls. In production, those are useless. You need structured logging. You need to be able to trace a request through your entire system. You need different log levels. You need logs shipped to a central place.

typescript
// Use a proper logger
import { Logger } from '@nestjs/common';

@Injectable()
export class OrderService {
  private logger = new Logger(OrderService.name);

  async createOrder(userId: string, items: OrderItem[]) {
    this.logger.log(`Creating order for user ${userId}`, 'createOrder');
    
    try {
      const order = await this.db.orders.create({ userId, items });
      this.logger.debug(`Order created with ID ${order.id}`);
      return order;
    } catch (error) {
      this.logger.error(`Failed to create order: ${error.message}`, error.stack);
      throw error;
    }
  }
}

Health checks are the second one. A production system needs to tell orchestrators (Kubernetes, load balancers, monitoring systems) whether it's healthy. Not just "is the process running" but "can it connect to the database? Can it reach Redis? Is it handling requests normally?"

typescript
// health/health.controller.ts
@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: HealthIndicator,
    private redis: HealthIndicator,
  ) {}

  @Get()
  check() {
    return this.health.check([
      () => this.db.pingCheck('database', { timeout: 1000 }),
      () => this.redis.pingCheck('redis', { timeout: 1000 }),
    ]);
  }
}

Graceful shutdown is the third one. When you deploy a new version, the old process doesn't die instantly. It stops accepting new requests. It finishes in-flight requests. It closes database connections cleanly. It flushes buffers. This isn't optional.

typescript
// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // Handle shutdown signals
  process.on('SIGTERM', async () => {
    console.log('SIGTERM received, shutting down gracefully');
    await app.close();
    process.exit(0);
  });

  await app.listen(3000);
}

bootstrap();

These three things—structured logging, health checks, graceful shutdown—transform a demo into something you can run in production. They're not glamorous. They don't make the feature list. But they're the difference between a service that occasionally needs manual intervention and one that runs reliably for months.

Keep learning in public — Full articles on expona.dev and what's next

Over 100 days, we've published deep dives on dependency injection, middleware, request lifecycle, microservices, WebSocket communication, testing strategies, database integration, performance optimization, and deployment patterns. Each article was written by engineers with real production experience across e-commerce, SaaS, trading, and ride-hailing platforms. The patterns we've covered work because they've been tested by teams at scale.

Learning in public—writing it down, sharing it, getting feedback—changed how we think about these topics. It forced clarity. If you can't explain a pattern clearly, you don't understand it deeply enough. If readers challenge your approach, you learn something. If someone applies a pattern and finds it works, you validate the approach.

All 100 articles are available on expona.dev. They're searchable. They're linked. They're written for engineers who are building real systems, not tutorials for beginners.

What should the next deep dive be? We're considering:

  • Event-driven architecture with NestJS and Kafka at scale
  • Multi-tenancy patterns: database isolation, data routing, compliance
  • Rate limiting and backpressure: practical strategies for protecting APIs
  • Observability: distributed tracing, metrics, and debugging production issues
  • Global error handling: from granular exception filters to cross-system error recovery

Let us know what would be most valuable for your team.

Common mistakes

  • Skipping module boundaries early. It feels faster to throw everything in one big module. It isn't. The cost compounds. Start with clear boundaries from the beginning.
  • Treating logging as optional. Console.log is not a logging strategy. Use a proper logger from day one. Future you will be grateful.
  • Deploying without graceful shutdown. Every deployment should drain in-flight requests. If you skip this, you'll lose data or corrupt state. It's not negotiable.
  • Building modules that are too generic. There's a balance between flexibility and clarity. A module that can do anything does nothing well. Keep it focused.
  • Ignoring the testing implications of your module structure. If a module is hard to test in isolation, the module structure is probably wrong. Let tests guide your design.

Key takeaways

  • NestJS's real value is the structure and consistency it enforces. This pays dividends as teams grow and codebases mature.
  • Invest in module boundaries early. Good boundaries make testing, scaling, and team coordination exponentially easier.
  • Production systems differ from demos in logging, health checks, and graceful shutdown. These aren't luxuries; they're requirements.
  • Structured code is learnable code. Consistency beats cleverness every time, especially on growing teams.
  • The patterns that work at scale are the ones that have been tested by multiple teams in production. Learn from real experience, not tutorials.
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