معماری Hexagonal در NestJS

Hexagonal architecture in NestJS

معماری شش‌ضلعی (Hexagonal Architecture) که بعضی وقت‌ها با نام Ports and Adapters شناخته می‌شه، یکی از الگوهای مدرن برای طراحی نرم‌افزار های ماژولار و تست‌ پذیره. هدفش اینه که منطق اصلی کسب‌ و کار (Domain Logic) از وابستگی‌های خارجی مثل دیتابیس، وب‌سرور، پیام‌رسان و غیره جدا بشه.

در دنیای واقعی یه پروژه شاید از دیتابیس PostgreSQL، وب‌فریمورک NestJS و فریمورک تست Jest  استفاده کنه. اما Hexagonal اجازه می‌ده اگر فردا تصمیم گرفتیم دیتابیس رو به MongoDB یا تست‌ ها رو با Vitest بنویسیم، به‌راحتی این بخش‌ها رو جایگزین کنیم بدون اینکه به منطق کسب‌ و کار دست بخوره.

مزیت‌ها برای کسب‌وکار:

  1. افزایش عمر پروژه: تغییر تکنولوژی زیرساخت بدون بازنویسی کل سیستم.
  2. قابلیت تست بالا: منطق کسب‌ و کار می‌تونه جداگانه تست بشه، حتی بدون ساخت پایگاه‌ داده.
  3. بحث‌ پذیری بهتر بین تیم‌ها: تیم‌های فنی، مدیریت و تحلیل، هرکدوم با بخش خودشون کار می‌کنن، بدون تداخل.

در پروژه‌های متوسط تا بزرگ، مخصوصاً اون‌هایی که قرارِ سال‌ها توسعه پیدا کنن، Hexagonal به ما یه ستون فقرات تمیز و ساختار یافته می‌ده. این معماری تضمین می‌کنه که هسته اصلی محصول شما، یعنی منطق کسب‌ و کار، در برابر نوسانات تکنولوژیک مقاوم شود. این رویکرد در مقابل معماری‌ های سنتی که اغلب وابستگی‌ های زیادی به فریم‌ورک (مثل تزریق مستقیم سرویس‌های NestJS به Domain) دارند، یک مزیت استراتژیک محسوب می‌شود.

 

ساختار کلی در NestJS

معماری شش‌ ضلعی (Hexagonal) بر اساس جداسازی دغدغه‌ها (Separation of Concerns) بنا شده است. لایه اصلی آن، هسته (Core) است که شامل منطق کسب‌ و کار (Domain) و سرویس‌های کاربردی (Application Services/Use Cases) می‌شود. دنیای بیرون (UI، دیتابیس، سیستم‌های پیام‌رسانی) از طریق رابط‌ هایی به نام پورت‌ها (Ports) با این هسته ارتباط برقرار می‌کنند. این ارتباط توسط آداپتورها (Adapters) برقرار می‌شود.

در NestJS، ساختار پوشه‌بندی ما معمولاً به این شکل خواهد بود:

📦 src
 ┣ 📂 domain          ← منطق اصلی کسب‌ و کار، موجودیت‌ ها، و پورت‌ ها (تعریف قراردادها)
 ┣ 📂 application     ← Use Cases (سناریوهای کاربردی) و مدل‌های DTO داخلی
 ┗ 📂 infrastructure  ← پیاده‌سازی پورت‌ها (آداپتورها): دیتابیس، وب، پیام‌رسان‌ها

نکته کلیدی این است که لایه‌های بیرونی (Infrastructure) نباید هیچ ارجاع مستقیمی به لایه‌های درونی (Domain) داشته باشند، بلکه باید پورت‌ های تعریف‌ شده توسط لایه Domain را پیاده‌ سازی کنند.
 

اجزای اصلی در Hexagonal
 

۱. لایه Domain (هسته کسب‌ و کار)

این لایه باید کاملاً مستقل از NestJS، TypeORM، یا هر فریم‌ ورک دیگری باشد. شامل:

  • Entities (موجودیت‌ها): اشیایی که وضعیت و رفتار کسب‌ و کار را تعریف می‌کنند.
  • Value Objects: اشیایی که یک مفهوم خاص از کسب‌ و کار را مدل می‌کنند (مثلاً آدرس، بازه زمانی).
  • Domain Services: منطق‌هایی که به هیچ موجودیت خاصی تعلق ندارند.
  • Ports (قراردادها): این‌ها اینترفیس‌هایی هستند که نشان می‌دهند Domain چه نیازی دارد (مانند ذخیره‌سازی یا دریافت داده).

۲. لایه Application (منطق کاربردی)

این لایه مسئول هماهنگی اجرای Use Case ها است. Use Case ها سناریو های خاصی از کسب‌ و کار را پیاده‌سازی می‌کنند (مثلاً  "ایجاد یک کاربر جدید" یا "پردازش سفارش"). آن‌ها از پورت‌های Domain برای تعامل با دنیای بیرون استفاده می‌کنند.

۳. لایه Infrastructure (زیرساخت / آداپتورها)

این لایه جایی است که وابستگی‌های تکنولوژیکی واقعی زندگی می‌کنند. آداپتورها پورت‌ های تعریف‌ شده در Domain را پیاده‌سازی می‌کنند.

  • Primary Adapters (Drivers): این آداپتورها تعاملات خارجی را آغاز می‌کنند (مثل Controllerها، Gatewayها، یا صف‌های پیام). آن‌ها با فراخوانی Use Caseها، سیستم را به حرکت در می‌آورند.
  • Secondary Adapters (Driven): این آداپتورها به درخواست‌های Domain پاسخ می‌دهند (مثل Repositoryهای دیتابیس، کلاینت‌های HTTP خارجی). آن‌ها پورت‌های Repository را پیاده‌ سازی می‌کنند.

 

ایجاد یک مثال واقعی: مدیریت ورزشکاران

فرض کن پروژه‌ی ما مربوط به ورزشکار هاست، و داریم سرویس ایجاد ورزشکار جدید رو طراحی می‌کنیم.

۱. تعریف موجودیت Domain:

این کلاس نقطه صفر ماست، هیچ وابستگی خارجی ندارد.

// src/domain/entities/athlete.entity.ts
export class Athlete {
  constructor(
    public readonly id: string,
    public name: string,
    public age: number,
  ) {}

  // متدهای رفتاری می‌توانند اینجا اضافه شوند
  public celebrateBirthday(): void {
    this.age += 1;
  }
}

۲. تعریف پورت (Interface) در لایه Domain:

این پورت یک قرارداد برای ذخیره‌ سازی است. توجه کنید که این پورت از Athlete.entity استفاده می‌کند، اما هیچ چیزی از Infrastructure نمی‌داند.

// src/domain/ports/athlete.repository.port.ts
import { Athlete } from '../entities/athlete.entity';

// این پورت یک قرارداد است (Port)
export interface AthleteRepositoryPort {
  save(athlete: Athlete): Promise<void>;
  findById(id: string): Promise<Athlete | null>;
}

۳. آداپتور دیتابیس (Infrastructure Layer)

این آداپتور مسئول اجرای پورت است و وابستگی واقعی (TypeORM) را معرفی می‌کند.

// src/infrastructure/repositories/athlete.repository.ts
import { Injectable } from '@nestjs/common';
import { AthleteRepositoryPort } from '../../domain/ports/athlete.repository.port';
import { Athlete } from '../../domain/entities/athlete.entity';
import { Repository } from 'typeorm';
import { AthleteEntity } from './athlete.orm.entity'; // ORM Specific Entity
import { InjectRepository } from '@nestjs/typeorm';

// این یک آداپتور (Adapter) است که پورت را پیاده‌سازی می‌کند
@Injectable()
export class AthleteRepositoryAdapter implements AthleteRepositoryPort {
  constructor(
    @InjectRepository(AthleteEntity)
    private readonly ormRepo: Repository<AthleteEntity>,
  ) {}

  async save(athlete: Athlete): Promise<void> {
    // تبدیل Domain Entity به ORM Entity و ذخیره
    const entity = this.ormRepo.create({ id: athlete.id, name: athlete.name, age: athlete.age });
    await this.ormRepo.save(entity);
  }

  async findById(id: string): Promise<Athlete | null> {
    const entity = await this.ormRepo.findOne({ where: { id } });
    if (!entity) return null;
    // تبدیل ORM Entity به Domain Entity
    return new Athlete(entity.id, entity.name, entity.age);
  }
}

۴. یوزکیس در لایه Application

اینجا منطق اجرای سناریو قرار دارد. Use Case از AthleteRepositoryPort استفاده می‌کند، نه از AthleteRepositoryAdapter.

// src/application/use-cases/create-athlete.usecase.ts
import { Injectable } from '@nestjs/common';
import { AthleteRepositoryPort } from '../../domain/ports/athlete.repository.port';
import { Athlete } from '../../domain/entities/athlete.entity';
import { v4 as uuidv4 } from 'uuid';

// این سرویس Application است
@Injectable()
export class CreateAthleteUseCase {
  // تزریق پورت، نه پیاده‌سازی
  constructor(private readonly repo: AthleteRepositoryPort) {}

  async execute(name: string, age: number): Promise<void> {
    if (age < 15) {
      throw new Error('Athlete must be at least 15 years old.');
    }
    const athlete = new Athlete(uuidv4(), name, age);
    await this.repo.save(athlete);
  }
}

۵. تزریق وابستگی در ماژول NestJS (Wiring Up)

NestJS نقش سیم‌کشی (Glue) بین پورت و آداپتور را ایفا می‌کند. ما به Nest می‌گوییم: "هر زمان کسی AthleteRepositoryPort را درخواست کرد، این AthleteRepositoryAdapter را به او بده."

// src/athlete/athlete.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AthleteEntity } from '../infrastructure/repositories/athlete.orm.entity';
import { AthleteRepositoryAdapter } from '../infrastructure/repositories/athlete.repository';
import { CreateAthleteUseCase } from '../application/use-cases/create-athlete.usecase';
import { AthleteController } from '../interface/rest/athlete.controller';

@Module({
  imports: [TypeOrmModule.forFeature([AthleteEntity])],
  controllers: [AthleteController],
  providers: [
    // 1. تعریف آداپتور به عنوان یک سرویس
    AthleteRepositoryAdapter, 
    
    // 2. سیم‌کشی: ارائه توکن پورت و اتصال آن به آداپتور
    { 
        provide: 'AthleteRepositoryPort', 
        useExisting: AthleteRepositoryAdapter 
    }, 
    
    // 3. تزریق Use Case (که پورت را می‌خواهد)
    CreateAthleteUseCase
  ],
  exports: [CreateAthleteUseCase],
})
export class AthleteModule {}

در این تنظیمات، CreateAthleteUseCase فقط توکن 'AthleteRepositoryPort' را می‌شناسد. در زمان اجرا، NestJS به‌صورت خودکار آداپتور PostgreSQL ما را به آن تزریق می‌کند.

۶. استفاده در Controller (Primary Adapter)

Controller یکی از Primary Adapters هاست در اینجا (Adapter REST/HTTP). این لایه تنها وظیفه دریافت درخواست HTTP، اعتبارسنجی اولیه، و فراخوانی Use Case را دارد.

// src/interface/rest/athlete.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { CreateAthleteUseCase } from '../../application/use-cases/create-athlete.usecase';

@Controller('athletes')
export class AthleteController {
  constructor(private readonly createAthlete: CreateAthleteUseCase) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  async create(@Body('name') name: string, @Body('age') age: number) {
    // Controller مستقیماً Use Case را صدا می‌زند
    await this.createAthlete.execute(name, age);
    return { message: 'Athlete created successfully' };
  }
}

جداسازی و تعویض‌پذیری (Interchangeability)

قدرت واقعی Hexagonal زمانی مشخص می‌شود که بخواهیم تکنولوژی را عوض کنیم.

سناریوی تعویض دیتابیس به MongoDB:

  1. ایجاد آداپتور جدید: یک فایل MongoAthleteRepositoryAdapter.ts می‌سازیم که دقیقاً AthleteRepositoryPort را پیاده‌سازی می‌کند و از کتابخانه‌های MongoDB استفاده می‌کند.
  2. تغییر در ماژول: در athlete.module.ts، آداپتور قدیمی را حذف و آداپتور MongoDB را اضافه می‌کنیم، و در بخش provide، نام آداپتور را تغییر می‌دهیم:
providers: [
    MongoAthleteRepositoryAdapter, // به‌جای قبلی
    { 
        provide: 'AthleteRepositoryPort', 
        useExisting: MongoAthleteRepositoryAdapter 
    }, 
    CreateAthleteUseCase
    // ...
],

نکته حیاتی: CreateAthleteUseCase و Athlete.entity بدون کوچکترین تغییری باقی می‌مانند، زیرا آن‌ها فقط قرارداد (پورت) را می‌شناسند، نه پیاده‌سازی MongoDB یا PostgreSQL را.

نقش تکنولوژی‌های مختلف در Hexagonal

جزء معماریمثال در NestJSمسئولیت اصلیDomainAthlete Entityقوانین کسب‌وکار اصلی.Port (Driving)Interfaceهای تعریف شده در Domain برای دریافت ورودی (مثلاً AthleteQueryPort).قراردادهایی که لایه بیرونی باید اجرا کند (کمتر رایج در NestJS سنتی).Port (Driven)AthleteRepositoryPortقراردادهایی که Domain از Infrastructure انتظار دارد (ذخیره‌سازی، یافتن).Application Service (Use Case)CreateAthleteUseCaseهماهنگی عملیات، اعمال منطق سطح بالاتر.Primary Adapter (Driver)AthleteControllerدریافت درخواست از منابع خارجی (HTTP، CLI، Queue) و فراخوانی Use Case.Secondary Adapter (Driven)AthleteRepositoryAdapter (با TypeORM/Mongoose)پیاده‌سازی عملیات I/O مورد نیاز Domain از طریق پورت‌ها.

 

تست‌پذیری پیشرفته

تست‌پذیری با Hexagonal ساده‌تر می‌شود:

  1. تست Use Case: برای تست CreateAthleteUseCase، ما نیازی به راه‌اندازی PostgreSQL نداریم. کافی است یک Mock از AthleteRepositoryPort بسازیم. این Mock می‌تواند در حافظه داده‌ها را نگه دارد یا ساده‌ترین پاسخ‌ها را برگرداند. تست تنها بر روی منطق تزریق‌شده (مثل بررسی اعتبارسنجی سن) متمرکز می‌شود.
  2. تست آداپتور (Integration Test): برای تست AthleteRepositoryAdapter، ما آن را با یک دیتابیس واقعی (یا دیتابیس در حافظه مانند SQLite) تست می‌کنیم تا مطمئن شویم تبدیل‌ها (Entity Mapping) به‌درستی انجام شده است.

این جداسازی امکان تست Unit Test سریع برای منطق کسب‌وکار (Use Case و Domain) را فراهم می‌آورد.

نتیجه نهایی

Hexagonal یعنی وابستگی‌ها از بیرون به درون تزریق می‌شن، نه برعکس. این جدایی باعث می‌شه هر بخشِ سیستم کوچک‌تر، قابل تست‌ تر و قابل نگهداری‌ تر باشه. در NestJS با dependency injection، decorators مثل @Inject, @InjectRepository, و provider-token ها این کار خیلی طبیعی انجام می‌شه.

در نتیجه:

  • Domain خالص و مستقل از تکنولوژی باقی می‌مونه.
  • Infrastructure تعدادی آداپتور برای ارتباط بیرونی فراهم می‌کنه.
  • Application لایه اتصال و اجراست.
  • فریم‌ورک NestJS فقط نقش glue رو بازی می‌کنه!

این معماری نه‌تنها تمیزی کد رو افزایش می‌ده، بلکه آینده‌سازی واقعی برای پروژه فراهم می‌کنه و هزینه‌ های نگهداری در بلندمدت را به شدت کاهش می‌دهد.