NestJS for SpringBoot developers

NestJS for SpringBoot developers

Never before it was easier for a SpringBoot developer to switch to another platform for implementing a RESTful backend. With NestJS you can leverage your experience and use TypeScript and node.js for fun and profit. Using code examples this post shows you how similar SpringBoot and NestJS really are. Hopefully after reading you will be inspired to try it yourself and have some fun.

If you are a full stack developer having some experience with TypeScript and SpringBoot you will feel at home pretty quickly. But beware: NestJS !== SpringBoot. There are some differences you should be aware of.

Please note: this is not a tutorial. You can find many tutorials on the web or have a look at NestJS’ great documentation.

What is NestJS?

According to its website NestJS is “A progressive Node.js framework for building efficient, reliable and scalable server-side applications.” With NestJS you can implement your backend with TypeScript based on node.js.

NestJS allows you to write your RESTful backend using the classical controller/service/repository layering which you know from SpringBoot. It provides an opinionated view on how to structure your application. Controllers and services (called providers in NestJS) are wired with dependency injection. Modules can be used to group domain related implementation.

Code comparison: NestJS vs. SpringBoot

Let’s have a look at some code! In this section code snippets are placed alongside each other to give you an idea how the NestJS code looks like in comparison to SpringBoot. The application manages locations (identified by address, longitude and latitude) in a CRUD style.

Controller

NestJS makes heavy use of annotations. The following code snippets show you how to annotate controllers and its functions to get a basic CRUD RESTful service.

Controllers are annotated on the class level setting the path under which your routes are available.

// locations.controller.ts

@Controller('api/v1/locations')
export class LocationsController {}
// LocationController.java

@RestController
@RequestMapping("api/v1/locations")
public class LocationController {}

Each function can be annotated for defining the HTTP method.

// locations.controller.ts

@Get()
async getLocations(): Promise<Location[]> {}

@Post()
async addLocation(@Body() addLocationDto: AddLocationDto): Promise<Location> {}

@Delete(':id')
async deleteLocationById(@Param('id') id: string): Promise<void> {}
// LocationController.java

@GetMapping()
public List<LocationDto> getLocations() {}

@PostMapping()
public LocationDto addLocation(@RequestBody AddLocationDto addLocationDto) {}

@DeleteMapping()
public void deleteLocationById(@PathVariable String id) {}

Path variables can be easily retrieved via function arguments. Also JSON bodies from POST or PUT requests are deserialized automatically into DTOs when you annotate your argument correspondingly.

// locations.controller.ts

@Get(':id')
async getLocationById(@Param('id') id: string): Promise<Location> { }

@Post()
async addLocation(@Body() addLocationDto: AddLocationDto): Promise<Location> {}
// LocationController.java

@GetMapping(value = "/{id}")
public LocationDto getLocationById(@PathVariable String id) { }

@PostMapping()
public LocationDto addLocation(@RequestBody AddLocationDto addLocationDto) {}

DTOs

Speaking of DTOs – these are declared as TypeScript classes. This allows us to declare validations for each property using annotations from a 3rd party library which is integrated seemlessly into NestJS (class-validator).

// add-location.dto.ts

export class AddLocationDto {
  @IsNotEmpty({ message: 'Name must always be defined.' })
  @Length(1, 65536, { message: 'The name of the location must not be empty.' })
  readonly name: string

  @IsNotEmpty({ message: 'Address must always be defined.' })
  readonly address: string

  @IsNotEmpty({ message: 'Latitude must always be defined.' })
  @IsNumber()
  @Min(-90, { message: 'Latitude must be between -90 an 90.' })
  @Max(90, { message: 'Latitude must be between -90 an 90.' })
  readonly latitude: number

  @IsNotEmpty({ message: 'Longitude must always be defined.' })
  @IsNumber()
  @Min(-180, { message: 'Longitude must be between -180 and 180.' })
  @Max(180, { message: 'Longitude must be between -180 and 180.' })
  readonly longitude: number
}
// AddLocationDto.java

@Data
@NoArgsConstructor
public class AddLocationDto {

    @Size(min = 1, message = "The name of the location must not be empty.")
    @NotNull(message = "Name must always be defined.")
    private String name;

