Skip to content

Create a Domain

This guide walks through creating a new domain for the Backcap registry. We’ll use a hypothetical notifications domain as the running example. Follow the numbered checklist in order.

A domain is a vertical slice of backend business logic structured in three layers: domain/, application/, and contracts/. Adapters (port implementations) are authored by you outside the domain directory. Read the Domains concept page and the Architecture page before proceeding.

Write a one-paragraph description of what the domain does and what it does not do. Be explicit about boundaries.

Example:

The notifications domain manages the delivery of transactional messages to users. It defines the INotificationSender port, a SendNotification use case, and typed results for delivery success and failure. It does not handle email provider configuration, template rendering, or user preference management.

Terminal window
mkdir -p packages/registry/domains/notifications/domain/entities
mkdir -p packages/registry/domains/notifications/domain/value-objects
mkdir -p packages/registry/domains/notifications/domain/errors
mkdir -p packages/registry/domains/notifications/domain/events
mkdir -p packages/registry/domains/notifications/domain/__tests__
mkdir -p packages/registry/domains/notifications/application/use-cases
mkdir -p packages/registry/domains/notifications/application/ports
mkdir -p packages/registry/domains/notifications/application/dto
mkdir -p packages/registry/domains/notifications/application/__tests__/mocks
mkdir -p packages/registry/domains/notifications/contracts
mkdir -p packages/registry/domains/notifications/shared

Copy shared/result.ts from an existing domain. This file is duplicated intentionally so each domain is self-contained:

Terminal window
cp packages/registry/domains/auth/shared/result.ts \
packages/registry/domains/notifications/shared/result.ts

Create typed error classes for every expected failure condition. Each error extends Error and has a static factory method:

domain/errors/notification-failed.error.ts
export class NotificationFailed extends Error {
static create(reason: string): NotificationFailed {
return new NotificationFailed(`Notification delivery failed: ${reason}`);
}
}

Rules:

  • One file per error class
  • File name: <kebab-name>.error.ts
  • Export name: PascalCase
  • No external imports

Value objects wrap and validate primitives. Use them when a primitive carries business meaning:

domain/value-objects/channel.vo.ts
import { Result } from "../../shared/result.js";
const VALID_CHANNELS = ["email", "sms", "push"] as const;
type Channel = (typeof VALID_CHANNELS)[number];
export class NotificationChannel {
private constructor(readonly value: Channel) {}
static create(value: string): Result<NotificationChannel, Error> {
if (!VALID_CHANNELS.includes(value as Channel)) {
return Result.fail(new Error(`Invalid channel: ${value}`));
}
return Result.ok(new NotificationChannel(value as Channel));
}
}

Rules:

  • Private constructor — always use a static factory that returns Result
  • Immutable — no setters
  • No external imports

Entities are domain objects with identity. Not every domain needs entities — some are purely service-oriented.

Rules:

  • Private constructor
  • Factory method returns Result<Entity, DomainError>
  • All mutation methods return Result<Entity, DomainError> (return a new instance)
  • No external imports

Domain events represent something that happened. They are emitted by use cases and can be consumed by event handlers you wire up:

domain/events/notification-sent.event.ts
export class NotificationSent {
readonly occurredAt: Date;
constructor(
readonly notificationId: string,
readonly userId: string,
readonly channel: string,
) {
this.occurredAt = new Date();
}
}

Test all domain logic before moving to the application layer:

domain/__tests__/channel.vo.test.ts
describe("NotificationChannel", () => {
it("creates a valid channel", () => {
const result = NotificationChannel.create("email");
expect(result.isOk()).toBe(true);
expect(result.unwrap().value).toBe("email");
});
it("rejects an invalid channel", () => {
const result = NotificationChannel.create("carrier-pigeon");
expect(result.isFail()).toBe(true);
});
});

Ports describe what the use cases need from external systems. One interface per external concern:

application/ports/notification-sender.port.ts
import type { SendNotificationDto } from "../dto/send-notification.dto.js";
export interface INotificationSender {
send(dto: SendNotificationDto): Promise<void>;
}

