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:
| Layer | May import | Must never import |
|---|---|---|
domain | other domain types | NestJS, Mongoose, infra |
application | domain (ports + entities) | concrete adapters |
infrastructure | domain (to implements a port) | interface/http |
interface/http | application services | repositories 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 |
|---|---|---|
WhatsAppSettingsRepositoryPort | MongoWhatsAppSettingsRepositoryAdapter | InMemoryWhatsAppSettingsRepository |
WhatsappChannelPort | WhatsappGraphAdapter (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:
useClassbinds a token to a class NestJS creates. Point it atInMemory…in a test module and one changed line moves the module to memory.useFactorypicks the adapter at runtime.WhatsAppProviderPortchooses a real or mock provider from theWA_PROVIDERenv var.useExistingaliases 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.
References and related work
- NestJS custom providers: tokens and provider bindings used to keep infrastructure replaceable.
The Matcha case study shows the backend these modules belong to.