·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