node-abstract-repository
is a lightweight and type-safe library to seamlessly create custom database repositories for Node.js applications. It provides developers with some built-in CRUD operations, enabling them to focus on adding their own operations, and ensures a clean separation between persistence and domain logic. Despite being database technology agnostic by nature, it currently includes an implementation for MongoDB.
This article introduces node-abstract-repository
, but it does not include all of its details. You may find the full documentation and some usage examples at this GitHub repository, including an example of how to use this library in a NestJS-based application.
Joining ClimatePartner made me switch my gears and start to develop JS/TS-based web applications. My very first assignment was connecting a small backend NestJS app with a MongoDB database. We had big plans for our app and we soon realized that the domain model owned by my team was going to grow in complexity, and quickly. Furthermore, we saw that this was also the reality in other teams that wanted to use the same kind of solution.
All considered, we decided to incorporate a database access utility that implements the Repository pattern to cleanly decouple persistence from domain logic while keeping query logic duplication to the bare minimum. We also wanted it to be of abstract nature so that we could reuse the database access logic that is common to all custom repositories (i.e., CRUD operations over any persistent domain object). Finally, we needed it to implement the Polymorphic pattern to support subtyping-based domain models.
So, we started researching the state-of-the-art and discovered several libraries that could fit our requirements. The best candidates we were able to find were Mongoose, Typegoose, and TypeORM. Mongoose is a well-known Node.js library for MongoDB that implements the Data Mapper pattern and lets developers define schemas to constrain the data models associated with their domain objects.
However, Mongoose works with concrete data models, which is a complex domain model scenario that results in query logic duplication. Typegoose is a type-safe Mongoose wrapper that allows schema constraint declaration at the domain object field level via JS decorators. Unfortunately, those very decorators leak persistence logic into the domain model. Besides, Typegoose also implements the Data Mapper pattern, thus sharing the same drawbacks of Mongoose. TypeORM, on another hand, implements the Repository pattern and provides some basic support for MongoDB. However, TypeORM presents several limitations compared to Mongoose.
Although none of the existing alternatives were exactly fitting our needs, we saw value on Mongoose's solid, well-documented, and complete set of features. On the top of that, we wanted the most lightweight solution possible. That's why we decided to build node-abstract-repository
.
Say we have a domain model that specifies Book
as the supertype domain object and PaperBook
and AudioBook
as subtypes. Here is one possible definition for that domain model:
class Book implements Entity {
readonly id?: string;
readonly title: string;
readonly description: string;
readonly isbn: string;
constructor(book: {
id?: string;
title: string;
description: string;
isbn: string;
}) {
this.id = book.id;
this.title = book.title;
this.description = book.description;
this.isbn = book.isbn;
}
}
class PaperBook extends Book {
readonly edition: number;
constructor(paperBook: {
id?: string;
title: string;
description: string;
isbn: string;
edition: number;
}) {
super(paperBook);
this.edition = paperBook.edition;
}
}
class AudioBook extends Book {
readonly hostingPlatforms: string[];
readonly format?: string;
constructor(audioBook: {
id?: string;
title: string;
description: string;
isbn: string;
hostingPlatforms: string[];
}) {
super(audioBook);
this.hostingPlatforms = audioBook.hostingPlatforms;
}
}
Now, you want to be able to persist and retrieve instances of Book
and any of its subtypes from a MongoDB database. MongooseBookRepository
is a custom repository that specifies Book
-related database operations. Here is its definition:
class MongooseBookRepository
extends MongooseRepository<Book>
implements BookRepository
{
constructor() {
super({
Default: { type: Book, schema: BookSchema },
PaperBook: { type: PaperBook, schema: PaperBookSchema },
AudioBook: { type: AudioBook, schema: AudioBookSchema },
});
}
async findByIsbn<T extends Book>(isbn: string): Promise<Optional<T>> {
if (!isbn)
throw new IllegalArgumentException('The given ISBN must be valid');
return this.entityModel
.findOne({ isbn: isbn })
.exec()
.then((book) => Optional.ofNullable(this.instantiateFrom(book) as T));
}
}
Voilà! MongooseBookRepository
inherits a series of CRUD database operations from MongooseRepository
and adds its own i.e., findByIsbn
. Now you can simply instantiate MongooseBookRepository
and execute any of the database operations as follows:
const bookRepository = new MongooseBookRepository();
const books: Book[] = bookRepository.findAll();
No more leaking of persistence logic into your domain/application logic!
You may be wondering about certain elements exposed in the previous example. Starting from the top part of the MongooseBookRepository
definition, the first of these is MongooseRepository<Book>
. This is one of the two main elements exposed at node-abstract-repository
: a generic template that implements several common CRUD database operations. Furthermore, this class also handles the creation of a Mongoose data model capable of handling subtyping-based book data objects. All this complexity is hidden from you; all you need to know is that you have entityModel
at your disposal to implement your own database operations, as shown at findByIsbn
.
The only extra effort that is required from you is that you define a map that specifies the types that compose your domain model and their related Mongoose schemas, as specified at the constructor of MongooseBookRepository
. It is also important to note that the key of the entry that specifies your domain object supertype must be named Default
, and the key of each other entry must be named after the type name of the subtype domain object it refers to. This is a naming convention required by the inner implementation details of MongooseRepository
. If you do not have any domain object subtype in your domain model then simply instantiate a map with a unique entry that specifies a Default
key and the type-schema values of your domain object.
Another element that may catch your eye is BookRepository
. This is an optional interface that extends Repository
, the second main element exposed at node-abstract-repository
: a database technology agnostic generic interface that specifies all the supported CRUD database operations to be implemented by any repository. The following snippet shows this relation between these two interfaces:
interface BookRepository extends Repository<Book> {
findByIsbn: <T extends Book>(isbn: string) => Promise<Optional<T>>;
}
Handling instances of interface types instead of classes is a good practice in object-oriented applications; depending on abstractions instead of implementations makes your code scalable and better suited to change. Therefore, I would highly recommend that you define your own repository interfaces, or in the case, you are only interested in exposing CRUD operations, instantiate your custom repositories as objects of type Repository<T>
, where T
is your domain object supertype.
On another hand, Book
implements an interface called Entity
. This interface models any persistent domain object type. The interface defines an optional id
field. The optional nature of the field is due to the fact that its value is internally set by Mongoose. If you do not want your domain model to include this dependency, though, you can just ensure that your domain objects specify an id?: string
field. And there is nothing wrong doing that, as id
is the canonical primary key in MongoDB documents.
Finally, many database operations return objects of type Optional
. This type is inspired in the Java Optional
type. Its purpose is to model the concept of "no result". This type is an excellent alternative to returning null
objects or throwing an exception. The usage of Optional
is safer and clearer than having to specify T | null
as result type or having to handle an exception that does not represent an application exceptional condition. So yes, this the feature that makes node-abstract-repository
a bit opinionated, but I believe that is worth it.
The node-abstract-repository
library provides the means to create custom repositories for Node.js-based applications in a fast and easy manner. It is conceived to achieve effective decoupling between persistence and domain logic. Its current implementation is a Mongoose wrapper that includes all the boilerplate code required to perform common CRUD operations over polymorphic domain objects in MongoDB. Thus, developers can focus in writing domain-specific database operations at custom repositories.
Moreover, the library has been designed to allow developers use many interesting Mongoose features (e.g., creation and validation of polymorphic models as well as hook implementations, just two mention two) without having to deal with most of Mongoose's inner complexity.