Tim Whitney | February 9, 2017
TypeScript and Node.js enterprise patterns
How far can we go with TypeScript 2.x on Node.js.?
TypeScript has gotten a lot of traction on the frontend recently with the final release of Angular 2.0. I'm primarily a backend architect and have focused on Java and Groovy for the last several years. However, the promise of a statically-typed language running on Node.js caught my attention. I created this proof of concept to see how far I could push backend enterprise architectural patterns with TypeScript 2.x on Node.js.
Large and complex applications with long maintenance lifetimes can greatly benefit from TypeScript, and more generally from static type checking. Some general benefits:
- Safe refactoring with IDE support
- Compile-time type checking
- Generics support providing more reusability
- Easier onboarding of new engineers through self-documenting code
- Lower long-term maintenance cost
- IDE support and much more reliable autocomplete and refactoring tools
- N-tier layer segregation (see my post on the Lego Monolith for more information on layer partitioning)
- Encapsulation and decoupled data and data sources
- TypeScript native libraries when possible
- Immutability where possible
The application is split into controller, service, and repository layers. The service and controller layers use the same data classes, which I will refer to as domain classes. The repository layer consumes and produces DTOs. This allows the repository to consume and produce data that will not be exposed past the service layer to the controller or the client.
InversifyJS — A TypeScript Inversion of Control/Dependency Injection container. This library reminds me of Google Guice. IoC promotes SOLID principles, loose coupling of components, and testability of your code.
TypeORM — A TypeScript ORM with support for a good number of open source and commercial relational databases. Sequelize is the de facto standard Node.js ORM, but like mongoose, I found it underwhelming to use with TypeScript. TypeORM is solid and has very good documentation.
The application is run with ts-node. This enables you to directly run TypeScript on node without transpiling first.
The index.ts is the entry point of the application.
This sets up the Express application and then grabs all the controllers from the InversifyJS kernel to register them with Express.
The application is wired together with InversifyJS—here's a look at how this is configured.
TYPES are used as identifiers at runtime to inject the correct dependencies.
The inversify.config.ts is used to bind implementations to interfaces. This is the only place in your application that needs to have tight coupling. This example shows how you can have multiple implementations bound to a single interface. It's possible to give these equal or separate TYPE identifiers depending on how you want to access or inject dependencies. In this POC, the Controllers would all have the same identifier so that they could be programmatically registered with Express without updating index.ts when adding a new controller.
The controllers implement the RegistrableController interface so that they can be auto-registered by the index.ts.
The AddressController implements the RegistrableController interface.
The register function sets up the endpoints in Express. AddressService is injected into the AddressController - the implementation is controlled by inversify.config.ts. One thing to note is that the functions passed to Express are using async/await, which the service and repositories are also using, so the entire call stack is asynchronous and non-blocking.
AddressService is where the business logic should reside.
This example shows how two repository implementations are injected and used together. This exact scenario isn't something you would want to do, but is done here as an example. The AddressService converts between Address and AddressDTO classes in order to not expose repository layer implementation details to the client. Like AddressController, the service is using async/await.
The AddressRepository contains both implementations to demo how Iridium and TypeORM work for MongoDB and a RDBMS work respectively.
The MongoDB implementation uses default connection properties; these would need to be put in a config file or externalized for production usage. The TypeORM connection settings are also embedded in the connect() function and should be externalized. Both implementations make use of the same interface, and both consume and produce the AddressDTO interface.
Iridium and TypeORM both require that a schema is present in order to perform mapping, which I defined in AddressSchema.
Both schemas implement AddressDTO so that both schema implementations are compatible.
In general, the native TypeScript libraries I used were well documented and intuitive to use. I would recommend defaulting to native libraries if possible for your use case. I really enjoyed using Iridium and TypeORM over moongoose and Sequelize.
I tried IntelliJ IDEA and Visual Studio Code for development. Both are workable development environments, but there is definitely room for improvement around compilation speed, click through to type definitions, and debugging.
All in all, I think TypeScript on the Node.js stack is worth a look. I've seen several organizations dive into backend Node.js development without any kind of well established architectural patterns. The addition of a statically typed language and well established design patterns can definitely improve this stack and provide for a maintainable application.
All of the code from this post is available on Github.