In this article, we’re going to discuss an alternative approach to handling the data access layer in TypeScript with MongoDB and without ORM.
Traditionally, developers leverage Object-Relational Mapping (ORM) tools to map between data types in databases and object-oriented programming languages. However, ORM may sometimes lead to performance issues, complex configurations, or inflexibility to read/write data.
To circumvent these challenges, we will utilize a method that employs zod
for data validation and type inference, companion object
pattern for converting Entities to DTO (Data Transfer Objects), and encapsulating data logic within a Service class.
Schema validation is a programming process that ensures data conforms to a predefined schema or structure. The schema serves as a blueprint or model that defines permitted data and its organization, often specified in terms of data types, constraints, and relationships. By enforcing schema validation, programs can catch data errors early, enhance data consistency, and prevent potential issues related to incorrect or malformed data.
There are two common scenarios in which we need to apply schema validation:
This article is focusing primarily on the second scenario.
Zod
is a TypeScript-first library designed for schema declaration and validation. It enables developers to construct schemas that validate runtime data and generate TypeScript types, ensuring type safety without additional manual type annotations. By integrating with TypeScript, zod
enhances the language's static typing capabilities with runtime validation, offering features like custom error handling, nested schemas, and transformations.
Please note that although MongoDB has a “JSON Schema Validation” feature, some MongoDB API-compatible services like AWS DocumentDB do not support it. Furthermore, I believe that the application code should be conscious of and enforce all the data constraints it is operating with. Therefore, I would still prefer to have schema validation as part of the application code, and this is where zod
can be helpful.
Let’s consider an example where we’re working with users in a MongoDB database. We’ll start by defining an entity representing how a user is stored in the database.
import { z } from "zod";
import { ObjectId } from "mongodb";
export const userEntitySchema = z.object({
_id: z.instanceof(ObjectId),
name: z.string(),
email: z.string().email(),
});
export type UserEntity = z.infer<typeof userEntitySchema>;
In the above code, we’re defining a userEntitySchema
and a UserEntity
type. The schema describes that a user entity has three fields: _id
, name
, and email
. The type UserEntity
is inferred directly from the schema.
Next, we’ll define a DTO that represents how we’re going to use the user data in our application.
export const userDTOSchema = z.object({
id: z.string(),
name: userEntitySchema.shape.name,
email: userEntitySchema.shape.email,
});
export type UserDTO = z.infer<typeof userDTOSchema>;
Here, we’ve defined a similar schema and type for UserDTO
, but notice that the _id
field has been replaced with id
, while name
and email
are both using the shape from userEntitySchema
.
To convert an entity to a DTO, we’ll apply the companion object pattern. This pattern involves creating a companion object to hold static functions related to the class or type.
export const UserDTO = {
convertFromEntity(entity: UserEntity): UserDTO {
const candidate: UserDTO = {
id: entity._id.toHexString(),
name: entity.name,
email: entity.email,
};
return userDTOSchema.parse(candidate);
},
};
In the above code, we’ve defined an object called UserDTO
with one function:convertFromEntity
— this function can convert a UserEntity
object to a UserDTO
object.
To explain it a bit more, TypeScript manages “types” and “concrete object definitions” in different namespaces, that’s why we could define a type UserDTO
as well as a concrete object called the same name. An object defined in this way usually has some util or helper functions that apply to the same-name type, thus it is so-called a “companion object”.
Please also note that we do not only guarantee the type safety on compile time but also ensure the runtime type safety via schema.parse()
— which will make sure that the object meets the schema validation requirement.
Finally, we will encapsulate our data logic within a service class, which will handle all CRUD operations for the users. This service class will interact directly with MongoDB using the MongoDB Node.js driver.
import { MongoClient, Db } from "mongodb";
export class UserService {
private readonly db: Db;
constructor(mongoClient: MongoClient) {
this.db = mongoClient.db();
}
private getUsersCollection() {
return this.db.collection<UserEntity>("users");
}
async findUser(id: string): Promise<UserDTO | null> {
const entity = await this.getUsersCollection().findOne({ _id: new ObjectId(id) });
return entity ? UserDTO.convertFromEntity(entity) : null;
}
async createUser(dto: Omit<UserDTO, "id">): Promise<UserDTO> {
const candidate = userEntitySchema.parse({
...dto,
_id: new ObjectId(),
});
const { insertedId } = await this.getUsersCollection().insertOne(candidate);
return UserDTO.convertFromEntity({ ...dto, _id: insertedId });
}
async updateUser(id: string, dto: Omit<Partial<UserDTO>, "id">): Promise<UserDTO | null> {
const candidate = userEntitySchema.partial().parse(dto);
const { value } = await this.getUsersCollection().findOneAndUpdate(
{ _id: new ObjectId(id) },
{ $set: candidate },
{ returnDocument: "after" }
);
return value ? UserDTO.convertFromEntity(value) : null;
}
async deleteUser(id: string): Promise<void> {
await this.getUsersCollection().deleteOne({ _id: new ObjectId(id) });
}
}
In this UserService
class, we implemented all CRUD operations for users: findUser
, createUser
, updateUser
, and deleteUser
. All these methods interact with MongoDB directly through the MongoDB Node.js driver and handle conversions between UserEntity
and UserDTO
.
Please note that this example may not represent real-world business data constraints, but rather an idea to encapsulate the data logic around User
.
Some people may prefer to enforce the Repository pattern and call it UserRepository
, making the class solely focused on CRUD operations without any business logic, then use UserRepository
inside the UserService
. For small to medium-sized projects, I would recommend to start with Service
which should already efficiently fulfill the needs without too much overhead.
By define types in the way we described in this blog, IDE will get us covered without need for extra library or plugin. For example, when we try to build query using .findOne()
, IDE or code editor knows what are the fields we can filer on:
Here we could have a simple code snippet to verify our idea:
import _ from "lodash";
const main = async () => {
const mongoClient = new MongoClient("mongodb://localhost:27017/data-access-example");
try {
await mongoClient.db().dropCollection("users");
} catch (e) {
if (_.get(e, "codeName") !== "NamespaceNotFound") {
throw e;
}
console.log(`Collection "users" does not exist, no need to drop`);
}
await mongoClient.db().createCollection("users");
const userService = new UserService(mongoClient);
const createdUser = await userService.createUser({ name: "example", email: "[email protected]" });
console.log({ createdUser });
const updatedUser = await userService.updateUser(createdUser.id, { name: "exampleX" });
console.log({ updatedUser });
const foundUser = await userService.findUser(createdUser.id);
console.log({ foundUser });
await userService.deleteUser(createdUser.id);
};
main()
.then(() => {
process.exit();
})
.catch((e) => {
console.error(e);
process.exit(1);
});
In this blog post, we’ve discussed an alternative approach to handle the data access layer without ORM when using TypeScript and MongoDB. This method offers flexibility and eliminates potential bottlenecks associated with ORMs, while maintaining the consistency and clarity of our data layer. With the zod
library for data validation and type inference, companion object pattern for conversion operations, and encapsulation within service classes, we're able to keep our code clear, concise, and highly functional.
Also published here.