Speeding Up Development with Nx Generators

wgd3

Wallace Daniel

Posted on June 15, 2023

Speeding Up Development with Nx Generators

Speeding Up Development with Nx Generators

In today's fast-paced software development landscape, efficiency and automation are paramount. With the advent of monorepos and the growing complexity of projects, tools like Nx have emerged as powerful allies for managing codebases efficiently. It's no secret that I'm a massive advocate of Nx, and I recently started taking advantage of custom Nx plugins to streamline development efforts. I created @nx-fullstack packages to share some of these, but I wanted to share a generator I made for a personal project I'm working on.

If you want to skip the reading and see the code behind this article, it's available in a gist here

Project Structure

My project aims to provide an API and a web application to track fitness data and log measurements such as body weight, calories consumed, etc. I knew this would not be a small codebase, so I wanted to ensure my repository structure was clean and logically defined. In the past, I've embraced design patterns such as Domain Driven, Hexagonal, and Onion designs. While my current repository structure doesn't fully conform to any of these ideas, I settled on a pattern that seems to work for me:

├── libs
│ ├── server
│ │ ├── core
│ │ │ ├── application-services
│ │ │ ├── domain
│ │ │ └── domain-services
│ │ ├── infrastructure
│ │ ├── shell
│ │ ├── ui-cli
│ │ ├── ui-rest
│ │ ├── util-config
│ │ └── util-testing
│ ├── shared
│ │ ├── domain
Enter fullscreen mode Exit fullscreen mode

Automated Entity Creation

One downside of this structure is that for each "entity" (most of which represent a single table in the database), the following needs to be created:

  • Shared interface defining the core and required properties when creating a new instance
  • An abstract class that acts as an interface to the entity's "repository."
  • A NestJS service that uses a repository to manipulate the entity
  • An actual entity definition for TypeORM
  • An implementation of the abstract repository base class
  • A NestJS controller that exposes CRUD endpoints and uses the associated service to perform operations

The above requirements result in a lot of boilerplate code, and after the first five repetitions of this sequence, I decided to devote development time to a generator instead. In addition to creating the above code, this generator's goal was to update barrel file exports and add imports to NestJS modules.

Generating A Custom Plugin

Nx generators have to be part of an Nx Plugin, which will be an additional library in the repository that doesn't belong to a specific application "domain."

# install the Nx package needed for plugin development
$ yarn add -D @nx/plugin

# generate a new plugin library to which the generator will be added
$ nx generate @nx/plugin:plugin CrudEntityCreator \
--importPath=@myapp/plugins/crud-entity-creator

# generate a generator
$ nx generate @nx/plugin:generator typeorm-entity-creator \
--project=plugins-crud-entity-creator \
--description='Generates all needed files for new TypeORM entites'
Enter fullscreen mode Exit fullscreen mode

There was no intention of making this a publishable library, and as such, you'll see that I've hardcoded almost every file path in the templated files. I want to update this to make it publishable and adaptable for other projects, but we'll save it for a future article.

Creating File Templates

Each bullet point above references a class or interface, and each one requires a dedicated file. The templates are relatively simple, thanks to a standardized naming scheme. For instance, almost every interface in the repository follows the naming pattern I<ModelName>. Templating an interface that belongs to a user looks like this:

import {IBaseModel} from './base.model';
import {IUserModel} from './user.model';

export interface I<%=className%>Relations {
    user?: IUserModel;
}

export interface I<%= className %> extends IBaseModel {
    userId: string;
}

export type ICreate<%= className %> = Omit<I<%=className%>, keyof IBaseModel>;
export type IUpdate<%= className %> = Partial<ICreate<%= className %>>;
Enter fullscreen mode Exit fullscreen mode

All templates are located under the files directory, next to the generator code:

$ tree libs/plugins/crud-entity-creator/src/generators/typeorm-entity/files

libs/plugins/crud-entity-creator/src/generators/typeorm-entity/files
└── libs
    ├── server
    │   ├── core
    │   │   ├── application-services
    │   │   │   └── src
    │   │   │       └── lib
    │   │   │           └── __fileName__.service.ts.template
    │   │   └── domain-services
    │   │       └── src
    │   │           └── lib
    │   │               └── repositories
    │   │                   └── __fileName__.repository.ts.template
    │   ├── infrastructure
    │   │   └── src
    │   │       └── lib
    │   │           ├── entities
    │   │           │   └── __fileName__.orm-entity.ts.template
    │   │           └── repositories
    │   │               └── __fileName__.orm-repository-adapter.ts.template
    │   └── ui-rest
    │       └── src
    │           └── lib
    │               ├── controllers
    │               │   └── __fileName__.controller.ts.template
    │               └── dtos
    │                   └── create-__fileName__.dto.ts.template
    └── shared
        └── domain
            └── src
                └── lib
                    └── models
                        └── __fileName__.model.ts.template

25 directories, 7 files

Enter fullscreen mode Exit fullscreen mode

The className and fileName references come from the generator code, where the names utility from @nx/devkit creates variations of a passed string. The generator at this point is very straightforward:

export async function typeormEntityGenerator(
  tree: Tree,
  options: TypeormEntityGeneratorSchema
) {
  const nameVariants = names(options.entityName);
  generateFiles(tree, path.join(__dirname, 'files'), '', { ...nameVariants });

  updateSourceFiles(tree, updates);
  await formatFiles(tree);
}

export default typeormEntityGenerator;
Enter fullscreen mode Exit fullscreen mode

For every template found under the files directory, render the template and save it to the filesystem.

Updating Exports and Imports

Templating files is easy, but programmatically updating Typescript files is a little more challenging. Files such as shell.module.ts and db.module.ts have array variables that reference our entities and their scaffolding:

const entities: EntityClassOrSchema[] = [
  // all database entities get declared here
];
const typeormModule = TypeOrmModule.forFeature(entities);
Enter fullscreen mode Exit fullscreen mode

libs/server/shell/src/lib/db.module.ts

I needed a way to programmatically say, "In this file, find this specific array and add an element to it." Fortunately, I found an existing library for this: ts-morph. It's a "TypeScript Compiler API wrapper" which offers a way to manipulate Typescript code natively instead of directly accessing/parsing lines in a file.

ts-morph made adding exports extremely easy:

    const updates: FileUpdates = {
    ['libs/shared/domain/src/lib/models/index.ts']: (
      sourceFile: SourceFile
    ) => {
      sourceFile.addExportDeclaration({
        moduleSpecifier: `./${nameVariants.fileName}.model`,
      });
    }
    }
Enter fullscreen mode Exit fullscreen mode

Note:FileUpdates is not part of ts-morph, but is a helper type that relies on SourceFile from ts-morph. See the section at the end of the article for more on this.

Updating arrays, however, proved a bit more troublesome. Here's a snippet of the code needed to update shell.module.ts:

    ['libs/server/shell/src/lib/server-shell.module.ts']: (
      sourceFile: SourceFile
    ) => {
      // make sure our application service is imported
      sourceFile.addImportDeclaration({
        moduleSpecifier: `@myapp/server/core/application-services`,
        namedImports: [`${nameVariants.className}Service`],
      });

      // attempt to find the definition of the applicationServices array
      const serviceArray = sourceFile
        .getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression)
        .find(
          (n) =>
            n.getText().includes('Service') &&
            !n.getText().includes('RepositoryAdapter')
        )
        .asKind(SyntaxKind.ArrayLiteralExpression);

      // add the reference for our application service to the array
      serviceArray.addElement(`${nameVariants.className}Service`);
    }
Enter fullscreen mode Exit fullscreen mode

Using The Generator

After a few hours of learning about ts-morph and testing my generator, it was time to put it to use. Here's the output from the CLI:

> nx g @myapp/plugins/crud-entity-creator:TypeormEntity UserProfile

> NX Generating @myapp/plugins/crud-entity-creator:TypeormEntity

CREATE libs/server/core/application-services/src/lib/user-profile.service.ts
CREATE libs/server/core/domain-services/src/lib/repositories/user-profile.repository.ts
CREATE libs/server/infrastructure/src/lib/entities/user-profile.orm-entity.ts
CREATE libs/server/infrastructure/src/lib/repositories/user-profile.orm-repository-adapter.ts
CREATE libs/server/ui-rest/src/lib/controllers/user-profile.controller.ts
CREATE libs/server/ui-rest/src/lib/dtos/create-user-profile.dto.ts
CREATE libs/shared/domain/src/lib/models/user-profile.model.ts
UPDATE libs/shared/domain/src/lib/models/index.ts
UPDATE libs/server/core/domain-services/src/lib/repositories/index.ts
UPDATE libs/server/core/application-services/src/index.ts
UPDATE libs/server/infrastructure/src/lib/entities/index.ts
UPDATE libs/server/infrastructure/src/lib/repositories/index.ts
UPDATE libs/server/shell/src/lib/db.module.ts
UPDATE libs/server/shell/src/lib/server-shell.module.ts
UPDATE libs/server/ui-rest/src/lib/server-ui-rest.module.ts
Enter fullscreen mode Exit fullscreen mode

And the output from the ORM entity template:

import { Column, Entity } from 'typeorm';

import { IUserProfile } from '@myapp/shared/domain';

import { BaseOrmEntity } from './base.orm-entity';

@Entity('UserProfile')
export class UserProfileOrmEntity
  extends BaseOrmEntity
  implements IUserProfile
{
  @Column({
    type: String,
  })
  userId!: string;
}

Enter fullscreen mode Exit fullscreen mode

Summary

As I reflect on my journey with custom Nx generators in my monorepo project, I am amazed at the automation and efficiency they have brought to my development workflow. While the specifics of my tool may be unique to my project, I encourage you to draw inspiration from this experience and explore the vast possibilities of custom Nx generators in your own software development endeavors. By embracing this powerful tool, you can unlock new productivity levels, streamline repetitive tasks, and pave the way for a more efficient and enjoyable coding experience. Let my journey be your catalyst for innovation and exploration in your projects.

Acknowledgments

💖 💪 🙅 🚩
wgd3
Wallace Daniel

Posted on June 15, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related