Example CRUD backend built with express
, ts-rest
and prisma
.
- The task is to build a simple CRUD backend for managing "tasks" on "projects"
- Each task must be assigned to a project and can be in 3 states - Open, In Progress and Done.
- Each task can also be assigned from 0 to 100 tags
- The properties of a project are:
title
,description
and dates forcreatedAt
andupdatedAt
- The properties for a task are:
projectId
,description
,state
,tags
createdAt
andupdatedAt
- The API needs to be able to CRUD projects and tasks, while also being able to filter tasks by state and tags with pagination.
- The API should be documented
- The database should be Postgres and the backend should be written in ExpressJS.
TypeScript, there's no argument here.
The requirement was to document the API, so I chose to use ts-rest
to generate the API documentation and the API itself. This way, the API is always up to date with the documentation and is end-to-end type safe.
If there was no requirement for Express, I would have used the NestJS framework, which offers a lot of features out of the box, including API documentation and dependency injection.
I chose prisma
because it's the most modern and type safe ORM for TypeScript. The main advantage is it's schema-first design, which also allows to automatic generation of database migrations.
For more complex queries, I would have used a query builder like knex
or kysely
. The disadvantage of those is that they don't include a migration engine, which is essential for ease of use.
I chose grouping by feature, while using a kind of service/repository architecture within each feature (although the lines are arguably quite blurry given the lack of business logic). This way, the code is easier to navigate and the dependencies are easier to manage.
I also implemented dependency injection (with manual resolution of dependencies in main.ts
), which allows for easier testing and better separation of concerns.
You can also notice a lot of "duplication" when it comes to entity types - there is one for the Database, the API and the application. This is intentional as is allows for independent evolution of each layer, while only requiring a simple mapping between the layers.
The main challenge was finding a way to manage the tags, as that was the only "complex" part of the application. I chose to use a separate table for the tags and joined them via a join table to form a many-to-many relationship. The other would be to use an array column to store just the tags, but that would make it harder to query and filter for them without proper indexing. This is all abstracted away in the API layer though, so the user only interacts with an array of strings.
The constraint of maximum 100 tags is also enforced in the API layer by not allowing more than 100 tags to be added to a task - updating a task's tags requires sending the entire array of tags - this might be a bit inconvenient, but it's simple and given it's only 100 tags, the performance implications are minimal. The only issue is deletion of "orphaned" tags, which is not addressed here. This could be done by a scheduled job or by a trigger in the database.
There are no tests yet (there is really no business logic for test), but I would have used jest
for both unit and integration tests. Having designed the application with IoC in mind, it would be easy to mock the dependencies and test the application in isolation.
For the lack of time, there is no Delete
logic at all. I would have implemented a soft delete, where the entity is not deleted from the database, but marked as deleted (using a new deletedAt
column). This way, the entity can be restored if needed.
The error handling could be improved. Right now, there is some basic separation between Application and API errors with a simple translation between them using a simple error handler. More exception cases could be handled with some recovery logic.
There is no authentication or authorization. I would have used passport
for authentication and casl
for authorization. Or more likely a managed solution like Auth0
or Keycloak
.
No logging is implemented. I would have used pino
for logging. As for traces and telemetry, I would have used opentelemety
, which plays nicely with the pino
logger.
The API documentation is served using the ugly swagger-ui-express
. I would have used my favourite documentation tool - RapiDoc, which makes a better use of screen real estate, while also being more visually appealing, but needs a bit more configuration.
I spent around 5 hours on the project, including the initialization, experimenting with ts-rest
and documentation, with the actual implementation taking around 3 hours.
The project requires node
and yarn
to be installed.
To run the development database you need docker
and docker-compose
.
The project is managed with yarn
. To install the dependencies run:
yarn install
Copy the .env.example
file to .env
and fill the variables.
Run the development database with:
docker compose up -d
Run database migrations with prisma
:
yarn prisma migrate dev
Run the development server with:
yarn dev
Go to localhost:3000/api-doc
to see the API interactive documentation.
There are no tests yet.
Build the project with:
yarn build
Run the built project with:
node dist/main.js