    @NotNull(message = "Address must always be defined.")
    private String address;

    @DecimalMin(value = "-90", message = "Latitude must be between -90 an 90.")
    @DecimalMax(value = "90", message = "Latitude must be between -90 an 90.")
    @NotNull(message = "Latitude must always be defined.")
    private BigDecimal latitude;

    @DecimalMin(value = "-180", message = "Longitude must be between -180 and 180.")
    @DecimalMax(value = "180", message = "Longitude must be between -180 and 180.")
    @NotNull(message = "Longitude must always be defined.")
    private BigDecimal longitude;
}

NestJS’ partials allow for nice shortcuts. Our DTO for updating basically looks the same as for creation but with an additional id property and all other properties being optional.

// patch-location.dto.ts

export class PatchLocationDto extends PartialType(AddLocationDto) {
  @IsNotEmpty({ message: 'Id of the location must always be provided.' })
  @IsUUID('all', { message: 'Id of the location must be a valid UUID.' })
  readonly id: string
}

Service

Controllers delegate to services for executing all your business logic. NestJS leverages dependency injection to inject your service into the controller.

// locations.controller.ts

export class LocationsController {
  constructor(private readonly locationsService: LocationsService) {}
}
// LocationController.java

public class LocationController {

    private final LocationService locationService;

    @Autowired
    public LocationController(LocationService locationService) {
        this.locationService = locationService;
    }
}

Services usually contain the business logic code. In our CRUD application they use repositories to store and retreive business entities.

// locations.service.ts

async getLocations(): Promise<Location[]> {
  return this.locationRepository.find()
}
// LocationService.java

public List<LocationDto> getLocations() {
    return map(locationRepository.findAll());
}

Repository

In our example we use a relational database to store the business entities. NestJS does not implement its own ORM but rather delegates to well known 3rd party libraries. There are tight integrations with TypeORM and Sequelize but it is also easy to use other libraries like Prisma, MikroORM etc.. TypeORM integration is described in the NestJS docs. Therefore we use it in our example.

With TypeORM entites are coupled to database tables and columns using annotations – like in JPA.

// location.entity.ts

@Entity('locations')
export class Location {
  @PrimaryGeneratedColumn('uuid')
  id: string

  @Column({ unique: true })
  name: string

  @Column()
  address: string

  @Column()
  latitude: number

  @Column()
  longitude: number
}
// LocationEntity.java

@Entity
@Table(name = "locations")
public class LocationEntity {

    @Id
    @Column()
    private UUID id;

    @Column(unique = true)
    private String name;

    @Column()
    private String address;

    @Column()
    private BigDecimal latitude;

    @Column()
    private BigDecimal longitude;
}

As with SpringBoot repositories are injected into your service.

// locations.service.ts

@Injectable()
export class LocationsService {
  constructor(
    @InjectRepository(Location)
    private locationRepository: Repository<Location>,
  ) {}
}
// LocationService.java

@Service
public class LocationService {

    private final LocationRepository locationRepository;

    @Autowired
    public LocationApiService(LocationRepository locationRepository) {
        this.locationRepository = locationRepository;
    }
}

Repositories provide a number of basic functions for saving, deleting and finding entities.

// locations.service.ts

this.locationRepository.find()

this.locationRepository.save(location)

this.locationRepository.delete(id)

this.locationRepository.findOneBy({ id })
// LocationService.java

this.repository.findAll()

this.repository.save(location)

this.repository.deleteById(id)

this.repository.findById(id)

Whereas with Spring-Data simple finders can be declared using method names with specific naming conventions, in TypeORM you provide query options to the find function.

// locations.service.ts

this.locationRepository.findOneBy({
  name: location.name,
})
// LocationService.java

repository.findLocationEntityByName(location.getName())

Error handlers

In SpringBoot you can define exception handlers if you want to return a proper response to your clients should any business related error occur. NestJS allows the same by defining filters. Also you can override NestJS’ default responses e.g. for unexpected exceptions.

// locations.controller.ts
@UseFilters(
  new NameNotUniqueLocationExceptionFilter(),
)
@Controller('api/v1/locations')
export class LocationsController { }


// name-not-unique-location-exception.filter.ts
@Catch(NameNotUniqueLocationException)
export class NameNotUniqueLocationExceptionFilter implements ExceptionFilter {
  public catch(exception: NameNotUniqueLocationException, host: ArgumentsHost) {
    return createErrorResponse(422, exception, host)
  }
}
// LocationController.java

@ExceptionHandler(NameNotUniqueLocationException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ApiErrorDto handleNameNotUniqueLocationException(
        NameNotUniqueLocationException nameNotUniqueLocationException) {
    return new ApiErrorDto(nameNotUniqueLocationException.getMessage());
}

Unit tests

Unit testing your services and controllers is a breeze with Jest. Mocks and spies are setup easily.

// locations.service.spec.ts

it('should get a single location by id', () => {
  // given: a location in the repo
  repository.findOneBy = jest.fn().mockResolvedValue(LOCATION)

  // when: getting one location by id
  const foundLocationResponse = service.getLocationById(LOCATION_ID)

  // then: this location is returned (queried by id in the repo)
  expect(foundLocationResponse).resolves.toEqual(LOCATION)
  expect(repository.findOneBy).toHaveBeenCalledWith({ id: LOCATION_ID })
})
// LocationControllerTest.java

@Test
void givenRepositoryWithLocation_whenGettingLocationById_thenReturnsDto() {
    // given
    LocationController locationController = new LocationController(
        new LocationService(new ModelMapper(), locationRepository));
    Location expectedLocation = randomLocation();
    Mockito.when(locationRepository.findById(expectedLocation.getId()))
            .thenReturn(expectedLocation);

    // when
    LocationDto locationDto = locationController.getLocationById(
        expectedLocation.getId().toString());

    // then
    Truth.assertThat(locationDto.getId()).isEqualTo(
        expectedLocation.getId().toString());
    Truth.assertThat(locationDto.getName()).isEqualTo(
        expectedLocation.getName());
    Truth.assertThat(locationDto.getAddress()).isEqualTo(
        expectedLocation.getAddress());
    Truth.assertThat(locationDto.getLatitude()).isEqualTo(
        expectedLocation.getLatitude());
    Truth.assertThat(locationDto.getLongitude()).isEqualTo(
        expectedLocation.getLongitude());
}

What else?

For logging you can choose from a variety of libraries. We have used Pino and Winston. Structured logging with JSON, different transports like Loki, formatting and many things more can be configured.

Authentication and authorization are well documented in the NestJS docs. In our example we have used Keycloak and nest-keycloak-connect. It is really easy to setup in NestJS. Aside from configuring the Keycloak module you only need to include guards for authentication and authorization. Annotations on your controller functions specify which roles are allowed to call which route.

// app.module.ts

providers: [
{
  provide: APP_GUARD,
  useClass: AuthGuard,
},
{
  provide: APP_GUARD,
  useClass: RoleGuard,
},
],

NestJS provides an OpenAPI module. Similar to SpringBoot you can annotate your controller to generate an OpenAPI spec and render the swagger UI.

// locations.controller.ts

@Post()
@ApiOperation({
  description: 'This operation adds a new location.',
  summary: 'Add new location',
})
@ApiResponse({
  status: 201,
  description: 'Successfully added a new location',
  type: Location,
})
@ApiUnprocessableEntityResponse({
  description: 'Name not unique',
  type: ApiErrorDto,
})
async addLocation(@Body() addLocationDto: AddLocationDto): Promise<Location> {}
// LocationsController.java

@Operation(
        summary = "Add new location",
        description = "This operation adds a new location.")
@ApiResponse(
        responseCode = "201",
        description = "Successfully added a new location")
@PostMapping()
@ResponseStatus(HttpStatus.CREATED)
public LocationDto addLocation(
    @Valid @RequestBody AddLocationDto addLocationDto) {}

Readiness/liveness probes are catered for with Terminus . There are a number of ways to add Prometheus metrics to your NestJS application (e.g. nestjs-prometheus). Or you can try the OpenTelemtry implementation which also lets you add tracing via Jaeger for example with nestjs-otel.

Details about database schema updates can be found in the next section.

Notable differences

Remember: NestJS !== SpringBoot. For one SpringBoot is much older than NestJS and boasts a whole lot more features. This is true also e.g. for TypeORM compared with JPA/Hibernate. Let’s look at a few other things you will notice.

Configuration

When configuring e.g. the database in SpringBoot it usually suffices to include the right starter dependency and to place the needed values in your configuration yaml or properties File. With NestJS you need to configure your modules explicitely. Here’s an example how this looks like for the TypeORM configuration.

// app.module.ts

TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  useFactory: (configService: ConfigService) => ({
    type: 'postgres',
    host: configService.get('DATABASE_HOST'),
    port: +configService.get('DATABASE_PORT'),
    username: configService.get('DATABASE_USER'),
    password: configService.get('DATABASE_PASSWORD'),
    database: configService.get('DATABASE_DATABASE'),
    entities: [Location],
    synchronize: false,
    logging: true,
  }),
  inject: [ConfigService],
}),

