DEV Community

Kaio Marx
Kaio Marx

Posted on

How to organize typescript projects with prisma.io

First things, first!

The idea here is to show a point of view on the code architecture and improve the format by your feedback in the comments, so feel free to demonstrate your way of orchestrating projects with Node/Typescript.

Let's Understand

Some tools used in typescript development can declare an opinion about your code, others like prism leave this responsibility of organization to you. I prefer the prism.

With the prism we have a configuration optimization because most of the prism does it for you, but this ORM doesn't organize its connections by "Entities". This implies that your connection client will bring all your tables in a single class, at this moment it is important to divide it with layers of responsibility, the famous repositories.

What are Repositories?

Repositories are classes or components that encapsulate the logic required to access data sources. They centralize common data access functionality, providing better maintainability and decoupling the infrastructure or technology used to access databases from the domain model layer

To demonstrate this abstraction of code construction, let's analyze this diagram that I use in the development routine:

.
├── src
│   ├── config
│   │   └── prisma.ts
│   ├── modules
│   │   └── domain_name
│   │       ├── dtos
│   │       ├── infra
│   │       │   ├── repository
│   │       │   └── IdomainRepository.ts
│   │       └── useCases
│   │           └── -subDomain
│   │               ├── -SubDomainController.ts
│   │               └── -SubDomainUseCase.ts
Enter fullscreen mode Exit fullscreen mode

For all class files or library configuration we have the configs directories, in projects with prisma it is appropriate to create this file "dbClient.ts" to instantiate a single connection to the database through the client class (PrismaClient), as you can see in the following code:

import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient();
Enter fullscreen mode Exit fullscreen mode

Domains

"The domain is the world of the business you are working with and the problems they want to solve. This will typically involve rules, processes and existing systems that need to be integrated as part of your solution. The domain is the ideas, knowledge and data of the problem you are trying to solve."

So imagine an ecommerce where we have a database with tables named "Products", "Customers", "Users". our domains will be respectively these tables, everything that involves a "product" will be in the Products directory, being this product update, deletion, creation, queries.

Domains have a very simple organization when implemented with typescript.

Dtos: refers to the acronym Data Transport Object, it is the folder where interfaces will be created and maintained, they are generally used to define the types of parameters passed in the "useCases" functions.

export interface ICreateClientDTO {
  username: string;
  password: string;
}
Enter fullscreen mode Exit fullscreen mode

The "I" starting the filename and the class is just a customization convention by eslint rules, to identify Interfaces.

infra/Repository: as explained earlier, here will be the classes that take care of the connection with the database, these classes must have connectivity directly related to the domain, to maintain a division of responsibilities implement contracts in your class, dictating what methods it must show for the useCases.

import { Clients, PrismaClient } from "@prisma/client"
import { prisma } from "../../../../config/prisma"
import { ICreateClientDTO } from "../../dtos/ICreateClientDTO"
import { IClientRepository } from "../IClientRepository"

class ClientRepository implements IClientRepository {
  private repository: PrismaClient

  constructor() {
    this.repository = prisma
  }

  async findOneByUsername(username: string): Promise<Clients> {}

  async create({ username, password }: ICreateClientDTO): Promise<void> {}

  async findAll(): Promise<Clients[]> {
    //Your code
  }
}

export { ClientRepository }
Enter fullscreen mode Exit fullscreen mode

infra/IdomainRepository: These interfaces serve to define custom methods serving as contracts that our repository class must follow. This ensures that even if one day the connection implementation changes it will serve the application without releasing side effects in the code.

import { Clients } from "@prisma/client";
import { ICreateClientDTO } from "../dtos/ICreateClientDTO";

export interface IClientRepository {
  findAll(): Promise<Clients[]>
  findOneByUsername(username: string): Promise<Clients>
  create({ username, password }: ICreateClientDTO): Promise<void>
}
Enter fullscreen mode Exit fullscreen mode

UseCases

the use cases will have the subdomains, subdomain can be classified as the action to be performed, for example in the Domain "Products" we will have a logical subdomain "createProducts". the structure of a subdomain does not change and is composed of Controller and useCase, this generates a request manager (Controller) and a manager of the business rule and validations (useCase).

Controller:
 import { Request, Response } from "express";
import { container } from "tsyringe";
import { CreateClientsUseCase } from "./CreateClientsUseCase";

class CreateClientsController {
  async handle(req: Request, res: Response): Promise<Response> {
    const { username, password } = req.body;

    const createClientsUseCase = container.resolve(CreateClientsUseCase)

    await createClientsUseCase.execute({
      username,
      password
    })

    return res.status(201).send()
  }
}

export { CreateClientsController }
Enter fullscreen mode Exit fullscreen mode
import { PrismaClient } from "@prisma/client";
import { hash } from "bcrypt";
import { inject, injectable } from "tsyringe";
import { AppError } from "../../../../shared/errors/AppError";
import { ICreateClientDTO } from "../../dtos/ICreateClientDTO";
import { IClientRepository } from "../../infra/IClientRepository";

@injectable()
class CreateClientsUseCase {
  constructor(
    @inject('ClientsRepository') private clientRepository: IClientRepository
  ) {}

  async execute({ username, password }: ICreateClientDTO) {
    const userAlreadyExists = await this.clientRepository.findOneByUsername(
      username
    )

    if (userAlreadyExists) {
      throw new AppError("User Already Exists")
    }

    const encryptedPassword = await hash(password, 10)

    await this.clientRepository.create({
      username,
      password: encryptedPassword
    })
  }
}

export { CreateClientsUseCase }
Enter fullscreen mode Exit fullscreen mode

Considering that architecture is much more theoretical and comprehensive than the simplification made here, the code and structure presented above serves as a guide to make it easier to organize. In future posts I will put these ideas into practical examples applied to real problems.

If you want to ask about any topic of the code, feel free to contact me through the information on my profile or on my twitter.

Top comments (1)

Collapse
 
padupe profile image
Paulo Peixoto

First, congratulations on the content!
It helped me a lot to implement a more adequate structure for a Study Project.

However, I am getting the following error in one of my Repository functions (findByUsername):

Type 'User / null' cannot be assigned to type 'User'.

This same error occurs in the search functions by email or id.

I still have a function that performs a JOIN, and that returns an error.