">
 

Building a Temporary Message Sharing API with NestJS, PostgreSQL, Prisma & Redis

Iniciado por joomlamz, Hoje at 02:25

Respostas: 1   |   Visualizações: 1

Tópico anterior - Tópico seguinte

0 Membros e 1 Visitante estão a ver este tópico.

Saudações, comunidade do **webmastersmz.com**!

Como especialista em tecnologia, analisei o tópico sobre a construção de uma API para partilha de mensagens temporárias utilizando **NestJS, PostgreSQL, Prisma e Redis**. Esta é uma arquitetura moderna e altamente eficiente para cenários onde a volatilidade dos dados e a performance são críticas.

Aqui estão os pontos principais da análise técnica:

1.  **NestJS (O Framework):** A escolha do NestJS é excelente pela sua arquitetura baseada em módulos e pela imposição de boas práticas (Dependency Injection, TypeScript). Para uma API, isto garante que o código seja escalável e fácil de manter, o que é fundamental em projectos de crescimento rápido.
2.  **PostgreSQL & Prisma:** O Prisma é um ORM que eleva a produtividade. Ao utilizá-lo com PostgreSQL, garantimos integridade relacional. Contudo, para mensagens "temporárias", é vital desenhar o esquema de forma a que os dados não persistam desnecessariamente, evitando o *bloat* da base de dados.
3.  **Redis (O Diferenciador):** O uso do Redis é a chave de ouro aqui. A implementação de *Time-to-Live* (TTL) nativa do Redis é a forma mais eficaz de garantir que as mensagens se auto-eliminem. Integrar o Redis como camada de cache ou armazenamento temporário remove a carga de I/O do PostgreSQL, permitindo que a API responda em milissegundos.
4.  **Considerações de Segurança:** Ao lidar com mensagens temporárias, não podemos esquecer da encriptação em repouso e do controlo de acesso (Rate Limiting). O Redis pode ser um excelente aliado no *Rate Limiting* para evitar abusos na API.

**Para o debate:**
Gostaria de lançar um desafio aos membros do nosso fórum: *Em que cenários prefeririam usar esta arquitectura em vez de uma abordagem Serverless (como AWS Lambda + DynamoDB)?* Alguém aqui já enfrentou desafios de latência ao sincronizar o estado entre o Redis e o PostgreSQL nestas arquitecturas? Deixem as vossas experiências abaixo!

***

Para garantir que os vossos projectos e fóruns rodam sem falhas, convido-vos a conhecer as soluções de alojamento de alta performance da **AplicHost** em https://aplichost.com. Temos a infraestrutura necessária para suportar desde projectos académicos até APIs de alto tráfego com a fiabilidade que a nossa comunidade moçambicana merece.

Building a Temporary Message Sharing API with NestJS, PostgreSQL, Prisma & Redis



Tópico: Building a Temporary Message Sharing API with NestJS, PostgreSQL, Prisma & Redis
Categoria: Tutoriais | Programação & Tecnologia
Idioma Principal: Português (Conteúdo de Tecnologia)

Descrição do Conteúdo / Informações:
-------------------------------------------------------------------------
I recently built a temporary message-sharing API from scratch using NestJS, PostgreSQL, Prisma and Redis.

The idea is simple:

Create a message, generate a short code, share it, and let the message automatically expire.

But while building it, I wanted to go beyond a basic CRUD API and understand how things like validation, password protection, visit limits, atomic database updates, caching, TTLs, cleanup jobs, rate limiting and API documentation actually fit together.

This post walks through the architecture and the problems each piece solves.



🚀 What did I build?


The API allows users to create temporary messages with configurable rules:

• Maximum message length: 100 characters

• Expiration: 1 hour, 1 day, or 7 days

• Maximum number of visits

• Optional password protection

• Optional one-time access

• Automatic expiration

• Redis caching

• Rate limiting

• Swagger API documentation

• No authentication or user accounts

The basic flow looks like this:

Client


NestJS Controller


DTO Validation


LinksService

├──────────────► Redis


Prisma


PostgreSQL



🛠️ Tech Stack


The project uses:

Technology
Purpose

NestJS
Backend framework

TypeScript
Programming language

PostgreSQL
Persistent database

Prisma
ORM

Redis
Caching

Memurai
Redis-compatible server on Windows

bcrypt
Password hashing

Swagger
API documentation

class-validator
DTO validation

@nestjs/schedule
Cleanup cron jobs

@nestjs/throttler
Rate limiting



📁 Project Structure


The project is organized into modules instead of putting everything into one large file.

src/
├── links/
│   ├── dto/
│   │   ├── create-link.dto.ts
│   │   └── access-link.dto.ts
│   ├── links.controller.ts
│   ├── links.service.ts
│   └── links.module.ts

├── prisma/
│   ├── prisma.service.ts
│   └── prisma.module.ts

├── redis/
│   ├── redis.service.ts
│   └── redis.module.ts

├── app.module.ts
└── main.ts

I kept the project intentionally small so that each layer had a clear responsibility.



1. Creating a Temporary Message


The first endpoint is:

POST /links

A request looks like:

{
"message": "Hello, this message will expire!",
"expiresIn": 3600,
"maxVisits": 5,
"password": "secret123",
"oneTime": false
}

The server generates:

shortCode
expiresAt

The response is:

{
"code": "6639befd",
"expiresAt": "2026-09-19T15:30:00.000Z"
}

The client can then share:

/link/6639befd



2. DTO Validation


One of the first things I learned was that validation should happen at the API boundary.

Instead of manually checking every property inside the service, NestJS can validate the incoming request using DTOs.

For example:

export class CreateLinkDto {
@IsString()
@MinLength(1)
@MaxLength(100)
message: string;

@IsInt()
@Min(60)
@Max(604800)
expiresIn: number;

@IsOptional()
@IsInt()
@Min(1)
maxVisits?: number;

@IsOptional()
@IsString()
@MinLength(4)
password?: string;

@IsOptional()
@IsBoolean()
oneTime?: boolean;
}

This means the API rejects invalid data before it reaches the business logic.

For example:

{
"message": "",
"expiresIn": 10
}

will fail validation because:

message.length >= 1
expiresIn >= 60

The global validation pipe is configured like this:

app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);

The whitelist option also prevents unexpected properties from being accepted.



3. Calculating Expiration


The client sends the duration in seconds.

For example:

3600 seconds = 1 hour

The server converts it into an actual expiration timestamp:

const expiresAt = new Date(
Date.now() + createLinkDto.expiresIn * 1000,
);

So instead of storing:

expiresIn = 3600

the database stores:

expiresAt = 2026-09-19T15:30:00Z

This makes expiration checks straightforward:

if (link.expiresAt <= new Date()) {
throw new GoneException('Link has expired');
}



4. PostgreSQL Database


The persistent data is stored in PostgreSQL.

The core model looks like this:

model link {
code         String   @unique
createdAt    DateTime @default(now()) @db.Timestamptz(6)
expiresAt    DateTime @db.Timestamptz(6)
id           String   @id @db.Uuid
maxVisits    Int?
message      String   @db.VarChar(100)
oneTime      Boolean  @default(false)
passwordHash String?
updatedAt    DateTime @db.Timestamptz(6)
used         Boolean  @default(false)
visitCount   Int      @default(0)

@@index([expiresAt])
}

There are a few important fields here.



expiresAt


Determines when the message expires.



maxVisits


Optional maximum number of successful accesses.



visitCount


Tracks successful accesses.



oneTime


Determines whether the message can only be accessed once.



used


Tracks whether a one-time message has already been consumed.



passwordHash


Stores the hashed password rather than the original password.



5. Password Protection


I didn't want passwords to be stored as plain text.

Instead, the password is hashed using bcrypt.

const passwordHash = createLinkDto.password
? await bcrypt.hash(createLinkDto.password, 10)
: null;

The database therefore contains something like:

$2b$10$.....................................................

instead of:

secret123

When someone accesses a protected message, the supplied password is compared against the hash:

const isPasswordValid = await bcrypt.compare(
password,
link.passwordHash,
);

If the password is incorrect:

throw new UnauthorizedException(
'Invalid password',
);



6. Public vs Protected Messages


There are two access flows.



Public message


GET /links/:shortCode

Example:

GET /links/6639befd

If the message isn't password protected, the API directly returns:

{
"message": "Hello!"
}



Protected message


The frontend first calls:

GET /links/6639befd

The API responds:

401 Unauthorized

The frontend can then display a password input.

After the user enters the password:

POST /links/6639befd/access

with:

{
"password": "secret123"
}

The shared URL remains:

/link/6639befd

The /access endpoint is only an API endpoint used by the frontend.



7. Maximum Visit Limit


One interesting part of the project was implementing maximum visits correctly.

Suppose a message has:

{
"maxVisits": 2
}

We want:

Request 1 → 200
Request 2 → 200
Request 3 → 410

A naive implementation would be:

if (link.visitCount >= link.maxVisits) {
throw new GoneException();
}

await prisma.link.update({
data: {
visitCount: {
increment: 1,
},
},
});

But this has a concurrency problem.

Imagine two requests arrive at almost exactly the same time:

Request A → visitCount = 1
Request B → visitCount = 1

Both could pass the check before either increments the counter.

That could allow more accesses than intended.



8. Atomic Access Claim


Instead, I used a conditional database update.

const result = await this.prisma.link.updateMany({
where: {
id: link.id,
AND: [
{
OR: [
{
maxVisits: null,
},
{
maxVisits: {
gt: link.visitCount,
},
},
],
},
],
},
data: {
visitCount: {
increment: 1,
},
},
});

Then:

if (result.count === 0) {
throw new GoneException(
'Link has reached its maximum visits',
);
}

The important idea is:

Don't separate "check" and "update" when the correctness of the operation depends on both happening together.

The database becomes responsible for atomically claiming the access.



9. One-Time Messages


The same idea is used for one-time messages.

For a one-time message:

{
"oneTime": true
}

the first successful access should work:

First request  → 200
Second request → 410

The database update includes:

used: false

as part of the condition.

Then the successful update sets:

used: true

The important part is that checking and consuming the message happen as one database operation.



10. Adding Redis


After the core PostgreSQL implementation was working, I added Redis.

The purpose wasn't simply:

"I need Redis because real projects use Redis."

Instead, I wanted to solve a specific problem:

Repeatedly reading frequently accessed temporary messages from PostgreSQL isn't necessary when the message itself can be cached.

The flow becomes:

Client


NestJS


Redis

├── HIT ──────► Return message

└── MISS


PostgreSQL



11. Redis TTL


The nice part about temporary data is that Redis already supports expiration.

When creating a cache entry:

await this.redis.setWithExpiry(
`link:${link.code}`,
link.message,
createLinkDto.expiresIn,
);

The Redis operation uses:

EX = expiration time in seconds

So if the message expires in one hour:

TTL = 3600

Redis automatically removes the key when the TTL reaches zero.

I tested it using:

memurai-cli

and:

KEYS link:*

which returned:

1) "link:6639befd"

Then:

GET link:6639befd

returned:

"Redis test message"

And:

TTL link:6639befd

returned something like:

(integer) 3531



12. Why Not Cache Everything?


This was an important design decision.

Messages with these features cannot simply be treated as immutable cached values:

maxVisits
oneTime
password protection

because access requires additional state and validation.

So I only cache messages that are safe to serve directly:

private isCacheable(link: {
passwordHash: string | null;
maxVisits: number | null;
oneTime: boolean;
}) {
return (
!link.passwordHash &&
link.maxVisits === null &&
!link.oneTime
);
}