Rules:

  • interface keyword (not abstract class)
  • Filename: <kebab-name>.port.ts
  • Export name: I<PascalName>
  • Import from domain/ only (for entity types if needed)

DTOs are plain data shapes for use case inputs and outputs. No methods, no validation logic:

application/dto/send-notification.dto.ts
export interface SendNotificationDto {
userId: string;
channel: string;
subject: string;
body: string;
}

One class per use case, one public execute() method:

application/use-cases/send-notification.use-case.ts
import { Result } from "../../shared/result.js";
import { NotificationFailed } from "../../domain/errors/notification-failed.error.js";
import type { INotificationSender } from "../ports/notification-sender.port.js";
import type { SendNotificationDto } from "../dto/send-notification.dto.js";
export class SendNotification {
constructor(private readonly sender: INotificationSender) {}
async execute(dto: SendNotificationDto): Promise<Result<void, NotificationFailed>> {
try {
await this.sender.send(dto);
return Result.ok(undefined);
} catch (err) {
return Result.fail(NotificationFailed.create((err as Error).message));
}
}
}

Rules:

  • Constructor receives port interfaces only (no concrete classes)
  • execute() returns Result<T, E> — never throws for expected failures
  • Import from domain/ and application/ only
application/__tests__/mocks/notification-sender.mock.ts
export class MockNotificationSender implements INotificationSender {
sent: SendNotificationDto[] = [];
async send(dto: SendNotificationDto): Promise<void> {
this.sent.push(dto);
}
}
application/__tests__/send-notification.use-case.test.ts
describe("SendNotification", () => {
it("sends a notification successfully", async () => {
const sender = new MockNotificationSender();
const useCase = new SendNotification(sender);
const result = await useCase.execute({
userId: "user-1",
channel: "email",
subject: "Hello",
body: "Welcome!",
});
expect(result.isOk()).toBe(true);
expect(sender.sent).toHaveLength(1);
});
});
contracts/notifications.contract.ts
import type { Result } from "../shared/result.js";
import type { SendNotificationDto } from "../application/dto/send-notification.dto.js";
import type { NotificationFailed } from "../domain/errors/notification-failed.error.js";
export interface INotificationsService {
send(dto: SendNotificationDto): Promise<Result<void, NotificationFailed>>;
}
contracts/notifications.factory.ts
import type { INotificationSender } from "../application/ports/notification-sender.port.js";
import { SendNotification } from "../application/use-cases/send-notification.use-case.js";
import type { INotificationsService } from "./notifications.contract.js";
export type NotificationsServiceDeps = {
notificationSender: INotificationSender;
};
export function createNotificationsService(
deps: NotificationsServiceDeps,
): INotificationsService {
const sendNotification = new SendNotification(deps.notificationSender);
return {
send: (dto) => sendNotification.execute(dto),
};
}
contracts/index.ts
export type { INotificationsService } from "./notifications.contract.js";
export { createNotificationsService } from "./notifications.factory.js";
export type { NotificationsServiceDeps } from "./notifications.factory.js";

This is the only barrel export in the domain. Do not create index.ts files in domain/ or application/.

Write a skill file at packages/registry/skills/backcap-notifications/SKILL.md. See the Skills concept page for the required format.

Build the registry to produce a JSON bundle (notifications.json) containing all source files as content strings. The CLI fetches this bundle when users run npx @backcap/cli add notifications.

Terminal window
pnpm --filter @backcap/registry build

The bundle format matches the registryItemSchema in packages/shared/. The build step runs runQualityChecks() from quality-check.ts, which enforces the domain directory structure and naming conventions at build time.

  • Bounded context defined in writing
  • Directory structure created
  • shared/result.ts copied
  • Domain errors defined and tested
  • Value objects defined and tested (if applicable)
  • Domain entities defined and tested (if applicable)
  • Domain events defined
  • Port interfaces defined
  • DTOs defined
  • Use cases implemented and tested with mocks
  • Public contract interface defined
  • Factory function written
  • contracts/index.ts barrel created
  • SKILL.md written
  • Registry bundle produced