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.
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 anarchyapp.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?"
- 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
- 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 generationnest 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.
Further Reading
- Event Sourcing in a mobile app: lessons learned
- One-person studio: my dev setup
- Build vs Buy for startups: when to develop, when to purchase
Sources: