All writing

Writing

Ports and Adapters in NestJS: A Domain That Doesn't Know About Mongo

By Aggelos Konstantilieris·May 19, 2026·6 min read·Updated October 28, 2026

A healthy domain file is boring. It has no @Injectable, no @nestjs/mongoose and no Types.ObjectId. Matcha is the multi-tenant WhatsApp booking platform I covered in the API overview. Its whatsapp-settings domain folder imports only files from the same folder. So the rules about WhatsApp settings live where a database driver cannot reach them.

This post looks closer at one repository port, its Mongo adapter and its in-memory adapter.

The one-way dependency rule

Every module has the same four layers, and dependencies point one way:

domain  →  application  →  infrastructure  →  interface/http
(pure)     (use cases)     (Mongo, Meta…)     (controllers)

A later layer may import an earlier one. The reverse never passes review. Each layer has a strict allow-list, which makes the rule easy to enforce:

LayerMay importMust never import
domainother domain typesNestJS, Mongoose, infra
applicationdomain (ports + entities)concrete adapters
infrastructuredomain (to implements a port)interface/http
interface/httpapplication servicesrepositories directly

In a DI framework this works through ports. The application layer depends on a port, and the real implementation is supplied at wiring time.

A port is just a contract

A port is a TypeScript interface or an abstract class. Matcha uses both and has no rule preferring one. Here is the repository port for WhatsApp settings. It deals in a plain domain entity, never a Mongoose document.

// whatsapp-settings/domain/whatsapp-settings-repository.port.ts
import { WhatsAppSettings } from './whatsapp-settings.entity';
 
export interface WhatsAppSettingsRepositoryPort {
  /** Returns null if no settings exist for the business. */
  findByBusinessId(businessId: string): Promise<WhatsAppSettings | null>;
 
  /** Save settings (create or update). */
  save(settings: WhatsAppSettings): Promise<WhatsAppSettings>;
 
  /** Resolve a business from an inbound WhatsApp webhook. */
  findByPhone(phone: string): Promise<WhatsAppSettings | null>;
}

An interface does not exist at runtime, so NestJS cannot use it as an injection key. A Symbol() token fills that role, kept in its own file in domain/ports/:

// whatsapp-settings/domain/ports/tokens.ts
export const WHATSAPP_SETTINGS_REPOSITORY = Symbol(
  'WHATSAPP_SETTINGS_REPOSITORY',
);

Every token follows the same rules. It uses SCREAMING_SNAKE_CASE, it is always a Symbol() instead of a string key, and it is defined in domain/ before anything references it.

Interface or abstract class?

When a port carries no shared behavior, an interface is enough. When an adapter should extend it, Matcha uses an abstract class. The notifications module's outbound channel is one:

// notifications/domain/ports/whatsapp-channel.port.ts — trimmed; the real
// class declares ~13 methods (sendText, sendImage, sendTemplateMessage…).
export abstract class WhatsappChannelPort {
  abstract send(
    to: string,
    body: string,
    sender?: WhatsAppSenderConfig,
  ): Promise<string>;
  abstract sendInteractiveButtons(to: string, options: {
    header?: InteractiveHeader;
    body: string;
    footer?: string;
    buttons: InteractiveButton[];
  }): Promise<string>;
  abstract trackMessageDelivery(messageId: string): Promise<DeliveryStatus>;
}

The adapter uses extends instead of implements, and the abstract class can be its own token.

Two adapters, one port

The production adapter lives in infrastructure/persistence/mongoose/ and implements the port. It uses @InjectModel, .lean() and ObjectId validation. A private toDomain maps every result back to the domain entity:

// infrastructure/persistence/mongoose/mongo-whatsapp-settings.repository.adapter.ts
@Injectable()
export class MongoWhatsAppSettingsRepositoryAdapter
  implements WhatsAppSettingsRepositoryPort
{
  constructor(
    @InjectModel(WhatsAppSettingsModel.name)
    private readonly model: Model<WhatsAppSettingsDocument>,
  ) {}
 
  async findByBusinessId(businessId: string): Promise<WhatsAppSettings | null> {
    if (!Types.ObjectId.isValid(businessId)) return null;
    const doc = await this.model
      .findOne({ businessId: new Types.ObjectId(businessId) })
      .lean()
      .exec();
    return doc ? this.toDomain(doc) : null; // Mongoose stops at this boundary
  }
 
  // save() upserts; findByPhone() matches displayPhoneNumber or phoneNumberId
}

The second adapter implements the same interface with a Map. It needs no driver and no network, and the file never imports @nestjs/mongoose:

// infrastructure/persistence/in-memory-whatsapp-settings.repository.ts
@Injectable()
export class InMemoryWhatsAppSettingsRepository
  implements WhatsAppSettingsRepositoryPort
{
  private readonly store = new Map<string, WhatsAppSettings>();
 
  async findByBusinessId(businessId: string): Promise<WhatsAppSettings | null> {
    return this.store.get(businessId) ?? null;
  }
 
  async save(settings: WhatsAppSettings): Promise<WhatsAppSettings> {
    // (real impl clones settings first to avoid shared references)
    this.store.set(settings.businessId, settings);
    return settings;
  }
 
  async findByPhone(phone: string): Promise<WhatsAppSettings | null> {
    for (const s of this.store.values()) {
      if (s.displayPhoneNumber === phone || s.phoneNumberId === phone) return s;
    }
    return null;
  }
 
  clear(): void {
    this.store.clear(); // a convenience the production path doesn't need
  }
}

Both satisfy WhatsAppSettingsRepositoryPort, so either can replace the other:

Port (domain)Production adapter (infrastructure)Test / dev adapter
WhatsAppSettingsRepositoryPortMongoWhatsAppSettingsRepositoryAdapterInMemoryWhatsAppSettingsRepository
WhatsappChannelPortWhatsappGraphAdapter (Meta Graph API)a test double bound under the same token

Wiring the token to an adapter

The module turns the port into a concrete object. Matcha registers the binding in providers and picks the implementation by token:

// whatsapp-settings.module.ts
@Module({
  imports: [
    MongooseModule.forFeature([
      { name: WhatsAppSettingsModel.name, schema: WhatsAppSettingsSchema },
    ]),
  ],
  controllers: [WhatsAppSettingsController],
  providers: [
    WhatsAppSettingsService,
    {
      provide: WHATSAPP_SETTINGS_REPOSITORY,   // the Symbol() token
      useClass: MongoWhatsAppSettingsRepositoryAdapter, // swap in tests
    },
  ],
  exports: [WhatsAppSettingsService, WHATSAPP_SETTINGS_REPOSITORY],
})
export class WhatsAppSettingsModule {}

NestJS custom providers offer three ways to bind a token, and Matcha uses each where it fits:

  1. useClass binds a token to a class NestJS creates. Point it at InMemory… in a test module and one changed line moves the module to memory.
  2. useFactory picks the adapter at runtime. WhatsAppProviderPort chooses a real or mock provider from the WA_PROVIDER env var.
  3. useExisting aliases a token to a class already registered, so one instance serves both keys.

The service asks for the token and gets an implementation:

// application/whatsapp-settings.service.ts
@Injectable()
export class WhatsAppSettingsService {
  constructor(
    @Inject(WHATSAPP_SETTINGS_REPOSITORY)
    private readonly repository: WhatsAppSettingsRepositoryPort,
  ) {}
 
  async getSettings(businessId: string): Promise<WhatsAppSettingsDto> {
    let settings = await this.repository.findByBusinessId(businessId);
    if (!settings) {
      settings = WhatsAppSettings.createDefault(businessId);
      await this.repository.save(settings);
    }
    return this.toDto(settings);
  }
}

What it buys, and when it's overkill

The first payoff is testing. The in-memory adapter really works, so a service test runs real logic at the speed of a Map. The Vimbus loop runs a whole suite without a database container. The slower mongodb-memory-server path is kept for testing the Mongo adapter itself.

The second payoff is replacement. Swapping a storage engine changes one file in infrastructure/.

A port, a token and two adapters are real overhead, and Cockburn's original write-up does not ask for them everywhere. A config reader gets the concrete class. A database or an external API gets a port.

The Matcha case study shows the backend these modules belong to.