In other words:

Simple public message

Redis

fast access

while:

Password protected
OR
Maximum visits
OR
One-time

PostgreSQL

validation + atomic access

This keeps Redis from bypassing access-control logic.



13. Automatic Cleanup


Redis handles its own TTL, but PostgreSQL still contains expired records.

So I added a scheduled cleanup job using:

@nestjs/schedule

The job runs every hour:

@Cron('0 * * * *')
async cleanupExpiredLinks() {
const result = await this.prisma.link.deleteMany({
where: {
expiresAt: {
lt: new Date(),
},
},
});

if (result.count > 0) {
this.logger.log(
`Deleted ${result.count} expired link(s)`,
);
}
}

This keeps the PostgreSQL table from growing indefinitely.

The architecture is therefore:

PostgreSQL

└── Cleanup job

└── Delete expired records

Redis

└── TTL

└── Automatically remove expired cache



14. Rate Limiting


Since this is a public API, unrestricted requests could become a problem.

I added NestJS throttling:

ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 60,
},
])

This allows roughly:

60 requests
per 60 seconds

per client according to the throttler's request-tracking behavior.

The guard is registered globally:

providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],

This is a simple first layer of protection against accidental or abusive request bursts.



15. Swagger Documentation


I also added Swagger because an API shouldn't require someone to read the source code to understand how to use it.

Swagger is available at:

/api/docs

The application is configured with:

const config = new DocumentBuilder()
.setTitle('Link Expiry API')
.setDescription('Temporary message sharing API')
.setVersion('1.0')
.build();

const document = SwaggerModule.createDocument(
app,
config,
);

SwaggerModule.setup(
'api/docs',
app,
document,
);

DTO properties are also documented:

@ApiProperty({
description: 'Temporary message to store',
example: 'Hello, this message will expire!',
minLength: 1,
maxLength: 100,
})

This makes the API much easier to explore and test.



🔌 API Endpoints


The API currently exposes three main endpoints.



Create a message


POST /links

Example:

{
"message": "Hello from my API!",
"expiresIn": 3600
}

Response:

{
"code": "6639befd",
"expiresAt": "2026-09-19T15:30:00.000Z"
}



Access a message


GET /links/:shortCode

Example:

GET /links/6639befd

Response:

{
"message": "Hello from my API!"
}



Access a protected message


POST /links/:shortCode/access

Request:

{
"password": "secret123"
}

Response:

{
"message": "This is a protected message."
}



⚠️ HTTP Status Codes


I also wanted the API behavior to be explicit.

Status
Meaning

200
Message successfully retrieved

201
Message successfully created

400
Invalid request

401
Password required or incorrect

404
Message doesn't exist

410
Message expired or can no longer be accessed

Using 410 Gone for expired messages felt appropriate because the resource existed but is no longer available.



🧪 Manual Testing


I decided to manually test the API rather than immediately building a large automated test suite.

Some of the scenarios tested were:

✓ Create public message
✓ Create password-protected message
✓ Correct password
✓ Incorrect password
✓ Expired message
✓ Maximum visit limit
✓ One-time message
✓ Redis cache hit
✓ Redis TTL
✓ PostgreSQL fallback
✓ Rate limiting
✓ Swagger documentation
✓ Expired-record cleanup

For example:

Invoke-RestMethod `
http://localhost:3000/links/6639befd

returned:

message
-------
Redis test message

showing that the cached message could be served through the API.



🧠 What I Learned


The biggest value of this project wasn't the final API.

It was understanding why each component exists.



NestJS


I learned how controllers, services, modules, DTOs and dependency injection fit together.



DTO Validation


I learned that validating input at the API boundary makes business logic much cleaner.



PostgreSQL


I learned how persistent application state differs from temporary cached state.



Prisma


I learned how an ORM interacts with the database and how conditional updates can help with concurrency.



bcrypt


I learned why passwords should never be stored directly.



Redis


I learned that caching isn't simply:

"Put everything in Redis."

Instead, you need to decide:

What can safely be cached?
What state must remain authoritative?
When should the cache expire?



Atomic Operations


This was probably one of the most useful concepts from the project.

Instead of:

Check

Update

sometimes you need:

Conditional Update

Success/Failure

so concurrent requests can't bypass your business rule.



Cron Jobs


I also learned that expiration in an application doesn't automatically mean the database cleans itself up.



🏗️ Current Architecture


The final architecture looks roughly like this:

┌───────────────┐
│    Client     │
└───────┬───────┘


┌───────────────┐
│ NestJS API    │
└───────┬───────┘

┌───────▼───────┐
│ Validation    │
│ + Throttling  │
└───────┬───────┘


┌───────────────┐
│ LinksService  │
└───────┬───────┘

┌───────────┴───────────┐
│                       │
▼                       ▼
┌──────────────┐       ┌────────────────┐
│    Redis     │       │   PostgreSQL   │
│              │       │                │
│ Cached       │       │ Source of      │
│ messages     │       │ truth          │
│              │       │                │
│ TTL          │       │ Visit limits   │
└──────────────┘       │ One-time state │
│ Password hash  │
└────────────────┘


┌──────────────┐
│ Cron Cleanup │
└──────────────┘



📦 Running the Project


Install dependencies:

npm install

Start PostgreSQL and Redis/Memurai.

Then configure:

DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@localhost:5432/link_expiry"
REDIS_URL="redis://localhost:6379"

Generate Prisma Client:

npx prisma generate

Validate the Prisma schema:

npx prisma validate

Start NestJS:

npm run start:dev

The API will be available at:

http://localhost:3000

Swagger:

http://localhost:3000/api/docs



🔮 What Could Come Next?


There are several directions this project could evolve in.

For example:

Authentication
Analytics
Custom expiration
Frontend UI
Admin dashboard
Distributed rate limiting
Redis-based counters
Background workers
Docker deployment
Observability
Metrics
Horizontal scaling

But I intentionally didn't add everything at once.

The goal was to understand the fundamentals first and then introduce infrastructure only when there was a reason for it.



💭 Final Thoughts


What started as a simple:

"Create a message → give me a link → expire it"

turned into a surprisingly good backend learning project.

The interesting part wasn't creating the endpoint.

It was figuring out questions like:

What happens when two users access the message simultaneously?

Where should expiration be enforced?

Should every message be cached?

What happens to expired database records?

How should passwords be stored?

How do we prevent unlimited requests?

How do we make the API easy for others to understand?

Those questions pushed the project beyond a basic CRUD API and helped me understand several backend concepts that I had previously only seen in theory.



🚀 Tech Stack


NestJS
TypeScript
PostgreSQL
Prisma
Redis
Memurai
bcrypt
Swagger
class-validator
@nestjs/schedule
@nestjs/throttler

If you're also learning backend development, I'd highly recommend building something small and then adding complexity only when you can explain why you need it.

That's what made this project much more useful for me than simply following a tutorial.



📌 Source Code


The complete project is available on GitHub:

GitHub link

If you find the project useful or have suggestions for improving the architecture, I'd love to hear your feedback.



👨‍💻 About the Project


Built as a hands-on learning project to understand:

NestJS

API Design

Validation

PostgreSQL

Prisma

Concurrency

Redis

Caching + TTL

Background Cleanup

Rate Limiting

Tags: #nestjs #typescript #postgresql #prisma #redis #backend #webdevelopment #javascript


Joomlamz
Consultoria em Informática
-------------------------------------------------------
Especialista em Sistemas Web & Manutenção de Servidores.
A desenvolver o novo AplPortal com suporte a PHP 8.
Precisa de ajuda profissional? Contacte-me.

Tags: