Orbit
03 · Guides

Add a bounded context

End-to-end: from empty folder to a typed HTTP endpoint that writes a new aggregate and broadcasts a realtime event.

Walking example: a Projects context. Each workspace has many projects; creating one enqueues a domain event and broadcasts. The same shape works for any noun you want to add — folders, spaces, pipelines.

1. Lay out the folders

ORM
apps/api/src/projects/
├── domain/
│ ├── project.ts # Aggregate + events
│ ├── project-slug.ts # Value object
│ └── repositories.ts # Port interface
├── application/
│ └── create-project.service.ts
├── infrastructure/
│ └── prisma-project.repository.ts
└── feature.ts # Wiring for composition

Every context looks like this. Once you know the pattern, the mechanics stop being interesting.

2. Define the aggregate

apps/api/src/projects/domain/project.ts

import type { UserId } from "@/identity/domain/user.ts";
import type { Clock } from "@/kernel/clock.ts";
import { DomainEvent } from "@/kernel/events.ts";
import { type Id, newId } from "@/kernel/id.ts";
import type { WorkspaceId } from "@/workspaces/domain/workspace.ts";
import { ProjectSlug } from "./project-slug.ts";
export type ProjectId = Id<"project">;
export class ProjectCreated extends DomainEvent {
readonly type = "projects.project.created";
constructor(
readonly workspaceId: WorkspaceId,
readonly projectId: ProjectId,
readonly createdById: UserId,
occurredAt: Date,
) {
super(occurredAt);
}
}
export class Project {
private events: DomainEvent[] = [];
private constructor(
public readonly id: ProjectId,
public readonly workspaceId: WorkspaceId,
private _slug: ProjectSlug,
private _name: string,
public readonly createdById: UserId,
public readonly createdAt: Date,
) {}
static create(
input: { workspaceId: WorkspaceId; slug: ProjectSlug; name: string; createdById: UserId },
clock: Clock,
): Project {
const id = newId("project");
const now = clock.now();
const p = new Project(id, input.workspaceId, input.slug, input.name.trim(), input.createdById, now);
p.events.push(new ProjectCreated(input.workspaceId, id, input.createdById, now));
return p;
}
pullEvents(): DomainEvent[] {
const e = this.events;
this.events = [];
return e;
}
}
Note

Register the new brand with the id system — open apps/api/src/kernel/id.ts and add "project" to the IdPrefixes map. That's what makes newId("project") type-check.

3. Define the repository port

apps/api/src/projects/domain/repositories.ts

import type { Project, ProjectId } from "./project.ts";
import type { WorkspaceId } from "@/workspaces/domain/workspace.ts";
export interface ProjectRepository {
findById(id: ProjectId): Promise<Project | null>;
findByWorkspaceAndSlug(workspaceId: WorkspaceId, slug: string): Promise<Project | null>;
listByWorkspace(workspaceId: WorkspaceId): Promise<Project[]>;
save(project: Project): Promise<void>;
delete(id: ProjectId): Promise<void>;
}

Ports are interfaces — no ORM, no SQL, no Hono. Services depend on this, not on the adapter.

4. Schema + migration

ORM

apps/api/prisma/schema.prisma

model Project {
id String @id
workspaceId String
slug String
name String
createdById String
createdAt DateTime @default(now())
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, slug])
@@index([workspaceId])
}
npm run prisma:migrate

Prisma prompts for a migration name; call it add_projects. The client regenerates.

5. Implement the repository

ORM

apps/api/src/projects/infrastructure/prisma-project.repository.ts

import type { Prisma } from "@/infrastructure/prisma.ts";
import { Project, type ProjectId } from "../domain/project.ts";
import type { ProjectRepository } from "../domain/repositories.ts";
import { ProjectSlug } from "../domain/project-slug.ts";
export class PrismaProjectRepository implements ProjectRepository {
constructor(private readonly db: Prisma) {}
async findById(id: ProjectId): Promise<Project | null> {
const row = await this.db.project.findUnique({ where: { id } });
return row ? hydrate(row) : null;
}
async findByWorkspaceAndSlug(workspaceId, slug) {
const row = await this.db.project.findUnique({
where: { workspaceId_slug: { workspaceId, slug } },
});
return row ? hydrate(row) : null;
}
async listByWorkspace(workspaceId) {
const rows = await this.db.project.findMany({ where: { workspaceId } });
return rows.map(hydrate);
}
async save(p: Project): Promise<void> {
await this.db.project.upsert({
where: { id: p.id },
update: { name: p.name },
create: {
id: p.id,
workspaceId: p.workspaceId,
slug: p.slug.value,
name: p.name,
createdById: p.createdById,
createdAt: p.createdAt,
},
});
}
async delete(id: ProjectId): Promise<void> {
await this.db.project.delete({ where: { id } }).catch(() => undefined);
}
}
function hydrate(row: { id: string; /* ... */ }): Project {
return Project.rehydrate({ /* ... */ });
}

6. Wire the repo into the Unit of Work

Open apps/api/src/kernel/uow.ts and extend TxContext:

import type { ProjectRepository } from "@/projects/domain/repositories.ts";
export interface TxContext {
users: UserRepository;
workspaces: WorkspaceRepository;
// ...existing...
projects: ProjectRepository; // ← add
events: TxEventCollector;
}

Then in the active UoW (apps/api/src/infrastructure/prisma-uow.ts), extend buildContext:

ORM

PrismaUnitOfWork.buildContext()

import { PrismaProjectRepository } from "@/projects/infrastructure/prisma-project.repository.ts";
protected buildContext(db: Prisma): RepoContext {
return {
users: new PrismaUserRepository(db),
workspaces: new PrismaWorkspaceRepository(db),
// ...existing...
projects: new PrismaProjectRepository(db), // ← add
};
}
Note

One line per new context. The read-only proxy in BaseUnitOfWork picks up the new repo automatically via Object.entries.

7. Write the service

apps/api/src/projects/application/create-project.service.ts

import type { Clock } from "@/kernel/clock.ts";
import type { UnitOfWork } from "@/kernel/uow.ts";
import type { UserId } from "@/identity/domain/user.ts";
import type { WorkspaceId } from "@/workspaces/domain/workspace.ts";
import { ConflictError } from "@/kernel/errors.ts";
import { Project } from "../domain/project.ts";
import { ProjectSlug } from "../domain/project-slug.ts";
interface Command {
workspaceId: WorkspaceId;
createdById: UserId;
slug: string;
name: string;
}
export class CreateProjectService {
constructor(
private readonly uow: UnitOfWork,
private readonly clock: Clock,
) {}
async execute(cmd: Command): Promise<Project> {
const slug = ProjectSlug.parse(cmd.slug);
return this.uow.run(async (tx) => {
const existing = await tx.projects.findByWorkspaceAndSlug(cmd.workspaceId, slug.value);
if (existing) throw new ConflictError("project.slug_taken");
const project = Project.create(
{ workspaceId: cmd.workspaceId, slug, name: cmd.name, createdById: cmd.createdById },
this.clock,
);
await tx.projects.save(project);
tx.events.addMany(project.pullEvents());
return project;
});
}
}

8. Wire into composition

composition.ts constructs the service and hands it to the HTTP layer. Add it alongside the other services:

import { CreateProjectService } from "@/projects/application/create-project.service.ts";
const createProject = new CreateProjectService(uow, clock);
// then include createProject in the container that controllers read from
return {
// ...existing...
createProject,
};

9. Add an HTTP controller

apps/api/src/interfaces/http/controllers/projects.controller.ts

import { Hono } from "hono";
import { z } from "zod";
import type { HonoEnv } from "../middleware/container.ts";
import { requireSession, requirePermission } from "../middleware/session.ts";
import { resolveWorkspaceMember } from "../middleware/workspace.ts";
const CreateBody = z.object({
slug: z.string().min(2).max(40),
name: z.string().min(1).max(80),
});
export const projects = new Hono<HonoEnv>()
.post("/", async (c) => {
const { userId } = requireSession(c);
const { me, workspace } = await resolveWorkspaceMember(c, userId);
requirePermission(me, "projects.create");
const body = CreateBody.parse(await c.req.json());
const container = c.get("container");
const project = await container.createProject.execute({
workspaceId: workspace.id,
createdById: me.userId,
slug: body.slug,
name: body.name,
});
return c.json({ project: { id: project.id, slug: project.slug.value, name: project.name } }, 201);
});

Mount the controller in the route tree under /v1/workspaces/:slug/projects. The session + workspace resolution middleware is the same as every other scoped route.

Heads up

requirePermission(me, "projects.create") won't compile until you add the permission string — see the Add a permission guide.

10. Broadcast it (optional)

If you want other tabs on the workspace to see the new project immediately, subscribe to ProjectCreated in RealtimeEventPublisher:

this.bus.subscribe<ProjectCreated>(
"projects.project.created",
async (event) => {
const dto = await this.uow.read(async (tx) => {
const project = await tx.projects.findById(event.projectId);
return project ? projectToDTO(project) : null;
});
if (!dto) return;
this.hub.broadcast(channels.workspace(event.workspaceId), {
type: "project.created",
project: dto,
});
},
);

Add the matching entry to the ServerEvent union in @orbit/shared/realtime and handle it in the client's applyServerEvent — TypeScript will force you to cover both.

That's the pattern

Every context you add after this one is the same nine steps in the same order. Once you've done it twice, it's a five-minute template fill-in. The domain stays pure, the adapters stay swappable, and the realtime layer picks up the event by name.