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
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'
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 %>>;
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
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;
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);
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`,
});
}
}
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`);
}
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
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;
}
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
- NX - How To Write a Generator
- Nx documentation on Creating Plugins
- An excellent example repository (and documentation!) for Domain Driven Design: sairyss/domain-driven-hexagon
Top comments (0)