DEV Community

Harry Nongomin
Harry Nongomin

Posted on

Building an HR Management API with NestJS and Prisma: Lessons From Real-World Database Problems

uilding a backend application is very different from simply following a tutorial.

When you are building a real system, you eventually encounter problems that don't have a simple "copy this code" solution.

Recently, I've been working on an HR management API using:

NestJS
TypeScript
Prisma
MySQL
Swagger

One of the modules I worked on was an HR query-management system.

The system needs to allow authorised HR users to create queries, associate them with employees and organisations, record responses, resolve queries, and maintain an audit trail of important actions.

What I initially expected to be straightforward quickly became an interesting lesson in database relationships, application architecture, and debugging.

The architecture

At a simplified level, the application looks like this:

Client
│
▼
Controller
│
▼
Service
│
▼
Prisma Client
│
▼
MySQL Database

Each layer has a responsibility.

The controller handles incoming HTTP requests.

The service contains the business logic.

Prisma provides the interface between the application and the database.

MySQL stores the actual data.

This separation is useful, but it also means that a problem in one layer can affect another.

The first important lesson: database relationships matter

One of the problems I encountered involved creating a record that had several relationships.

For example, a query could be associated with:

an organisation
an employee
an infraction type
the user who issued the query

It is tempting to think:

"I have the IDs, so I'll just pass the IDs when creating the record."

But Prisma's generated types may expect a relationship to be represented through a nested relation depending on how the schema is defined.

Conceptually, you might have something like:

model QueryRecord {
id BigInt @id @default(autoincrement())
clientId String

organisation Organisation @relation(
fields: [clientId],
references: [clientId]
)

employeeRecordId BigInt
employee Employee @relation(
fields: [employeeRecordId],
references: [id]
)
}

The important thing here is that there are two related concepts:

Foreign Key
↓
clientId
↓
Organisation

The foreign key is the value stored in the database.

The relation is how Prisma represents the connection between models.

Understanding that distinction makes Prisma errors much easier to reason about.

Don't blindly trust the error — understand what it means

One of the most useful things I've learned is that fixing errors isn't just about making the red message disappear.

Suppose Prisma reports that a required relation is missing.

The first instinct might be:

"What syntax do I need to make this error go away?"

A better question is:

"Why does Prisma think this relationship is required?"

That leads you back through the chain:

Database
↓
Prisma schema
↓
Generated Prisma Client
↓
Service implementation

If the Prisma schema says a relation is required, the generated client will enforce that expectation.

So the error can actually be useful information about the architecture.

Database schema and application schema must agree

Another important lesson was dealing with differences between the database and the Prisma schema.

Imagine your Prisma model contains:

updatedAt DateTime @map("updated_at")

but the actual database table doesn't contain:

updated_at

Your application can fail even though your TypeScript code looks perfectly reasonable.

The problem isn't necessarily the service.

The problem is that the application's understanding of the database doesn't match the actual database.

This gives us another useful model:

    Prisma Schema
          │
          │ should match
          ▼
    MySQL Database
Enter fullscreen mode Exit fullscreen mode

When those two disagree, strange runtime errors can appear.

This is why database migrations, schema synchronization, and generated clients are important parts of backend development.

API design matters too

The module also required endpoints for different stages of an HR query.

For example:

PUT /queries/:referenceId/response

could be used to submit a response.

And:

PUT /queries/:referenceId/resolve

could be used to resolve the query.

These endpoints aren't just about making HTTP requests work.

They represent actual business actions.

That means the backend needs to consider things such as:

Who is allowed to perform the action?
Which employee does the query belong to?
What state is the query currently in?
Can an already-resolved query be resolved again?
What should be recorded in the audit log?
What happens if the reference number doesn't exist?

This is where backend development becomes more than CRUD.

You're implementing business rules.

Authorization is part of the feature

For an HR system, not every authenticated user should be able to perform every action.

For example:

User
│
├── Authentication
│ ↓
│ "Who are you?"
│
└── Authorization
↓
"What are you allowed to do?"

A user being logged in doesn't automatically mean they should be able to resolve an HR query.

This is why role-based authorization is important in business applications.

For example:

HR Admin
├── Create query
├── Respond
├── Resolve
└── View audit history

Regular Employee
└── View relevant information

The exact permissions depend on the application's requirements, but the principle remains the same.

Swagger became useful during development

Another tool I found useful during the process was Swagger.

Instead of testing every endpoint manually from the frontend, Swagger provides an interface where the API can be tested directly.

For example:

PUT /queries/{referenceId}/response

Request:

{
"responseText": "Response from employee"
}

This makes it easier to determine whether a problem is coming from:

the frontend
the API request
validation
the service
Prisma
or the database

That separation can save a lot of debugging time.

What I learned

The biggest lesson wasn't a particular NestJS command or Prisma syntax.

It was learning to think about the entire system.

When something breaks, I now try to trace it through the architecture:

Request
↓
Controller
↓
Validation
↓
Service
↓
Prisma
↓
Database

Then I ask:

Where does the expectation stop matching reality?

That question is often more useful than simply searching for the error message.

Final thoughts

Working on real software has changed the way I think about programming.

Tutorials can teach you how to create a controller, define a Prisma model, or write a database query.

Real projects teach you how those pieces interact.

You encounter:

unexpected database constraints
relationship problems
schema mismatches
authorization requirements
business rules
API design decisions
debugging problems that don't have a one-line solution

And that's where a lot of the actual learning happens.

I'm continuing to improve my backend engineering skills and to build systems that solve real business problems.

There is still a lot to learn, but every difficult bug is another opportunity to understand the system better.

NestJS #TypeScript #Prisma #MySQL #BackendDevelopment #SoftwareEngineering #WebDevelopment #Programming

Top comments (0)