paint-brush
Handling ORM-Free Data Access Layer in TypeScript With MongoDBby@zzdjk6
1,408 reads
1,408 reads

Handling ORM-Free Data Access Layer in TypeScript With MongoDB

by Thor ChenJune 19th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

In this article, we’re going to discuss an alternative approach to handling data access layers in TypeScript with MongoDB. We will utilize a method that employs `zod` for data validation and type inference. We’ll also use the companion object pattern to convert an entity to a DTO (Data Transfer Objects)
featured image - Handling ORM-Free Data Access Layer in TypeScript With MongoDB
Thor Chen HackerNoon profile picture

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.


Why Schema Validation?

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:


  1. When handling input data, such as with API request payloads. In this context, schema validation can help ensure that the submitted data conforms to the expected format and structure. This is especially important for complex data sets that may contain a variety of different fields and data types.
  2. When performing read and write operations with a NoSQL database, such as MongoDB. In this context, schema validation can help ensure that the written data conforms to the expected schema, and can also validate retrieved data. This is especially important when data consistency is a concern, or when there are strict requirements for data quality and accuracy.


This article is focusing primarily on the second scenario.

Why Zod?

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.


Defining Entity with Zod

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.


Defining Data Transfer Object (DTO) with Zod

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.


Applying the Companion Object Pattern

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.


Encapsulating Data Logic in a Service Class

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.


Bonus: out-of-box IDE support with IntelliSense

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:


JetBrains IDE (e.g., IntelliJ)

Visual Studio Code


Code snippet for idea verification

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);
  });

Result


Conclusion

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.