DEV Community

Allen Jones
Allen Jones

Posted on Originally published at jonesstack.com

NestJS for Express Developers: A Practical Guide, With Prisma ORM

I've spent years building backends in Express. NestJS looks different on the surface, but here's the thing nobody tells you clearly enough: if you already know Express, you don't need to learn backend development again. You need to learn how NestJS organizes the things you already know how to do

At first, NestJS looks like a lot more ceremony for the same result. A simple route handler turns into a controller class, a service class, decorators everywhere, and a module wiring them together. But once you understand why each piece exists, none of it feels like ceremony anymore; it feels like structure you were probably improvising by hand in every Express project anyway.

This post is the mental model I wish I'd had on day one, mapped directly against Express, using Prisma instead of TypeORM, since that's the ORM I already use in production.

The one-sentence version

Think of NestJS as:

Express, plus TypeScript, plus dependency injection, plus modules, plus decorators, plus enforced architecture.

In Express, you might write:

app.post('/users', async (req, res) => {
  const user = await userService.create(req.body);
  res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

The same idea in NestJS:

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body() body: CreateUserDto) {
    return this.usersService.create(body);
  }
}
Enter fullscreen mode Exit fullscreen mode

More characters, same idea. The rest of this post is about why that extra structure exists and when it earns its keep.

The project structure

Scaffold a new project, and you'll get something like this:

nest-api/
├── src/
│   ├── app.controller.ts
│   ├── app.controller.spec.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   └── main.ts
├── test/
├── package.json
├── tsconfig.json
└── nest-cli.json
Enter fullscreen mode Exit fullscreen mode

Four files matter to start: main.ts, app.module.ts, app.controller.ts, app.service.ts. Everything else is scaffolding you'll grow into.

main.ts is your entry point:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();
Enter fullscreen mode Exit fullscreen mode

If you're coming from Express, this is your const app = express(); app.listen(3000);, just bootstrapped from a root module instead of a bare instance. NestFactory.create(AppModule) is Nest reading your entire application's shape from one root module and building it from there.

app.module.ts is the first genuinely unfamiliar concept:

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Hold off on fully understanding modules yet; they'll make more sense once you've seen a controller and a service in place.

The architecture

                HTTP Request
                     │
                     ▼
              ┌─────────────┐
              │   Module    │
              └──────┬──────┘
                     │
                     ▼
              ┌─────────────┐
              │ Controller  │
              └──────┬──────┘
                     │
                     ▼
              ┌─────────────┐
              │   Service   │
              └──────┬──────┘
                     │
                     ▼
              ┌─────────────┐
              │   Prisma    │
              └──────┬──────┘
                     │
                     ▼
              ┌─────────────┐
              │ PostgreSQL  │
              └─────────────┘
Enter fullscreen mode Exit fullscreen mode

The pieces worth knowing, roughly in the order you'll actually meet them:

  • Module: organizes a feature
  • Controller: handles HTTP requests
  • Service: contains business logic
  • DTO: describes and validates incoming data
  • Guard: controls authentication and authorization
  • Pipe: validates or transforms input
  • Interceptor: wraps or transforms request and response behavior
  • Middleware: the same concept as Express middleware
  • Dependency injection: Nest supplies each class the dependencies it needs, instead of you constructing them by hand

Don't try to hold all of these at once. Controllers and services get you most of the way there.

Your Express knowledge maps directly

This is genuinely the fastest way to internalize it.

Express NestJS
app.get() @Get()
app.post() @Post()
req.params @Param()
req.body @Body()
req.query @Query()
res.json() return
Middleware Middleware
Router Controller
Service modules Providers and services
Manual dependency passing Dependency injection
Validation middleware Pipes
Auth middleware Guards
Error middleware Exception filters
Router organization Modules

Express:

router.get('/users/:id', async (req, res) => {
  const user = await usersService.findById(req.params.id);
  res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

NestJS:

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(':id')
  findById(@Param('id') id: string) {
    return this.usersService.findById(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

The NestJS version is saying, out loud, in the type system: this class handles /users, and this method handles GET /users/:id. That's the whole trade the extra syntax is making: implicit routing conventions become explicit, checkable structure.

Controllers

This is where you'll feel most at home immediately.

@Controller('users')
export class UsersController {
  @Get()
  findAll() {
    return [];
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return { id };
  }

  @Post()
  create(@Body() body: any) {
    return body;
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return { id };
  }
}
Enter fullscreen mode Exit fullscreen mode

That's the equivalent of router.get('/users', ...), router.get('/users/:id', ...), router.post('/users', ...), and router.delete('/users/:id', ...). Nothing conceptually new here.

Services, where Nest starts diverging

In Express you might write:

const userService = {
  async findAll() {
    return db.user.findMany();
  },
  async findOne(id: string) {
    return db.user.findUnique({ where: { id } });
  },
};
Enter fullscreen mode Exit fullscreen mode

In NestJS:

@Injectable()
export class UsersService {
  async findAll() {
    return [];
  }

  async findOne(id: string) {
    return { id };
  }
}
Enter fullscreen mode Exit fullscreen mode

@Injectable() is the important part. It tells Nest that this class can be managed by Nest's dependency injection system. Your controller then does this:

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
}
Enter fullscreen mode Exit fullscreen mode

Notice what's missing: const usersService = new UsersService() never appears anywhere. Nest creates it and hands it to the controller. That's dependency injection, and it's worth its own section, because it's the concept that actually changes how you think about structuring an app.

Modules

A module groups related functionality into a feature boundary.

src/
├── users/
│   ├── users.controller.ts
│   ├── users.service.ts
│   ├── users.module.ts
│   └── dto/
├── auth/
│   ├── auth.controller.ts
│   ├── auth.service.ts
│   └── auth.module.ts
└── app.module.ts
Enter fullscreen mode Exit fullscreen mode
@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}
Enter fullscreen mode Exit fullscreen mode
@Module({
  imports: [UsersModule],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Thinking about this against Formgrid,s actual feature set is a useful exercise, even without rebuilding it in Nest: auth, users, forms, leads, integrations, webhooks, billing, and notifications would each map cleanly to its own module. That boundary is exactly what makes Nest attractive once a SaaS codebase grows past the size where "everything imports everything" stops being manageable in a flat Express app.

DTOs and validation

Instead of the shapeless @Body() body: any, you define what's actually expected:

export class CreateUserDto {
  name: string;
  email: string;
  password: string;
}
Enter fullscreen mode Exit fullscreen mode

Combined with class-validator, this becomes real, enforced validation instead of a type hint:

import { IsEmail, IsString, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsString()
  name: string;

  @IsEmail()
  email: string;

  @MinLength(8)
  password: string;
}
Enter fullscreen mode Exit fullscreen mode

Nest validates incoming requests against this automatically, which replaces a fair amount of hand-rolled validation middleware you'd otherwise be writing and maintaining yourself in Express.

Pipes

Pipes transform and validate input before it reaches your handler.

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  return this.usersService.findOne(id);
}
Enter fullscreen mode Exit fullscreen mode

A URL parameter arrives as the string "123". The pipe converts it to the number 123 before your method ever sees it, and throws an appropriate exception automatically if the value isn't valid in the first place.

Exceptions

Express:

return res.status(404).json({ message: 'User not found' });
Enter fullscreen mode Exit fullscreen mode

NestJS:

throw new NotFoundException('User not found');
Enter fullscreen mode Exit fullscreen mode
async findOne(id: string) {
  const user = await this.prisma.user.findUnique({ where: { id } });
  if (!user) {
    throw new NotFoundException('User not found');
  }
  return user;
}
Enter fullscreen mode Exit fullscreen mode

Nest turns that thrown exception into the correct HTTP response for you. You also get BadRequestException, UnauthorizedException, ForbiddenException, ConflictException, and InternalServerErrorException out of the box, all consistent, all readable at the point they're thrown instead of buried in a response call.

Guards

You'll meet these implementing authentication:

@UseGuards(AuthGuard)
@Get('profile')
getProfile() {
  return this.usersService.getProfile();
}
Enter fullscreen mode Exit fullscreen mode

A request either fails the guard and never reaches the controller, or passes it and proceeds normally. That's generally cleaner than scattering an authentication check inside every individual Express route handler.

Interceptors

These can wait until you actually need them. They wrap around a controller's execution, useful for logging, response transformation, timing, or caching:

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    console.log('Request started');
    return next.handle();
  }
}
Enter fullscreen mode Exit fullscreen mode

Middleware

Good news here: you already know this one.

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    console.log(req.method, req.url);
    next();
  }
}
Enter fullscreen mode Exit fullscreen mode

This transfers from Express almost without translation.

Wiring up Prisma instead of TypeORM

Most NestJS material defaults to TypeORM, but if you're already running Prisma, there's no reason to switch ORMs just to learn a framework. A thin PrismaService is all it takes to bring Prisma into Nest's dependency injection system:

@Injectable()
export class PrismaService extends PrismaClient {
  async onModuleInit() {
    await this.$connect();
  }
}
Enter fullscreen mode Exit fullscreen mode

Then your service depends on it like anything else:

@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  findOne(id: string) {
    return this.prisma.user.findUnique({ where: { id } });
  }
}
Enter fullscreen mode Exit fullscreen mode

And the module wires both together:

@Module({
  controllers: [UsersController],
  providers: [UsersService, PrismaService],
})
export class UsersModule {}
Enter fullscreen mode Exit fullscreen mode

Nest handles the application's structure and dependency graph. Prisma handles the database layer. Neither one needs to know much about the other.

Understanding dependency injection: why @Injectable() actually exists

This is the concept that took me the longest to actually feel, rather than just memorize, so it's worth its own deep section.

Here's the honest question: Express works completely fine without anything like @Injectable(). So why does Nest need it?

Express: you are the dependency injection system

Suppose you have this:

class EmailService {
  sendEmail() {
    console.log('Sending email...');
  }
}

class UsersService {
  constructor(private emailService: EmailService) {}

  createUser() {
    this.emailService.sendEmail();
  }
}

class UsersController {
  constructor(private usersService: UsersService) {}

  createUser() {
    return this.usersService.createUser();
  }
}
Enter fullscreen mode Exit fullscreen mode

In Express, you wire this by hand:

const emailService = new EmailService();
const usersService = new UsersService(emailService);
const usersController = new UsersController(usersService);

app.post('/users', (req, res) => {
  usersController.createUser();
  res.send('Created');
});
Enter fullscreen mode Exit fullscreen mode

No @Injectable() anywhere, because none is needed. You are the dependency injection system: you decide what gets created, in what order, and what gets passed into what. TypeScript's constructors already support this pattern natively; nothing about it is Nest-specific.

What NestJS actually adds

NestJS's pitch is essentially: why keep wiring this by hand? So instead:

@Injectable()
export class EmailService {
  sendEmail() {
    console.log('Sending email...');
  }
}

@Injectable()
export class UsersService {
  constructor(private emailService: EmailService) {}

  createUser() {
    this.emailService.sendEmail();
  }
}

@Controller('users')
export class UsersController {
  constructor(private usersService: UsersService) {}

  @Post()
  createUser() {
    return this.usersService.createUser();
  }
}
Enter fullscreen mode Exit fullscreen mode
@Module({
  controllers: [UsersController],
  providers: [UsersService, EmailService],
})
export class UsersModule {}
Enter fullscreen mode Exit fullscreen mode

Now nothing gets manually constructed. Nest resolves the graph itself: the controller needs UsersService, so Nest finds or creates one; that service needs EmailService, so Nest finds or creates that too. @Injectable() is the marker that tells Nest's container "this class is allowed to participate in that resolution," nothing more mystical than that. It isn't a requirement of TypeScript constructors; it's metadata Nest specifically needs to do its job.

Why this matters more as the app grows

At three classes, manual wiring in Express is completely fine, arguably even clearer, since you can see the whole graph in one place. The value of letting Nest do it shows up once the graph gets genuinely large.

Picture something the shape of Formgrid's dependency tree, hypothetically, if it were structured this way:

UsersController
      │
      ├── UsersService
      │        │
      │        ├── UsersRepository → DatabaseService
      │        ├── EmailService → ResendService
      │        └── PaymentService → PaddleService
Enter fullscreen mode Exit fullscreen mode

Wired by hand in Express, that's:

const database = new DatabaseService();
const repository = new UsersRepository(database);

const resend = new ResendService();
const email = new EmailService(resend);

const paddle = new PaddleService();
const payment = new PaymentService(paddle);

const users = new UsersService(repository, email, payment);
const controller = new UsersController(users);
Enter fullscreen mode Exit fullscreen mode

That's manageable once. It gets genuinely unpleasant at real scale, a hundred services deep, where any one of them needing an additional dependency means updating every place that constructs it, in the right order, every time.

In Nest, you just declare what each class needs:

constructor(
  private repository: UsersRepository,
  private email: EmailService,
  private payment: PaymentService,
) {}
Enter fullscreen mode Exit fullscreen mode

And the container resolves and constructs the entire tree for you, in the correct order, every time.

The actual insight

Constructors aren't the difference between Express and NestJS. Both let you declare constructor(private emailService: EmailService) and both will happily accept it; that part is plain TypeScript, nothing framework-specific about it either way.

The difference is who is responsible for calling those constructors and supplying what they need. In Express, that's you, by hand, every time. In NestJS, that's the container, once you've told it, via @Injectable() and a module's providers array, which classes it's allowed to manage.

That's the whole idea @Injectable() exists to express. Everything else in Nest, controllers, modules, guards, pipes, is really just structure built on top of that one core mechanism.

Where this leaves you

If you already know Express well, none of this is new backend knowledge; it's a new way of organizing backend knowledge you already have. Controllers are routers with better names. Services are the business logic you were already separating, formalized. Modules are the feature folders you were probably already creating by convention, now enforced by the framework instead of by team discipline.

The place NestJS earns its extra ceremony is scale and team size; a large, long-lived API with many contributors benefits from a framework insisting on structure. A small API, a quick internal tool, or a solo project where you already have the discipline to keep things organized may not need it at all. Express isn't wrong for those; it's just a different tradeoff.

If you've made the same jump from Express to NestJS, or gone the other way, I'd like to hear what actually clicked for you. Reach me at allen@formgrid.dev.


I'm Allen, a full-stack TypeScript engineer and the founder of Formgrid and SheetRocket. I write about real production engineering from products that people actually pay for. More at jonesstack.com.

Top comments (0)