Hết Part 3, sản phẩm chạy trọn một vòng: đăng nhập, upload, chuyển mã, xem tiến độ, phát lại. Tất cả nằm trong một khối mã và một database.

Phần lớn bài viết về microservice bắt đầu ở đây bằng câu "giờ ta tách ra cho đúng chuẩn". Part này không làm vậy. Nó tách, nhưng trước hết trả lời câu hỏi vì sao, rồi trả lời trung thực câu hỏi mất gì — vì cái mất lớn hơn nhiều so với những gì các sơ đồ kiến trúc thường thừa nhận.

🔍 Hết Part 4 bạn sẽ có gì
Hệ thống tách thành ba dịch vụ nói chuyện qua gRPC, với file .proto là hợp đồng chung. Hiểu chính xác vì sao transaction của Part 1 vỡ khi hai thao tác rơi vào hai dịch vụ, và outbox pattern vá nó thế nào. Một tầng cache có chống dồn toa. Correlation ID xuyên suốt mọi dịch vụ để còn gỡ lỗi được. Và một danh sách những thứ đã cố ý không làm, kèm lý do.

1. Khi nào thì tách — và khi nào thì không

Monolith của ba part vừa rồi không phải bản nháp. Nó là kiến trúc đúng cho tới khi có một lý do cụ thể buộc phải đổi. Dự án này có đúng một lý do như vậy, và nó nằm ở phần chuyển mã.

Nhu cầu tài nguyên của hai loại việc
Xac thuc, danh sach video, phat lai
  - moi request: vai mili giay, gan nhu khong ton CPU
  - can: NHIEU ban nho, phan hoi nhanh
  - luu luong: 10.000 request/phut la binh thuong

Chuyen ma
  - moi job: vai phut, an tron mot nhan CPU
  - can: IT ban nhung MAY MANH, co the co GPU
  - luu luong: 50 job/gio da la nhieu

=> Hai loai nay khong the dung chung mot cach nhan ban.
   Nhan ban de phuc vu HTTP thi thua CPU. Nhan ban de chuyen ma thi
   tra tien cho mot dong may manh ngoi cho request nhe.

Đó là một lý do thật. Còn những lý do sau đây, nghe rất hay nhưng không đủ để đánh đổi: "microservice là chuẩn hiện đại", "để mỗi đội sở hữu một dịch vụ" (khi cả dự án có ba người), "để dễ mở rộng sau này" (khi chưa biết sẽ mở rộng theo chiều nào).

⚠️ Cái giá thật, liệt kê thẳng
Một lời gọi hàm mất vài nano giây và không bao giờ thất bại vì mạng. Cũng lời gọi đó qua gRPC mất vài mili giây và có thể thất bại vì hàng chục lý do mới: mất gói, dịch vụ kia đang khởi động lại, phiên bản proto lệch nhau, timeout.

Ngăn xếp lỗi không còn đi xuyên qua chỗ hỏng nữa. Gỡ lỗi tại chỗ thành gỡ lỗi phân tán. Triển khai một bản thành phối hợp nhiều bản. Và — phần đắt nhất — transaction không còn ôm được cả hai thao tác, đó là mục 4.

Tách khi cái giá đó nhỏ hơn cái lợi cụ thể bạn đo được. Không tách vì nó nghe đúng.

2. Ranh giới đã vạch từ Part 1

Đây là lúc quyết định ở Part 1 — chia thư mục theo miền nghiệp vụ chứ không theo loại file — trả lời cho chính nó. Việc tách không phải viết lại, mà là đổi đường truyền giữa các module đã có.

TRƯỚC — một tiến trình, gọi nhau bằng inject auth media billing một database, một transaction ôm được tất cả lời gọi: vài nano giây, không bao giờ lỗi mạng tách SAU — ba tiến trình, gọi nhau bằng gRPC auth-svc DB riêng nhiều bản nhỏ media-svc DB riêng máy mạnh, ít bản billing-svc DB riêng sổ cái credit Cùng ba cái tên. Không còn transaction nào ôm được cả ba. Code nghiệp vụ gần như không đổi. Thứ đổi là những gì có thể hỏng giữa hai hộp.

Ba dịch vụ, và một API Gateway đứng trước tất cả — chính là ứng dụng NestJS cũ, giờ chỉ còn giữ controller HTTP và chuyển tiếp xuống dưới. Trình duyệt không hề biết có chuyện gì xảy ra.

Cấu trúc kho mã sau khi tách
media-forge/
├── proto/                    # HOP DONG chung, khong thuoc ve rieng ai
│   ├── auth.proto
│   ├── media.proto
│   └── billing.proto
├── apps/
│   ├── gateway/              # HTTP + WebSocket, khong co nghiep vu
│   ├── auth-svc/             # users, refresh_tokens
│   ├── media-svc/            # videos, media_assets, jobs
│   └── billing-svc/          # credit_entries
└── libs/
    └── proto-types/          # kieu TypeScript sinh tu .proto
🔍 Mỗi service tự đăng ký kết nối Postgres riêng của nó
Phần còn lại của bài chỉ nói đến logic nghiệp vụ (controller, entity, outbox) — TypeOrmModule.forRootAsync(...) của media-svcbilling-svc không được nhắc lại, nhưng cả hai vẫn cần đúng một cấu hình đã dựng ở Part 1 mục 7.3: DataSource riêng, và quan trọng nhất là namingStrategy: new SnakeNamingStrategy(). Thiếu dòng đó, mọi câu SQL thô trong BillingService (WHERE e.user_id = :userId) chạm đúng lỗi Postgres thật column e.user_id does not exist — TypeORM mặc định sinh cột theo camelCase (userId), không phải snake_case.

3. gRPC: hợp đồng viết ra được

Ba dịch vụ hoàn toàn có thể gọi nhau bằng HTTP và JSON. gRPC thắng ở chỗ khác: nó bắt bạn viết hợp đồng ra thành file, và sinh kiểu cho cả hai đầu từ file đó.

Với REST, hợp đồng nằm trong tài liệu hoặc trong đầu người viết. Bên gọi tự khai báo một interface, bên trả về đổi tên một trường, và không có gì báo lỗi cho tới lúc chạy. Đây chính xác là vấn đề "hai danh sách trôi khỏi nhau" đã gặp ở Part 1 với schema zod, chỉ khác là lần này hai danh sách nằm ở hai kho mã khác nhau — nên còn dễ trôi hơn.

proto/billing.proto
syntax = "proto3";
package billing;

service BillingService {
  rpc GetBalance (GetBalanceRequest) returns (BalanceReply);
  rpc Charge (ChargeRequest) returns (ChargeReply);
}

message GetBalanceRequest {
  string user_id = 1;
}

message BalanceReply {
  int64 balance = 1;
}

message ChargeRequest {
  string user_id = 1;
  int64 amount  = 2;
  // Khoa binh than: goi lai voi cung job_id thi khong tru them lan nua.
  string job_id = 3;
}

message ChargeReply {
  bool   charged     = 1;
  int64  new_balance = 2;
}
🔍 Những con số 1, 2, 3 quan trọng hơn tên trường
Protobuf mã hoá theo số thứ tự, không theo tên. Đổi user_id thành userId mà giữ nguyên số 1 thì hai bên vẫn hiểu nhau hoàn hảo. Nhưng đổi số 1 thành số 4 là phá vỡ mọi client cũ.

Hệ quả thực tế: không bao giờ dùng lại một số đã bỏ. Xoá một trường thì đánh dấu reserved 3; để không ai vô tình dùng lại số đó cho ý nghĩa khác — nếu không, một dịch vụ cũ chưa kịp triển khai sẽ đọc dữ liệu mới bằng cách hiểu cũ, âm thầm và sai.
Cài và sinh kiểu
npm i @nestjs/microservices @grpc/grpc-js @grpc/proto-loader @nestjs/event-emitter
npm i -D ts-proto