Logging

As described above it is easy to include logging into your NestJS application. The more difficult part is to get all your logs – including those from your libraries – logged in the same way. Say you want to have JSON logging transported via Loki. By default the logs from the TypeORM library will not be part of that. You have to implement your own custom logger for TypeORM (see https://github.com/typeorm/typeorm/blob/master/docs/logging.md#using-custom-logger). This is pretty straight forward by copy/pasting some code from the examples. But – thanks to slf4j – in the SpringBoot world you get this for free.

Database schema updates

TypeORM provides its own way of migrating the database schema. Being accustomed to Flyway et al this might look a bit akward but comes with its benefits too. TypeORM expects TypeScript code for your migration. In the code you can either execute the migration programmatically or execute plain SQL. We opted for the latter which results in two files for each migration – the TypeScript plus the SQL file. Also versioning is based on timestamps rather than version numbers like in Flyway.

// create-table-locations_1657894333611.ts

export class CreateTableLocations_1657894333611 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    const query = fs
      .readFileSync('./src/db-migrations/v001_createTableLocations.sql')
      .toString()
    await queryRunner.query(query)
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP TABLE IF EXISTS locations`)
  }
}

Dependency injection

We saw that injecting a dependency into a service or controller is easy. But in order for this to work you must configure all injectable classes in your module config. Otherwise NestJs will not know these – there is no “classpath scanning” like in SpringBoot.

// app.module.ts

@Module({
  imports: [TypeOrmModule.forFeature([Location])],
  controllers: [LocationsController],
  providers: [LocationsService],
})
export class LocationsModule {}

Also injecting services soley based on the interface they implement (for some devs the default way of handling dependencies in SpringBoot) is not possible out of the box. In NestJS you need to provide factory code which constructs your service by using a concrete dependency. This dependency of course needs to be listed explicitely in the module context.

// locations.service.ts
export class LocationsService {
  // LocationRepository is an interface
  constructor(private locationRepository: LocationRepository) {}


// app.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([LocationEntity])],
  controllers: [LocationsController],
  providers: [
    DatabaseLocationRepository,  // this class is implementing LocationRepository
    {
      inject: [DatabaseLocationRepository],
      provide: LocationsService,
      useFactory: (databaseLocationRepository: DatabaseLocationRepository) =>
        new LocationsService(databaseLocationRepository),
    },
  ],
})

Some other differences

If you use declarative transactions in SpringBoot heavily – this is not possible in NestJS. You can mark your transaction boundaries programmatically.

You can use all the TypeScript goodies and in turn are not forced to write stuff like getters/setters or use code generation libs like Lombok.

Of course you need to respect the underlying runtime/platform. For example JavaScript is single threaded – you should not use NestJS to do heavy computations.

Summary

You now should have a good idea on how to write a RESTful backend with NestJS. You cannot wait to get your fingers dirty and try it for yourself? Head over to the NestJS docs, fire up the CLI to scaffold your first application and get going!. Thanks to the amazing team behind NestJS this is really easy. They also provide a lot of samples for your inspiration. I hope you will have as much fun as I had :-).

What do you think? I would like to hear about it. Please share your experiences in the comments.

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Nach oben scrollen
Cookie Consent Banner von Real Cookie Banner