The clearest signal that a backend's boundaries are healthy is boring: I can open a domain file and find nothing in it. No @Injectable, no @nestjs/mongoose, no Types.ObjectId. In Matcha — the multi-tenant multichannel booking platform I covered in the API overview — the whatsapp-settings domain folder imports exactly one thing from outside itself: another file in the same folder. That emptiness is the whole point. It means the rules about what WhatsApp settings are live somewhere that a database driver can never reach.
This post is the close-up the overview didn't have room for: how a single repository port gets one Mongo adapter and one in-memory adapter, and what that buys.
The one-way dependency rule
Every module is the same four layers, and the arrows only point one direction:
domain → application → infrastructure → interface/http
(pure) (use cases) (Mongo, Meta…) (controllers)
A later layer may import an earlier one; an earlier layer importing a later one is the bug we never let through review. The discipline is enforceable because each layer has a strict allow-list:
| 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 |
The trick that makes this work in a DI framework is the port: an abstraction the application layer depends on, with the real implementation supplied at wiring time.
A port is just a contract
A port is a TypeScript interface or an abstract class — Matcha uses both, and the codebase has no rule preferring one. Here's the real repository port for WhatsApp settings. Note what it traffics 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 has no runtime presence, so NestJS can't use it as an injection key. The fix is a Symbol() token, kept in its own file in domain/ports/:
// whatsapp-settings/domain/ports/tokens.ts
export const WHATSAPP_SETTINGS_REPOSITORY = Symbol(
'WHATSAPP_SETTINGS_REPOSITORY',
);The convention is consistent across the codebase: SCREAMING_SNAKE_CASE, always a Symbol() (never a fragile string key), and defined in domain/ before any application or infrastructure file references it.
Interface or abstract class?
When a port carries no shared behavior, an interface is enough. When it benefits from being a real class — richer surface, or so an adapter can extends it — Matcha uses an abstract class instead. The notifications module's outbound channel is the abstract-class style:
// 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 wiring is identical — the only difference is implements versus extends on the adapter. An abstract class is also its own injection token (a class exists at runtime), so it can skip the separate Symbol(). The distinction is ergonomic, not architectural.
Two adapters, one port
This is where the structure earns its keep. The production adapter lives in infrastructure/persistence/mongoose/ and implements the port. It speaks fluent Mongoose — @InjectModel, .lean(), ObjectId validation — and maps every result back to the domain entity through a private toDomain:
// 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. No driver, no network, no ObjectId — and crucially, no @nestjs/mongoose import anywhere in the file:
// 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
}
}Because both satisfy WhatsAppSettingsRepositoryPort, they are drop-in interchangeable. The shape of one port and its two implementations:
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 is where an abstract port becomes a concrete object. Matcha registers the binding in providers, choosing 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's custom providers give three knobs for binding a token, and Matcha uses each where it fits:
useClass— the default. Bind a token to a class NestJS instantiates. Point it atInMemory…in a test module and the whole module flips to in-memory with one line changed.useFactory— choose the adapter at runtime. The overview post shows theWhatsAppProviderPortpicking a real or mock provider off aWA_PROVIDERenv var; the application layer never notices.useExisting— alias a token to a class already registered under its own name, so one instance serves both keys.
The consumer never sees any of this. The service asks for the token and gets some implementation of the contract:
// 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);
}
}A port is a promise the application makes to itself: "I will only ever ask for this contract." Adapters are just the different ways the world keeps it.
What the discipline buys — and when it's overkill
The immediate payoff is testing. The in-memory adapter isn't a mocking-library puppet with stubbed return values; it's a real, behaving repository — save then findByPhone actually works. So a service test exercises genuine logic at the speed of a Map, and the test-driven Vimbus loop can run a feature's whole suite without a database container spinning up. The slower path through mongodb-memory-server is reserved for verifying the Mongo adapter itself.
The longer payoff is replaceability. The day Matcha outgrows a backend — swaps a storage engine, moves a messaging provider — the blast radius is one file in infrastructure/. The domain entity, the application service, the controller, and every test against them stay untouched, because none of them ever knew the old backend's name. That's the same conviction as a data model that tells the truth, pushed out to the edge: the volatile thing is quarantined behind a contract the stable core defined.
I'll be honest about the cost, though, because Alistair Cockburn's original hexagonal write-up is often cited to justify ceremony it never asked for. A port, a token, and two adapters is real overhead. For a value that has exactly one implementation that will never change — a config reader, a UUID generator — it's pure tax, and I'll inject the concrete class and move on. A port earns its existence the moment a dependency is genuinely swappable: a database, an external API, anything I'll want to fake in a test or replace in a year. The architecture isn't a law to apply uniformly. It's a tool you reach for exactly where the seam will hurt if it isn't there.