# ts-proto chi la plugin sinh kieu TypeScript cho protoc, khong thay the duoc
# ban than trinh bien dich protoc. npx tu tai mot binary thay the neu may
# chua co, nhung binary do la mot goi npm khong ro nguon goc — cai qua trinh
# quan ly goi he dieu hanh de kiem soat dung phien ban dang dung:
brew install protobuf                    # macOS
sudo apt install -y protobuf-compiler    # Ubuntu/Debian

npx protoc \
  --proto_path=./proto \
  --plugin=./node_modules/.bin/protoc-gen-ts_proto \
  --ts_proto_out=./libs/proto-types \
  --ts_proto_opt=nestJs=true,useOptionals=messages \
  ./proto/*.proto

Lệnh này sinh ra interface TypeScript cho cả hai phía. Bên phục vụ implement đúng interface đó, bên gọi inject đúng interface đó — nên đổi .proto mà quên cập nhật một bên là lỗi biên dịch, không phải lỗi lúc chạy lúc 2 giờ sáng.

Còn một mảnh nữa mà phần sau của bài luôn giả định là đã có: mỗi dịch vụ cần một entry point riêng. Khác với gateway, chúng không mở cổng HTTP nào — createMicroservice thay cho create, và cổng gRPC thay cho cổng web:

apps/billing-svc/src/main.ts — hai service kia đổi tên và số cổng
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { join } from 'node:path';
import { BillingModule } from './billing.module';

async function bootstrap(): Promise<void> {
  // createMicroservice, KHONG phai create(): khong co cong HTTP, khong
  // controller REST — chi mot server gRPC nghe theo hop dong .proto.
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(BillingModule, {
    transport: Transport.GRPC,
    options: {
      package: 'billing', // PHAI khop `package billing;` trong .proto
      protoPath: join(process.cwd(), 'proto/billing.proto'),
      url: process.env['GRPC_URL'] ?? '0.0.0.0:50051',
    },
  });

  await app.listen();
}
void bootstrap();

media-svcauth-svc dùng đúng khuôn đó, chỉ đổi module, đổi package/protoPath, và đổi cổng thành 50052 / 50053 — đúng những con số mà các @Client({ url: 'media-svc:50052' }) ở dưới trỏ tới.

⚠️ Thiếu --proto_path thì file sinh ra nằm sai chỗ
protoc phản chiếu đường dẫn tương đối của input vào cây thư mục output. Bỏ --proto_path=./proto và chỉ truyền ./proto/*.proto, kết quả nằm ở libs/proto-types/proto/billing.ts — không phải libs/proto-types/billing.ts như mọi câu import ... from '@app/proto-types/billing' trong phần còn lại của bài giả định. Thiếu cờ này, mọi import đó báo Cannot find module '@app/proto-types/billing', dù lệnh sinh kiểu chạy exit 0 không báo lỗi gì.
tsconfig.json — path alias cho proto-types
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/proto-types/*": ["libs/proto-types/*"]
    }
  }
}

Alias này cho cả billing-svc lẫn media-svc cùng import chung một bộ kiểu sinh ra ở libs/proto-types, kiểu import type { ChargeRequest } from '@app/proto-types/billing' — không cần đường dẫn tương đối dài dòng giữa hai app nằm ở hai thư mục khác nhau.

apps/billing-svc/src/billing.controller.ts — phía phục vụ
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import type {
  ChargeRequest,
  ChargeReply,
  GetBalanceRequest,
  BalanceReply,
} from '@app/proto-types/billing';

@Controller()
export class BillingGrpcController {
  constructor(private readonly billing: BillingService) {}

  // Ten service va ten method phai khop CHINH XAC voi file .proto.
  @GrpcMethod('BillingService', 'Charge')
  async charge(request: ChargeRequest): Promise<ChargeReply> {
    const result = await this.billing.chargeForJob(
      request.jobId,
      request.userId,
      request.amount,
    );
    return { charged: result.charged, newBalance: result.balance };
  }

  @GrpcMethod('BillingService', 'GetBalance')
  async getBalance(request: GetBalanceRequest): Promise<BalanceReply> {
    const balance = await this.billing.getBalance(request.userId);
    return { balance };
  }
}
apps/media-svc/src/billing.client.ts — phía gọi
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Client, ClientGrpc, Transport } from '@nestjs/microservices';
import { firstValueFrom, timeout } from 'rxjs';
import type { BillingServiceClient } from '@app/proto-types/billing';

@Injectable()
export class BillingClient implements OnModuleInit {
  @Client({
    transport: Transport.GRPC,
    options: { package: 'billing', protoPath: 'proto/billing.proto', url: 'billing-svc:50051' },
  })
  private readonly client!: ClientGrpc;

  private service!: BillingServiceClient;

  onModuleInit(): void {
    this.service = this.client.getService<BillingServiceClient>('BillingService');
  }

  async charge(userId: string, amount: number, jobId: string): Promise<boolean> {
    // gRPC tra ve Observable. KHONG bao gio goi ma khong dat han thoi gian —
    // thieu no thi mot dich vu treo se keo theo ca chuoi goi phia tren.
    const reply = await firstValueFrom(
      this.service.charge({ userId, amount, jobId }).pipe(timeout(3000)),
    );
    return reply.charged;
  }

  async getBalance(userId: string): Promise<number> {
    const reply = await firstValueFrom(
      this.service.getBalance({ userId }).pipe(timeout(3000)),
    );
    return reply.balance;
  }
}

3.1. Streaming và hạn chót

gRPC hỗ trợ luồng theo cả hai chiều, và tiến độ chuyển mã là ví dụ tự nhiên: thay vì media-svc bắn từng thông điệp rời rạc, nó mở một luồng và đẩy liên tục cho tới khi xong.

proto/media.proto — server streaming
service MediaService {
  // Mot request, NHIEU reply chay ve dan.
  rpc WatchJob (WatchJobRequest) returns (stream JobProgress);

  // Worker cua media-svc goi khi chuyen ma xong — day la duong vao that su
  // cua outbox o muc 4.1. Thieu RPC nay thi completeJob() khong ai goi duoc.
  rpc CompleteJob (CompleteJobRequest) returns (CompleteJobReply);
}

message WatchJobRequest {
  string job_id = 1;
}

message JobProgress {
  string job_id  = 1;
  int32  percent = 2;
  string status  = 3;
}
apps/media-svc/src/media.controller.ts
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Observable } from 'rxjs';
import type { WatchJobRequest, JobProgress } from '@app/proto-types/media';

// Ten rieng, KHONG trung voi ProgressEvent co san cua DOM.
interface JobProgressEvent {
  jobId: string;
  percent: number;
  status: string;
}

@Controller()
export class MediaGrpcController {
  constructor(private readonly progress: EventEmitter2) {}

  @GrpcMethod('MediaService', 'WatchJob')
  watchJob(request: WatchJobRequest): Observable<JobProgress> {
    return new Observable<JobProgress>((subscriber) => {
      const handler = (event: JobProgressEvent): void => {
        if (event.jobId !== request.jobId) return;
        subscriber.next({ jobId: event.jobId, percent: event.percent, status: event.status });
        if (event.percent === 100) subscriber.complete();
      };

      this.progress.on('progress', handler);

      // Ham don dep — chay khi ben goi ngat luong hoac het han.
      // Thieu no thi moi ket noi ro ra mot listener.
      return () => this.progress.off('progress', handler);
    });
  }
}

this.progress ở trên là EventEmitter2 — nội bộ một tiến trình, không tự vượt được ranh giới nào cả. Ai gọi .emit('progress', ...)? Worker chuyển mã vẫn publish lên Redis như Part 3, nhưng SHAPE phải đổi. Part 3 báo tiến độ bằng hai tín hiệu tách biệt: kênh 'progress' mang percent chặn cứng ở 99 (mục 4.2), và một kênh 'job:done' riêng — không mang percent lẫn status — báo lúc thật sự xong. Cách đó hợp lý khi đích đến là hai sự kiện WebSocket khác tên. Nhưng rpc WatchJob trả về một luồng JobProgress DUY NHẤT, mọi thông điệp cùng một hình dạng — không có chỗ cho một tín hiệu phụ đi ngoài luồng. Worker phải gộp lại thành một kênh, một hình dạng, đi tới status: 'completed'percent: 100 thay vì tách rời:

Part 3 (hai tín hiệu tách biệt) so với worker của media-svc (một luồng)
// Part 3 — hai kenh, hai hinh dang khac nhau:
await this.redis.publish('progress', JSON.stringify({ userId, jobId, percent })); // percent toi da 99
// ... roi rieng, sau khi that su xong:
await this.redis.publish('job:done', JSON.stringify({ userId, jobId })); // khong percent, khong status

// media-svc — mot kenh, mot hinh dang, di het toi 100:
await this.redis.publish('progress', JSON.stringify({ jobId, percent: 0, status: 'processing' }));
// ... trong luc chay, moi lan ffmpeg bao tien do:
await this.redis.publish('progress', JSON.stringify({ jobId, percent, status: 'processing' }));
// ... xong that (sau khi completeJob() da ghi outbox — muc 4.1):
await this.redis.publish('progress', JSON.stringify({ jobId, percent: 100, status: 'completed' }));

Thiếu mắt nối kế tiếp thì MediaGrpcController ở trên không bao giờ nhận được gì, dù cú pháp biên dịch hoàn toàn đúng:

apps/media-svc/src/progress/progress-bridge.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { Redis } from 'ioredis';
import { RedisService } from '../redis/redis.service';

interface ProgressEvent {
  readonly jobId: string;
  readonly percent: number;
  readonly status: string;
}

// Nguoi lam cau noi. Worker publish len Redis (khong biet gi ve EventEmitter2),
// class nay nghe roi phat lai NOI BO tien trinh — dung vai tro cua
// ProgressSubscriber o Part 3, nhung gio dat trong chinh media-svc.
@Injectable()
export class ProgressBridge implements OnModuleInit {
  private readonly subscriber: Redis;

  constructor(
    private readonly redis: RedisService,
    private readonly emitter: EventEmitter2,
  ) {
    this.subscriber = this.redis.duplicate();
  }

  async onModuleInit(): Promise<void> {
    await this.subscriber.subscribe('progress');
    this.subscriber.on('message', (_channel, raw) => {
      const event = JSON.parse(raw) as ProgressEvent;
      this.emitter.emit('progress', event);
    });
  }
}
⚠️ Hạn chót phải truyền tiếp, không chỉ đặt tại chỗ
Gateway cho request 10 giây. Nó gọi media-svc, media-svc gọi billing-svc. Nếu media-svc đặt hạn 10 giây của riêng nó cho lời gọi xuống billing, tổng cộng có thể thành 20 giây — trong khi gateway đã bỏ cuộc từ giây thứ 10 và người dùng đã thấy lỗi.

Cách đúng là mỗi tầng trừ đi thời gian đã tiêu rồi truyền phần còn lại xuống dưới. gRPC có sẵn khái niệm này gọi là deadline, và nó lan truyền qua metadata — đây chính là ngân sách thời gian của Part 2, giờ đi qua ranh giới tiến trình.

3.2. Auth-svc: cùng logic, khoác vỏ gRPC

Sơ đồ ở mục 2 đã vẽ auth-svc như dịch vụ thứ ba, nhưng chưa cho code nào — ranh giới của nó đơn giản hơn billing-svc/media-svc vì không có gì mới về mặt nghiệp vụ. Toàn bộ logic xoay vòng refresh token, phát hiện dùng lại, khoảng ân hạn đã viết đầy đủ ở Part 2 mục 3. Việc ở đây chỉ là đặt đúng lớp vỏ.

proto/auth.proto
syntax = "proto3";
package auth;

service AuthService {
  rpc Login (LoginRequest) returns (TokenPair);
  rpc Refresh (RefreshRequest) returns (TokenPair);
  rpc Logout (LogoutRequest) returns (Empty);
  rpc LogoutEverywhere (LogoutEverywhereRequest) returns (Empty);
}

message LoginRequest { string email = 1; string password = 2; }
message RefreshRequest { string refresh_token = 1; }
message LogoutRequest { string refresh_token = 1; }
message LogoutEverywhereRequest { string user_id = 1; }
message TokenPair { string access_token = 1; string refresh_token = 2; }
message Empty {}

Bên phục vụ gọi lại đúng AuthService đã có ở Part 2 — constructor 6 tham số, login/refresh/logout/logoutEverywhere giữ nguyên chữ ký. Viết ngây thơ thì để lỗi tự bay lên:

apps/auth-svc/src/auth.controller.ts — bản đầu tiên
@GrpcMethod('AuthService', 'Refresh')
async refresh(request: RefreshRequest): Promise<TokenPair> {
  const pair = await this.auth.refresh(request.refreshToken); // co the nem UnauthorizedException
  return { accessToken: pair.accessToken, refreshToken: pair.refreshToken };
}

Gọi thử với một refresh token không tồn tại, đo bằng client gRPC thô — UnauthorizedException của Part 2 không đi xuyên qua ranh giới gRPC:

Lỗi thật, đo bằng grpc-js thô
code: 2 (UNKNOWN)
details: "Internal server error"
⚠️ NestJS không tự dịch HttpException sang mã trạng thái gRPC
Trong một app HTTP, exception filter của Nest tự đổi UnauthorizedException thành 401. Qua @GrpcMethod, không có filter tương đương nào chạy mặc định — bất kỳ exception nào không phải RpcException đều rơi xuống thành UNKNOWN kèm thông điệp rỗng, xoá sạch lý do thật ("Refresh token không hợp lệ", "Phiên đăng nhập đã bị thu hồi"...). Gateway nhận được cái này thì chỉ còn cách trả 500, dù lỗi thật là 401.

Vá bằng một cặp đối xứng — bên phục vụ bọc lại, bên gọi đọc đúng mã rồi ném lại:

apps/auth-svc/src/auth.controller.ts
import { Controller, UnauthorizedException } from '@nestjs/common';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { status as GrpcStatus } from '@grpc/grpc-js';
import type {
  LoginRequest,
  RefreshRequest,
  LogoutRequest,
  LogoutEverywhereRequest,
  TokenPair,
  Empty,
} from '@app/proto-types/auth';
import { AuthService } from './auth.service';

@Controller()
export class AuthGrpcController {
  constructor(private readonly auth: AuthService) {}

  @GrpcMethod('AuthService', 'Login')
  async login(request: LoginRequest): Promise<TokenPair> {
    const pair = await this.callAuth(() => this.auth.login(request.email, request.password));
    return { accessToken: pair.accessToken, refreshToken: pair.refreshToken };
  }

  @GrpcMethod('AuthService', 'Refresh')
  async refresh(request: RefreshRequest): Promise<TokenPair> {
    const pair = await this.callAuth(() => this.auth.refresh(request.refreshToken));
    return { accessToken: pair.accessToken, refreshToken: pair.refreshToken };
  }

  @GrpcMethod('AuthService', 'Logout')
  async logout(request: LogoutRequest): Promise<Empty> {
    await this.callAuth(() => this.auth.logout(request.refreshToken));
    return {};
  }

  @GrpcMethod('AuthService', 'LogoutEverywhere')
  async logoutEverywhere(request: LogoutEverywhereRequest): Promise<Empty> {
    await this.callAuth(() => this.auth.logoutEverywhere(request.userId));
    return {};
  }

  // NestJS khong tu dich UnauthorizedException sang ma trang thai gRPC —
  // boc lai thanh RpcException de AuthClient (gateway) doc dung ma + thong diep.
  private async callAuth<T>(fn: () => Promise<T>): Promise<T> {
    try {
      return await fn();
    } catch (err) {
      if (err instanceof UnauthorizedException) {
        throw new RpcException({ code: GrpcStatus.UNAUTHENTICATED, message: err.message });
      }
      throw err;
    }
  }
}
apps/gateway/src/auth/auth.client.ts
import { Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import { Client, ClientGrpc, Transport } from '@nestjs/microservices';
import { status as GrpcStatus } from '@grpc/grpc-js';
import { catchError, firstValueFrom, throwError, timeout, type Observable } from 'rxjs';
import type { AuthServiceClient, TokenPair } from '@app/proto-types/auth';

function mapAuthError(err: { code?: number; details?: string; message?: string }): Observable<never> {
  if (err?.code === GrpcStatus.UNAUTHENTICATED) {
    return throwError(() => new UnauthorizedException(err.details ?? err.message));
  }
  return throwError(() => err);
}

@Injectable()
export class AuthClient implements OnModuleInit {
  @Client({
    transport: Transport.GRPC,
    options: { package: 'auth', protoPath: 'proto/auth.proto', url: 'auth-svc:50053' },
  })
  private readonly client!: ClientGrpc;
  private service!: AuthServiceClient;

  onModuleInit(): void {
    this.service = this.client.getService<AuthServiceClient>('AuthService');
  }

  async login(email: string, password: string): Promise<TokenPair> {
    return firstValueFrom(this.service.login({ email, password }).pipe(timeout(3000), catchError(mapAuthError)));
  }

  async refresh(refreshToken: string): Promise<TokenPair> {
    return firstValueFrom(this.service.refresh({ refreshToken }).pipe(timeout(3000), catchError(mapAuthError)));
  }

  async logout(refreshToken: string): Promise<void> {
    await firstValueFrom(this.service.logout({ refreshToken }).pipe(timeout(3000), catchError(mapAuthError)));
  }

  async logoutEverywhere(userId: string): Promise<void> {
    await firstValueFrom(this.service.logoutEverywhere({ userId }).pipe(timeout(3000), catchError(mapAuthError)));
  }
}

Đo lại đúng thất bại vừa gặp: cùng request refresh-token-không-tồn-tại, giờ gateway trả đúng 401 kèm thông điệp thật, không còn 500 rỗng nghĩa.

Phần đáng chú ý nhất nằm ở HTTP controller của gateway — nó gần như là AuthController của Part 2 đem dán lại nguyên văn:

apps/gateway/src/auth/auth.controller.ts
const COOKIE_NAME = 'refresh_token';

@Controller('auth')
export class AuthController {
  constructor(
    private readonly authClient: AuthClient,
    private readonly config: ConfigService<AppConfig, true>,
  ) {}

  @Post('login')
  async login(@Body() dto: LoginDto, @Res({ passthrough: true }) response: Response): Promise<{ accessToken: string }> {
    const pair = await this.authClient.login(dto.email, dto.password);
    this.setRefreshCookie(response, pair.refreshToken);
    return { accessToken: pair.accessToken };
  }

  @Post('refresh')
  async refresh(
    @Req() request: Request,
    @Body() dto: RefreshDto,
    @Res({ passthrough: true }) response: Response,
  ): Promise<{ accessToken: string }> {
    const presented = this.readPresentedToken(request, dto);
    if (presented === undefined) throw new UnauthorizedException('Thieu refresh token');

    const pair = await this.authClient.refresh(presented);
    this.setRefreshCookie(response, pair.refreshToken);
    return { accessToken: pair.accessToken };
  }

  // logout, logout-everywhere, setRefreshCookie, readPresentedToken: xem Part 2 muc 3.5 —
  // tung dong giu nguyen, chi ten thanh vien constructor doi tu "auth" sang "authClient".
}
💡 5 dòng đổi, phần còn lại nguyên vẹn
Diff thật giữa hai bản: đúng 5 dòng code nghiệp vụ khác nhau — tên kiểu tham số constructor, và bốn lời gọi this.auth.xxx(...) đổi thành this.authClient.xxx(...). Toàn bộ setRefreshCookie, readPresentedToken, cấu hình httpOnly/secure/sameSite/ path/maxAge — không đổi một ký tự. Đây chính xác là luận điểm đã nêu ở đầu Part 4: cái đổi là đường truyền, không phải nghiệp vụ.

Kịch bản "đua hai tab" của Part 2 mục 3.3 kiểm lại được nguyên vẹn qua ranh giới gRPC mới: hai request /auth/refresh cùng lúc với cùng refresh token cũ — cả hai đều thành công (một xoay vòng bình thường, một rơi vào nhánh ân hạn), để lại đúng ba dòng trong bảng refresh_tokens: token gốc (đã dùng), và hai token mới sinh ra từ hai nhánh của cuộc đua. Gọi lại token gốc sau 31 giây (ngoài khoảng ân hạn 30s) → 401, và toàn bộ family bị thu hồi thật trong Postgres — 0 dòng còn lại cho family đó. Đúng hành vi đã đo ở phiên rà lỗi đầu tiên của loạt bài (Part 2 mục 3.3), vẫn đúng sau khi đi qua thêm một tầng gRPC.

3.3. Bắc cầu gRPC streaming sang WebSocket

Ở monolith, ProgressGateway (Part 3 mục 7) subscribe trực tiếp vào Redis pub/sub — vì gateway và worker khi đó là cùng một tiến trình, cùng một Redis. Sau khi tách, Redis mà ProgressBridge vừa dùng ở mục 3.1 là hạ tầng RIÊNG của media-svc, không phải thứ dùng chung. Cho gateway tự ý kết nối vào Redis nội bộ của một dịch vụ khác là phá đúng ranh giới vừa dựng ở mục 2 — dịch vụ khác chỉ được phép nói chuyện qua hợp đồng công khai, tức là rpc WatchJob.

apps/gateway/src/progress/media.client.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Client, ClientGrpc, Transport } from '@nestjs/microservices';
import type { Observable } from 'rxjs';
import type { JobProgress, MediaServiceClient } from '@app/proto-types/media';

@Injectable()
export class MediaClient implements OnModuleInit {
  @Client({
    transport: Transport.GRPC,
    options: { package: 'media', protoPath: 'proto/media.proto', url: 'media-svc:50052' },
  })
  private readonly client!: ClientGrpc;
  private service!: MediaServiceClient;

  onModuleInit(): void {
    this.service = this.client.getService<MediaServiceClient>('MediaService');
  }

  // Tra ve Observable, khong await mot lan — day la stream, khong phai mot cau tra loi.
  watchJob(jobId: string): Observable<JobProgress> {
    return this.service.watchJob({ jobId });
  }
}

Khác với monolith (một kết nối Redis, phát cho mọi phòng cùng lúc), gateway giờ mở một luồng gRPC riêng cho mỗi job mà client thật sự hỏi tới — client Socket.io gửi 'job:subscribe' kèm jobId, gateway mới mở watchJob(jobId):

apps/gateway/src/progress/progress.gateway.ts
import {
  ConnectedSocket,
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import type { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import type { Subscription } from 'rxjs';
import type { JwtPayload } from '../auth/jwt-payload';
import { MediaClient } from './media.client';

@WebSocketGateway({ path: '/ws', cors: false })
export class ProgressGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer() private readonly server!: Server;
  private readonly subscriptions = new Map<string, Set<Subscription>>();

  constructor(
    private readonly jwt: JwtService,
    private readonly media: MediaClient,
  ) {}

  async handleConnection(client: Socket): Promise<void> {
    try {
      const token = String(client.handshake.auth['token'] ?? '');
      const payload = await this.jwt.verifyAsync<JwtPayload>(token);
      await client.join(`user:${payload.sub}`);
      client.data.userId = payload.sub;
    } catch {
      client.disconnect(true);
    }
  }

  // Ngat ket noi: huy moi stream gRPC dang mo cho client nay. Thieu buoc nay
  // thi moi lan reconnect lai them mot stream ro ri, khong bao gio dong.
  handleDisconnect(client: Socket): void {
    const subs = this.subscriptions.get(client.id);
    if (subs === undefined) return;
    for (const sub of subs) sub.unsubscribe();
    this.subscriptions.delete(client.id);
  }

  @SubscribeMessage('job:subscribe')
  handleJobSubscribe(@ConnectedSocket() client: Socket, @MessageBody() body: { jobId: string }): void {
    const room = `user:${client.data.userId as string}`;

    const subscription = this.media.watchJob(body.jobId).subscribe({
      next: (progress) => {
        this.server.to(room).emit('job:progress', { jobId: progress.jobId, percent: progress.percent });
        if (progress.status === 'completed') {
          this.server.to(room).emit('job:done', { jobId: progress.jobId });
        }
      },
      error: () => this.forgetSubscription(client.id, subscription),
      complete: () => this.forgetSubscription(client.id, subscription),
    });

    const bucket = this.subscriptions.get(client.id) ?? new Set<Subscription>();
    bucket.add(subscription);
    this.subscriptions.set(client.id, bucket);
  }

  private forgetSubscription(clientId: string, subscription: Subscription): void {
    subscription.unsubscribe();
    this.subscriptions.get(clientId)?.delete(subscription);
  }
}

Phía client cũng phải đổi theo — useJobProgress.ts ở Part 3 chỉ ngồi chờ thụ động, không bao giờ nói cho gateway biết nó quan tâm job nào. Thêm đúng một dòng vào chỗ đã nhận danh sách job đang chạy:

src/hooks/useJobProgress.ts — thêm job:subscribe cho gateway mới
socket.on('connect', () => {
  void api('/jobs/active')
    .then((res) => res.json() as Promise<JobState[]>)
    .then((active) => {
      setJobs(active);
      // Gateway chi mo watchJob() cho job nao client CHU DONG hoi —
      // thieu dong nay thi 'job:progress'/'job:done' khong bao gio toi.
      for (const job of active) {
        if (job.status === 'queued' || job.status === 'processing') {
          socket.emit('job:subscribe', { jobId: job.id });
        }
      }
    });
});
🔍 Đường đi thật, đo hết cả chặng
ffmpeg thật chạy ở worker của media-svc → publish lên Redis nội bộ media-svc (kênh 'progress') → ProgressBridge re-emit qua EventEmitter2rpc WatchJob chảy ra ngoài → MediaClient của gateway nhận → ProgressGateway phát lại qua Socket.io. Đo bằng client Socket.io thật kết nối /ws, nhận đúng chuỗi job:progress: 0%, 96%, 100%, rồi job:done — sáu chặng, một client thật, không chặng nào bị bỏ qua.

4. Chỗ transaction vỡ

Đây là phần quan trọng nhất của Part 4, và nó là hệ quả trực tiếp của Part 1.

Ở monolith, chuyển mã xong thì trừ credit và đánh dấu job hoàn thành nằm gọn trong một transaction. Hoặc cả hai cùng xảy ra, hoặc không cái nào — đó chính là chữ A trong ACID, và ta đã tin vào nó suốt ba part.

Monolith — một transaction ôm cả hai
await this.dataSource.transaction(async (manager) => {
  await manager.update(Job, jobId, { status: 'completed' });
  await manager.insert(CreditEntry, { userId, jobId, delta: -amount });
});
// Chay het hoac khong chay gi. Database bao dam.

Sau khi tách, hai dòng đó nằm ở hai dịch vụ với hai database. Viết ngây thơ thì thành:

Sau khi tách — và ba cách nó hỏng
await this.jobs.update(jobId, { status: 'completed' }); // DB cua media-svc
await this.billingClient.charge(userId, amount, jobId);  // qua mang, DB khac

// Hong 1: charge nem loi -> job "completed" nhung khong ai bi tru tien.
// Hong 2: charge thanh cong, phan hoi mat tren duong ve -> ta tuong that bai,
//         thu lai. Khoa binh than cuu duoc lan nay.
// Hong 3: tien trinh chet giua hai dong -> khong con ai biet buoc 2 con no.

Đảo thứ tự hai dòng không cứu được gì, chỉ đổi loại thiệt hại: trừ tiền xong rồi chết thì người dùng mất credit mà video vẫn ghi là chưa xong. Không có thứ tự nào đúng, vì vấn đề không nằm ở thứ tự — nó nằm ở chỗ không còn ai đảm bảo cả hai cùng xảy ra.

🔍 Vì sao không dùng transaction phân tán
Có một cơ chế đúng về lý thuyết cho chuyện này, gọi là two-phase commit: một bên điều phối hỏi tất cả "sẵn sàng chưa", rồi ra lệnh "commit đi".

Thực tế gần như không ai dùng cho microservice. Nó khoá dữ liệu ở mọi bên trong suốt hai vòng đi về, nên thông lượng sụp. Và nếu bên điều phối chết giữa hai pha, tất cả các bên ngồi giữ khoá chờ một mệnh lệnh không bao giờ tới — chính cái tình huống mà microservice sinh ra để tránh.

Cách được dùng thay thế là chấp nhận nhất quán sau cùng: hai bên có thể lệch nhau trong một khoảng ngắn, nhưng chắc chắn gặp nhau. Outbox là cách rẻ nhất để đạt điều đó.
⚠️ Khoá ngoại cũng vỡ, và nó vỡ trước cả transaction
Mục này nói về transaction, nhưng có một thứ hỏng sớm hơn thế. CreditEntry từ Part 1 mang @ManyToOne(() => User, { onDelete: 'CASCADE' }), tức Postgres tạo một khoá ngoại thật tới bảng users. Sau khi tách, users thuộc auth-svc còn credit_entries thuộc billing-svc — hai database khác nhau, và khoá ngoại thì không đi xuyên qua ranh giới đó được.

Lần gọi Charge đầu tiên sẽ chết với QueryFailedError: insert or update on table "credit_entries" violates foreign key constraint. Cách chữa đúng là bỏ hẳn quan hệ đó ở billing-svc và chỉ giữ lại cột user_id trơn: sau khi tách, tính hợp lệ của user_id là việc của auth-svc, không phải của ràng buộc database. Đây là cái giá thứ hai của việc tách, ít được nhắc hơn nhưng gặp sớm hơn nhiều.

4.1. Outbox pattern

Ý tưởng nằm ở một chỗ rất nhỏ: thay vì gọi mạng bên trong transaction, ta ghi ý định gọi vào chính database đang mở transaction. Ghi ý định và cập nhật job cùng một transaction, nên chúng lại nguyên tử với nhau như cũ. Việc gửi đi thật do một tiến trình khác đọc bảng đó mà làm.

MỘT transaction — DB của media-svc jobs: completed outbox: charge Cùng commit hoặc cùng rollback — như Part 1. đọc Relay tiến trình riêng thử lại tới khi được billing -svc Không có lời gọi mạng nào bên trong transaction. Relay chết → bản ghi vẫn nằm đó, lần chạy sau lấy tiếp. billing-svc sập → thử lại mãi, không mất gì. Đổi lại: có một khoảng thời gian job đã xong mà tiền chưa trừ.
apps/media-svc/src/outbox/outbox.entity.ts
@Entity('outbox')
export class OutboxMessage {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @Column()
  topic!: string; // 'billing.charge'

  @Column({ type: 'jsonb' })
  payload!: Record<string, unknown>;

  // null = chua gui. Co gia tri = da gui xong.
  @Index()
  @Column({ name: 'sent_at', type: 'timestamptz', nullable: true })
  sentAt!: Date | null;

  @Column({ default: 0 })
  attempts!: number;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt!: Date;
}
apps/media-svc/src/job/job.service.ts
async completeJob(jobId: string, userId: string, amount: number): Promise<void> {
  await this.dataSource.transaction(async (manager) => {
    await manager.update(Job, jobId, { status: 'completed' });

    // KHONG goi mang o day. Chi ghi y dinh xuong cung mot database.
    await manager.insert(OutboxMessage, {
      topic: 'billing.charge',
      payload: { userId, amount, jobId },
      sentAt: null,
    });
  });
  // Ra khoi transaction: hoac ca hai dong cung ton tai, hoac khong dong nao.
}

Hàm này cần một đường vào từ bên ngoài, nếu không nó chỉ là code chết. Thêm vào MediaGrpcController ở mục 3.1 — worker gọi đúng RPC CompleteJob vừa khai trong media.proto:

apps/media-svc/src/media.controller.ts — thêm method
@GrpcMethod('MediaService', 'CompleteJob')
async completeJob(request: CompleteJobRequest): Promise<CompleteJobReply> {
  await this.jobs.completeJob(request.jobId, request.userId, Number(request.amount));
  return { ok: true };
}
Phép thử thật — tắt hẳn billing-svc rồi mới hoàn thành job
docker compose stop billing-svc      # dich vu dich CHET han

# Goi CompleteJob qua gRPC (worker se lam dung viec nay)
# -> OK {"ok":true}      <-- van thanh cong, du khong ai tru tien duoc

docker exec ...media-db-1 psql -U media -d media \
  -c "SELECT sent_at IS NULL AS chua_gui, attempts FROM outbox ORDER BY created_at DESC LIMIT 1;"
# -> chua_gui = t, attempts = 7      (relay dang thu lai moi giay)
# -> vai giay sau: attempts = 13, 30, 95 ... van chua gui duoc

docker compose start billing-svc     # dich vu song lai

docker exec ...media-db-1 psql -U media -d media \
  -c "SELECT sent_at IS NOT NULL AS da_gui FROM outbox ORDER BY created_at DESC LIMIT 1;"
# -> da_gui = t

docker exec ...billing-db-1 psql -U billing -d billing \
  -c "SELECT delta, reason, job_id FROM credit_entries ORDER BY created_at DESC LIMIT 1;"
# -> -10 | transcode | 16a4bf70-...

Đây là toàn bộ giá trị của outbox, đo được bằng một lệnh docker compose stop: dịch vụ đích chết hoàn toàn mà CompleteJob vẫn trả về thành công, job vẫn được đánh dấu xong, và khoản trừ tiền không mất đi đâu cả — nó nằm trong bảng outbox đợi, rồi tự đi tiếp khi bên kia sống lại. Viết ngây thơ theo kiểu gọi mạng thẳng trong transaction thì đúng tình huống này là "hỏng 1" ở đầu mục 4: job completed mà không ai bị trừ tiền.

apps/media-svc/src/outbox/outbox.relay.ts
import { Injectable } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
import { OutboxMessage } from './outbox.entity';
import { BillingClient } from '../billing.client';

@Injectable()
export class OutboxRelay {
  constructor(
    private readonly dataSource: DataSource,
    private readonly billingClient: BillingClient,
  ) {}

  @Interval(1000)
  async flush(): Promise<void> {
    await this.dataSource.transaction(async (manager) => {
      const pending = await manager
        .createQueryBuilder(OutboxMessage, 'o')
        .setLock('pessimistic_write')
        .setOnLocked('skip_locked') // relay khac dang giu dong nao thi bo qua dong do
        .where('o.sent_at IS NULL')
        .orderBy('o.created_at', 'ASC')
        .limit(50)
        .getMany();

      for (const message of pending) {
        try {
          await this.dispatch(message);
          await manager.update(OutboxMessage, message.id, { sentAt: new Date() });
        } catch {
          // Khong danh dau da gui. Vong sau lay lai dung ban ghi nay.
          await manager.increment(OutboxMessage, { id: message.id }, 'attempts', 1);
        }
      }
    });
  }

  private async dispatch(message: OutboxMessage): Promise<void> {
    if (message.topic === 'billing.charge') {
      const { userId, amount, jobId } = message.payload as {
        userId: string;
        amount: number;
        jobId: string;
      };
      await this.billingClient.charge(userId, amount, jobId);
      return;
    }
    // Topic la chuoi tu do, khong phai enum — mot ban ghi voi topic sai
    // (do bug hoac do doi migration) khong duoc am tham bo qua.
    throw new Error(`Unknown outbox topic: ${message.topic}`);
  }
}
⚠️ Thiếu ScheduleModule.forRoot() thì @Interval không bao giờ chạy
@Interval(1000) chỉ hoạt động nếu module gốc của media-svc (module chứa OutboxRelay) có ScheduleModule.forRoot() trong mảng imports, và phải import { ScheduleModule } from '@nestjs/schedule' để lấy nó.

Thiếu dòng import đó, flush() không bao giờ được gọi — không có lỗi nào ném ra, ứng dụng khởi động bình thường, log không báo gì bất thường. Message nằm mãi trong bảng outbox với sent_at luôn là null, và chỉ lộ ra khi có người thắc mắc vì sao tiền không bao giờ bị trừ.

skip_locked là thứ cho phép chạy nhiều relay song song mà không cần phối hợp gì: mỗi tiến trình lấy những dòng chưa ai giữ, không tiến trình nào chờ tiến trình nào, và không dòng nào bị xử lý hai lần trong cùng một thời điểm.

💡 Outbox đảm bảo "ít nhất một lần", không phải "đúng một lần"
Relay gửi thành công rồi chết trước khi kịp ghi sent_at. Vòng sau nó gửi lại — billing-svc nhận cùng một lệnh trừ tiền hai lần.

Không có cách nào loại bỏ hoàn toàn khả năng này, và đó không phải khiếm khuyết của outbox mà là bản chất của việc truyền tin qua mạng. Cách sống chung là bên nhận phải bình thản — và nó đã bình thản từ Part 3: ràng buộc duy nhất trên job_id cộng với ON CONFLICT DO NOTHING. Lệnh trừ tiền thứ hai vào tới nơi, chạm ràng buộc, không làm gì cả.

Đây là lý do idempotency và outbox luôn đi thành cặp. Có outbox mà bên nhận không bình thản thì bạn đã đổi lỗi "mất thông điệp" lấy lỗi "trừ tiền hai lần" — không khá hơn chút nào.

4.2. Dựng cả cụm lên

Mọi @Client({ url: 'billing-svc:50051' }) ở trên chỉ phân giải được nhờ đúng một thứ: tên dịch vụ trong Compose. Đây cũng chính là lý do mục 7 nói không cần service discovery — nhưng cái file làm điều đó thành sự thật thì phải có mặt.

docker-compose.yml — rút gọn, mỗi service một database riêng
services:
  billing-db: { image: postgres:17-alpine, environment: { POSTGRES_USER: billing, POSTGRES_PASSWORD: billing, POSTGRES_DB: billing } }
  media-db:   { image: postgres:17-alpine, environment: { POSTGRES_USER: media,   POSTGRES_PASSWORD: media,   POSTGRES_DB: media } }
  auth-db:    { image: postgres:17-alpine, environment: { POSTGRES_USER: auth,    POSTGRES_PASSWORD: auth,    POSTGRES_DB: auth } }
  redis:      { image: redis:7-alpine }

  billing-svc:
    build: .
    command: ['node', '-r', 'ts-node/register', '-r', 'tsconfig-paths/register', 'apps/billing-svc/src/main.ts']
    environment:
      DATABASE_URL: postgres://billing:billing@billing-db:5432/billing
      GRPC_URL: '0.0.0.0:50051'
    depends_on: { billing-db: { condition: service_healthy } }

  media-svc:
    build: .
    command: ['node', '-r', 'ts-node/register', '-r', 'tsconfig-paths/register', 'apps/media-svc/src/main.ts']
    environment:
      DATABASE_URL: postgres://media:media@media-db:5432/media
      BILLING_GRPC_URL: 'billing-svc:50051'   # ten dich vu = ten host
      GRPC_URL: '0.0.0.0:50052'
    depends_on: { media-db: { condition: service_healthy } }

  auth-svc:
    build: .
    command: ['node', '-r', 'ts-node/register', '-r', 'tsconfig-paths/register', 'apps/auth-svc/src/main.ts']
    environment:
      DATABASE_URL: postgres://auth:auth@auth-db:5432/auth
      JWT_SECRET: ${JWT_SECRET}    # PHAI giong het gateway
      GRPC_URL: '0.0.0.0:50053'
    depends_on: { auth-db: { condition: service_healthy } }

  gateway:
    build: .
    command: ['node', '-r', 'ts-node/register', '-r', 'tsconfig-paths/register', 'apps/gateway/src/main.ts']
    environment:
      JWT_SECRET: ${JWT_SECRET}    # PHAI giong het auth-svc
      PORT: '3000'
    ports:
      - '3000:3000'                # dich vu DUY NHAT mo cong ra ngoai
    depends_on: [auth-svc, media-svc]
📝 Chỉ gateway mở cổng ra ngoài
Ba dịch vụ gRPC không cần khối ports: nào cả — chúng chỉ nói chuyện với nhau trong mạng nội bộ của Compose, và đó là điều đúng: gRPC ở đây không có TLS lẫn xác thực ở tầng vận chuyển. Ánh xạ chúng ra ngoài chỉ nên làm tạm lúc gỡ lỗi.

JWT_SECRET phải giống hệt giữa auth-svc và gateway: auth-svc ký token, gateway tự xác thực chữ ký mà không gọi ngược lại (mục 3.2). Lệch nhau thì mọi request có token đều trả 401 và không có gì trong log chỉ ra lý do.
Terminal — đăng nhập đi xuyên qua cả hai tầng
export JWT_SECRET=$(openssl rand -hex 32)
docker compose up -d --build

curl -s -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"demo@test.local","password":"matkhau123"}'
# -> {"accessToken":"eyJhbGciOi..."}  + Set-Cookie: refresh_token=...

curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"demo@test.local","password":"saibetbet"}'
# -> 401   (GrpcStatus.UNAUTHENTICATED -> UnauthorizedException, muc 3.2)

Request đó đi qua đúng hai tầng: HTTP tới gateway, rồi gRPC xuống auth-svc, rồi ngược lại. Trình duyệt không biết gì về chuyện đó — đúng lời hứa ở mục 2. Và mã 401 ở lệnh thứ hai là bằng chứng cụ thể rằng mapAuthError của mục 3.2 làm đúng việc: một mã lỗi gRPC đã được dịch lại thành một mã lỗi HTTP đúng nghĩa, thay vì rơi ra thành 500.

5. Cache: nhanh hơn, và cái bẫy dồn toa

Mỗi lần media-svc trả về danh sách video, nó gọi sang billing-svc hỏi số dư. Số dư thay đổi rất ít nhưng bị hỏi rất nhiều — đúng dạng dữ liệu nên cache.

Cache-aside — bản cơ bản
async getBalance(userId: string): Promise<number> {
  const key = `balance:${userId}`;
  const cached = await this.redis.get(key);
  if (cached !== null) return Number(cached);

  const balance = await this.billingClient.getBalance(userId);
  await this.redis.set(key, String(balance), 'EX', 60);
  return balance;
}

Bản này chạy tốt cho tới một buổi tối đông người. Khoá hết hạn đúng lúc có 500 request đang cần nó: cả 500 cùng thấy cache rỗng, cả 500 cùng gọi sang billing-svc. Một dịch vụ đang phục vụ 0 request/giây bỗng nhận 500 request trong cùng một mili giây, và nó sập — không phải vì tải thật cao, mà vì tải dồn thành một cục.

apps/media-svc/src/cache/balance.cache.ts
import { Injectable } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
import { BillingClient } from '../billing.client';

@Injectable()
export class BalanceCache {
  constructor(
    private readonly redis: RedisService,
    private readonly billingClient: BillingClient,
  ) {}

  private readonly inFlight = new Map<string, Promise<number>>();

  async getBalance(userId: string): Promise<number> {
    const key = `balance:${userId}`;

    const cached = await this.redis.get(key);
    if (cached !== null) return Number(cached);

    // Trong MOT tien trinh: moi nguoi cho chung mot lan goi.
    // Cung y tuong voi bien `refreshing` cua Part 2.
    const existing = this.inFlight.get(key);
    if (existing !== undefined) return existing;

    const promise = this.loadAndStore(key, userId).finally(() => {
      this.inFlight.delete(key);
    });
    this.inFlight.set(key, promise);
    return promise;
  }

  private async loadAndStore(key: string, userId: string): Promise<number> {
    const balance = await this.billingClient.getBalance(userId);

    // Han dung ngau nhien 60-75s. Neu tat ca cung dat 60s thi cac khoa
    // tao ra trong cung mot dot se cung het han trong cung mot dot.
    const ttl = 60 + Math.floor(Math.random() * 15);
    await this.redis.set(key, String(balance), 'EX', ttl);
    return balance;
  }
}

Hai kỹ thuật, hai tầng khác nhau. inFlight gộp các request trong cùng một tiến trình; hạn dùng ngẫu nhiên tránh việc nhiều khoá được tạo cùng lúc rồi cũng hết hạn cùng lúc. Muốn chặt hơn nữa — gộp cả giữa các tiến trình — thì cần một khoá trong Redis: ai lấy được khoá thì đi nạp, những người còn lại chờ một nhịp ngắn rồi đọc lại cache.

⚠️ Xoá cache khi số dư đổi, đừng chờ hết hạn
Người dùng nạp tiền xong, mở trang, và thấy số dư cũ trong tối đa 60 giây. Họ sẽ nạp lần nữa.

billing-svc phải phát một sự kiện sau mỗi lần số dư đổi, và các dịch vụ có cache thì xoá khoá tương ứng. Với dữ liệu người dùng nhìn thấy và tự tay thay đổi, thời gian hết hạn là lưới an toàn — không phải cơ chế cập nhật chính.

Còn thiếu đường vào: BalanceCache mới chỉ là một provider chưa ai gọi, và media.proto chưa có RPC nào trả số dư. Thêm một RPC nữa, đi qua cache chứ không gọi thẳng billing-svc:

proto/media.proto + media.controller.ts — thêm đường vào cho cache
// proto/media.proto
rpc GetBalance (GetBalanceRequest) returns (BalanceReply);

// apps/media-svc/src/media.controller.ts
@GrpcMethod('MediaService', 'GetBalance')
async getBalance(request: GetBalanceRequest): Promise<BalanceReply> {
  return { balance: await this.balances.getBalance(request.userId) };
}
Terminal — 50 request đồng thời vào một khoá vừa bị xoá
# Xoa khoa de cache LANH, roi ban 50 loi goi GetBalance cung luc,
# vua chay vua theo doi Redis:
docker exec ...redis-1 redis-cli DEL "balance:$USER_ID"
docker exec ...redis-1 redis-cli MONITOR &

# ...ban 50 request...

# Dem lenh cham vao khoa do:
# GET : 50      <-- ca 50 deu kiem cache
# SET : 1       <-- nhung CHI MOT lan thuc su goi sang billing-svc
#
# va dong SET do:
# "set" "balance:961EC050-..." "-10" "EX" "63"
#                                          ^^ khong phai 60

Hai con số đó là toàn bộ mục này, đo được: 50 lần đọc cache, đúng 1 lần nạp — 49 request kia được inFlight gộp vào cùng một lời hứa thay vì mỗi đứa một lời gọi mạng. Và hạn dùng ghi ra là 63 chứ không phải 60, tức phần ngẫu nhiên 60 + random(15) đang thật sự hoạt động — nếu mọi khoá đều đúng 60 thì cả đám tạo cùng một đợt sẽ lại cùng hết hạn một đợt, và ta quay về đúng chỗ vừa tránh.

6. Gỡ lỗi khi lỗi nằm ở đâu đó

Trong monolith, một ngăn xếp lỗi chỉ thẳng vào dòng gây ra vấn đề. Sau khi tách, request đi qua bốn tiến trình và bạn có bốn tập nhật ký không liên quan gì tới nhau. Đây là cái giá đã hứa ở mục 1, và nó có thật.

Thứ tối thiểu phải có là correlation ID: một mã sinh ra ở cửa vào, đi theo request qua mọi tầng, và xuất hiện trong mọi dòng nhật ký.

apps/gateway/src/common/correlation.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
// PHAI lay tu 'express'. Thieu dong nay thi TypeScript im lang dung
// Request/Response cua DOM, va bao loi o cho khac han: "Headers has no
// index signature" va "Property 'setHeader' does not exist".
import type { Request, Response, NextFunction } from 'express';

export const requestContext = new AsyncLocalStorage<{ correlationId: string }>();

@Injectable()
export class CorrelationMiddleware implements NestMiddleware {
  use(request: Request, response: Response, next: NextFunction): void {
    const correlationId = String(request.headers['x-correlation-id'] ?? randomUUID());
    response.setHeader('X-Correlation-Id', correlationId);

    // AsyncLocalStorage giu gia tri nay xuyen suot moi await phia sau,
    // ma khong phai truyen tay qua tung ham.
    requestContext.run({ correlationId }, () => next());
  }
}
apps/gateway/src/common/correlation.metadata.ts
import { Metadata } from '@grpc/grpc-js';
import { requestContext } from './correlation.middleware';

// Dinh correlation id vao metadata cua moi loi goi gRPC di ra. Moi method
// sinh tu ts-proto deu nhan Metadata lam THAM SO THU HAI:
//   this.service.charge({ userId, amount, jobId }, correlationMetadata())
export function correlationMetadata(): Metadata {
  const metadata = new Metadata();
  metadata.set('x-correlation-id', requestContext.getStore()?.correlationId ?? 'unknown');
  return metadata;
}
⚠️ Vì sao đây không phải một NestInterceptor
Cách trông tự nhiên hơn là viết một interceptor toàn cục rồi gọi context.switchToRpc().getContext().add(metadata). Nó biên dịch hoàn toàn sạch — vì Nest khai getContext() trả về any — nhưng gateway là ứng dụng HTTP, nên lúc chạy getContext() trả về đối tượng request của Express, thứ không hề có method add().

Bật nó lên rồi gọi thử: mọi request trả 500 kèm TypeError: context.switchToRpc(...).getContext(...).add is not a function. Metadata của lời gọi gRPC đi ra phải được gắn ở chỗ gọi client, không phải ở tầng xử lý request HTTP đi vào — hai chiều khác nhau, và chỉ tầng đầu mới thấy được ngữ cảnh RPC.

Bên nhận đọc metadata đó và mở AsyncLocalStorage của chính nó. Kết quả là một mã duy nhất chạy suốt từ trình duyệt xuống tận worker. Khi có người báo lỗi, bạn tìm theo mã đó và thấy đủ hành trình thay vì bốn mảnh rời.

Nhật ký sau khi có correlation ID
gateway     [8f3a...] POST /media/abc/transcode -> 202 (12ms)
media-svc   [8f3a...] job d41 queued
media-svc   [8f3a...] job d41 processing 0%
media-svc   [8f3a...] job d41 completed (194s)
media-svc   [8f3a...] outbox billing.charge queued
billing-svc [8f3a...] charge user=42 amount=10 job=d41 -> ok
💡 Ba thứ đo, đừng đo mọi thứ
Độ trễ theo phân vị, không phải trung bình. Trung bình 80 ms nghe rất ổn trong khi phân vị 99 là 4 giây — tức là cứ 100 người thì một người đợi 4 giây, và họ là những người sẽ viết đánh giá xấu. Trung bình giấu đi đúng cái cần thấy.

Độ sâu hàng đợi. Nó tăng đều nghĩa là worker không theo kịp — dấu hiệu này xuất hiện trước khi người dùng kịp phàn nàn.

Tỷ lệ lỗi theo từng lời gọi giữa các dịch vụ, không gộp chung. Gộp lại thì một dịch vụ hỏng hoàn toàn có thể chỉ làm tỷ lệ chung nhích lên 2% và không ai để ý.

7. Những thứ cố ý không làm

Danh sách này quan trọng ngang phần đã làm, vì mỗi mục đều là thứ hay bị thêm vào quá sớm.

Đã cân nhắc và bỏ qua
Service discovery
  Ba dich vu voi ten co dinh trong docker-compose la du.
  Can khi so ban thay doi lien tuc va dia chi khong doan truoc duoc.

Circuit breaker
  Deadline + thu lai da xu ly phan lon truong hop. Ngat mach chi dang
  gia khi mot dich vu hong LAU va viec cu goi vao no lam hong ca ben goi.

Service mesh
  Them mot lop ha tang nua de van hanh. Voi ba dich vu, chi phi hoc va
  van hanh lon hon loi ich.

Event sourcing
  So cai credit da la append-only — do la phan duy nhat can lich su day du.
  Ap cho toan he thong la doi mot van de de gap lay mot van de kho hon.

Mẫu số chung: mỗi thứ trên giải quyết một vấn đề có thật, nhưng là vấn đề của quy mô mà dự án này chưa chạm tới. Thêm chúng bây giờ là trả chi phí vận hành ngay hôm nay cho một lợi ích của năm sau — mà năm sau thì bài toán có thể đã khác.

8. Nhìn lại cả bốn part

Bắt đầu từ một thư mục rỗng, giờ có một hệ thống nhận video, chuyển mã, tính tiền, đẩy tiến độ theo thời gian thực và phát lại có tua được — chạy trên nhiều tiến trình, nhiều dịch vụ, nhiều database.

Nhưng thứ đáng mang theo không phải danh sách công nghệ. Đọc lại bốn part sẽ thấy cùng một bài toán quay lại năm lần, mỗi lần khoác một cái áo khác:

Cùng một bài toán, năm lần
Part 1  Hai request cung tru tien mot tai khoan
        -> SELECT FOR UPDATE

Part 2  Hai tab cung xoay mot refresh token
        -> khoa dong + khoang an han
        Nhieu request cung nhan 401 va cung doi refresh
        -> gop vao MOT promise

Part 3  Mot thong diep bi xu ly hai lan sau khi worker chet
        -> unique index + ON CONFLICT DO NOTHING

Part 4  Mot lenh tru tien duoc gui hai lan tu outbox
        -> van la unique index do
        500 request cung nap lai mot khoa cache vua het han
        -> gop vao MOT promise + han dung ngau nhien

Ba loai loi giai, dung cho ca nam truong hop:
  1. Khoa lai        — cho xep hang
  2. Lam nguyen tu   — de khong co khe ho o giua
  3. Gop thanh mot   — de chi co mot ban chay that

Công nghệ trong bài sẽ cũ đi. NestJS sẽ lên phiên bản mới, gRPC có thể bị thay bằng thứ khác, ffmpeg sẽ có cờ mới. Ba dòng cuối của bảng trên thì không — chúng đúng từ trước khi có Node và sẽ còn đúng sau đó.

Nếu chỉ giữ lại một câu từ cả loạt bài này, hãy giữ câu ở Part 1: đoạn code trừ tiền đầu tiên không có lỗi cú pháp, không có lỗi logic, và đúng với mọi bài test chạy tuần tự. Nó chỉ sai khi có hai người bấm cùng lúc — mà đó là điều kiện mặc định của mọi hệ thống có người dùng thật.

🔍 Muốn đi tiếp thì đi đâu
Loạt bài này dừng ở chỗ hệ thống chạy đúng. Ba hướng tự nhiên để đi tiếp:

Phát video kiểu thích ứng — cắt thành HLS hoặc DASH để trình duyệt tự đổi chất lượng theo tốc độ mạng, thay vì tải một file duy nhất.

Lưu trữ đối tượng — chuyển từ đĩa cục bộ sang S3 hoặc tương đương, và khi đó phần X-Accel-Redirect ở Part 2 đổi thành URL ký sẵn của nhà cung cấp.

Kiểm thử tải — dựng kịch bản đủ nặng để tự tay thấy từng nút thắt trong bài này lộ ra theo đúng thứ tự đã mô tả. Đọc về chúng là một chuyện; nhìn thanh phân vị 99 dựng đứng lên trên màn hình của chính mình là chuyện khác.

Các phần trong loạt bài

Part 1: Nền móng, thiết kế CSDL & ACID Part 2: Xác thực, nginx, rate limit & streaming Part 3: child_process, worker_threads, cluster & realtime Quay lại Blog

Bình luận