·7 min read

NestJS for Solo Devs: Why I Chose This Stack

After trying Express, Fastify and Hono, I chose NestJS for the Coachy API. Opinionated structure, native DI and ecosystem: my concrete reasons.

TL;DR

NestJS enforces a structure (modules, services, controllers) that seems like overkill at first. After 6 months on the Coachy API, it's precisely that structure that saves me time: I always know where to look, and adding a feature doesn't break anything.

The Coachy API started on Express.js. Simple, flexible, everyone knows it. Three months later: spaghetti code where I spent more time searching for where a feature was implemented than actually coding.

Fastify for performance, Hono for its lightweight nature — I tried them. NestJS won out, despite its reputation as "too heavy a framework for small projects."

Spoiler: for a solo dev building as a side project, NestJS's opinionated architecture is a real game changer.

Why I Abandoned Express

Express means total freedom. And that's exactly the problem. Every architectural decision falls on you: where to put validation? How to organize middleware? How to handle DI? Where to store config?

// Express - Total freedom = total anarchy
app.post('/api/workouts', authenticateUser, validateWorkout, (req, res) => {
  const workout = req.body;

  // Where does the business logic go?
  // How do you test this route?
  // How do you handle errors cleanly?

  workoutService.create(workout)
    .then(result => res.json(result))
    .catch(err => res.status(500).json({error: err.message}));
});

// Separate file for workoutService... or not
// Validation... somewhere
// Config... in process.env directly
// Tests... we'll see later

After a few months, I had 15 different ways of handling errors, 3 different validation patterns depending on the mood of the day, and zero consistency in the architecture. Every new feature was reinventing the wheel.

NestJS: Architecture That Scales with You

NestJS enforces a structure. At first, it's frustrating. After 6 months, you realize that's your freedom.

// NestJS - Clear and consistent structure
@Controller('workouts')
export class WorkoutsController {
  constructor(private workoutsService: WorkoutsService) {}

  @Post()
  @UseGuards(AuthGuard)
  @UsePipes(ValidationPipe)
  async create(@Body() createWorkoutDto: CreateWorkoutDto): Promise<WorkoutResponseDto> {
    return this.workoutsService.create(createWorkoutDto);
  }
}

@Injectable()
export class WorkoutsService {
  constructor(
    private workoutsRepository: WorkoutsRepository,
    private eventBus: EventBus
  ) {}

  async create(dto: CreateWorkoutDto): Promise<WorkoutResponseDto> {
    const workout = await this.workoutsRepository.create(dto);

    // Event Sourcing integration
    await this.eventBus.publish(new WorkoutCreatedEvent(workout.id));

    return WorkoutMapper.toDto(workout);
  }
}

Everything has its place. DTOs for validation, services for logic, repositories for data, guards for auth. You never have to ask yourself "where do I put this code?"

Pros
  • Opinionated architecture: No choice paralysis, patterns are predefined
  • Native Dependency Injection: Testability and modularity out-of-the-box
  • Rich ecosystem: Passport, TypeORM, Swagger, Redis — everything integrates easily
  • TypeScript first: Type safety across the entire stack
  • Decorator pattern: Expressive and readable code
  • Powerful CLI: Automatic boilerplate generation
Cons
  • Learning curve: Angular/Spring concepts to absorb
  • Initial overhead: Heavier than Express for simple APIs
  • Verbosity: More code for basic features
  • Performance: Slightly slower than pure Express (negligible in practice)

Concrete Comparison: Express vs NestJS vs Alternatives

I benchmarked all 4 frameworks on the same API (authentication, CRUD workouts, WebSocket for real-time):

Express.js

  • Setup: 2h for the basic boilerplate
  • Lines of code: 850 for the complete API
  • Tests: Hard to test properly without refactoring
  • Maintenance: Organizational chaos after 3 months

Fastify

  • Setup: 3h (plugins to configure manually)
  • Performance: +40% vs Express in my benchmarks
  • Problem: Smaller ecosystem, occasionally buggy plugins

Hono

  • Setup: 1h (truly minimalist)
  • Performance: Excellent (+60% vs Express)
  • Problem: Too young, lacks established patterns for complex apps

NestJS

  • Setup: 4h (learning the concepts)
  • Lines of code: 1200 (more verbose but more maintainable)
  • Tests: Native integration, easy mocking
  • Maintenance: Solid structure that holds up over time

For throwaway APIs or ultra-simple microservices, Express or Hono wins. For projects that will evolve over 2+ years, NestJS pays for itself by the 3rd month.

The NestJS Features That Won Me Over

1. Automatic Dependency Injection

// No more manual import management
@Injectable()
export class WorkoutsService {
  constructor(
    private workoutsRepository: WorkoutsRepository,
    private cacheService: CacheService,
    private eventBus: EventBus,
    private configService: ConfigService
  ) {
    // NestJS automatically resolves all dependencies
    // Native testability with mocks
  }
}

// In tests, super easy to mock
const mockRepository = {
  create: jest.fn(),
  findById: jest.fn()
};

Test.createTestingModule({
  providers: [
    WorkoutsService,
    { provide: WorkoutsRepository, useValue: mockRepository }
  ]
});

2. Declarative Validation with class-validator

export class CreateWorkoutDto {
  @IsString()
  @Length(3, 50)
  name: string;

  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => ExerciseDto)
  exercises: ExerciseDto[];

  @IsOptional()
  @IsDateString()
  scheduledAt?: string;
}

// Automatic validation on all routes
// Normalized and i18n-ready errors

3. Native WebSocket Integration

For Coachy, I need real-time sync during workout sessions:

@WebSocketGateway({ cors: true })
export class WorkoutGateway {
  @WebSocketServer() server: Server;

  @SubscribeMessage('joinWorkout')
  handleJoinWorkout(client: Socket, workoutId: string) {
    client.join(`workout-${workoutId}`);
  }

  @SubscribeMessage('exerciseCompleted')
  async handleExerciseCompleted(
    client: Socket,
    @MessageBody() data: ExerciseCompletedDto
  ) {
    await this.workoutsService.completeExercise(data);

    // Broadcast to all clients in this session
    this.server.to(`workout-${data.workoutId}`).emit('exerciseUpdated', data);
  }
}

4. Built-in Swagger

API documentation automatically generated from DTOs and decorators:

@ApiTags('workouts')
@ApiBearerAuth()
@Controller('workouts')
export class WorkoutsController {
  @ApiOperation({ summary: 'Create a new workout' })
  @ApiResponse({ status: 201, type: WorkoutResponseDto })
  @Post()
  async create(@Body() dto: CreateWorkoutDto) {
    // Swagger automatically generates the docs
    // With schemas, examples and error codes
  }
}

Performance: The "Too Heavy" Myth

"NestJS is heavy" comes up a lot. In practice, on the Coachy API in development:

  • Startup time: 2.3s (vs 0.8s for Express)
  • Throughput: 8500 req/s (vs 12000 req/s for Express)
  • Memory: +15MB baseline

For a typical backend API, these differences are invisible. The bottleneck will be your database, not the framework.

And in terms of DX (Developer Experience), NestJS wins hands down:

# Automatic boilerplate generation
nest g controller workouts
nest g service workouts
nest g module workouts

# Integrated tests
npm run test:watch
npm run test:e2e

# Build and deploy
npm run build
npm run start:prod

Ecosystem and Integrations

What kept me on NestJS is its mature ecosystem:

Database: TypeORM, Prisma, Mongoose — native integration

Auth: Passport with 500+ strategies

Caching: Redis, Memcached — simple decorators

Validation: class-validator — declarative and powerful

Documentation: Swagger/OpenAPI — automatic

Testing: Jest — zero configuration

Monitoring: Prometheus, Grafana — out-of-the-box metrics

Every integration is designed for NestJS. No hacks, no custom wrappers. It just works.

The Over-Engineering Trap

NestJS can push you toward over-engineering. I almost fell into that trap:

// Trap: too many layers for nothing
@Controller('users')
export class UsersController {
  constructor(private usersApplicationService: UsersApplicationService) {}
}

@Injectable()
export class UsersApplicationService {
  constructor(private usersDomainService: UsersDomainService) {}
}

@Injectable()
export class UsersDomainService {
  constructor(private usersRepository: UsersRepository) {}
}

// For a simple CRUD, this is overkill

My rule now: start simple (Controller + Service + Repository), add complexity only when necessary. NestJS supports both approaches.

NestJS shines on medium to large projects. For ultra-simple microservices or one-off scripts, Express or Fastify remain more appropriate.

Lessons from the Field

The Coachy API has been built with NestJS since the start of the project. Here's the assessment:

What works:

  • Architecture that held up through the addition of 15 new endpoints
  • Tests that are easy to write and maintain
  • Quick onboarding of contributors (clear architecture)
  • More than sufficient performance (P95 < 200ms)

What surprised me:

  • Very effective hot reload in dev
  • Excellent debugging with decorators
  • Active and responsive community on issues

What I miss:

  • Slower startup time in dev (but acceptable)
  • Larger bundle size (offset by the features)

In the end, NestJS boosted my velocity over the medium term. The initial 2-week learning period paid for itself by the 2nd month of development.

For a solo developer working on medium to long-term projects, NestJS is an excellent choice. The opinionated architecture and ecosystem more than offset the initial overhead. If you're building to last, go for it.

Further Reading


Sources:

NestJSNode.jsAPIbackendExpressTypeScript