Core
DDD domain slices with strict layer direction, named exports, and determinism governance.
Core (packages/core)
The core package contains the domain logic for the entire system. It is organized as DDD domain slices under src/domains/, with each domain following a strict folder structure and layer dependency direction.
Domain Structure
Each domain slice must contain these folders and files:
packages/core/src/domains/<domain>/
events.ts # PUBLIC cross-domain event contract (the ONLY importable file)
domain/ # Entities, value objects, pure business rules
ports/ # Capability contracts (Effect Context.Tag services)
application/ # Use-case orchestration (Effect services)
adapters/ # External system adapter implementationsThe runtime/ folder (layer wiring and composition) and ui/ folder (presentation-facing helpers) are optional.
The repositories/ and services/ folders are forbidden. Concrete persistence lives in packages/infra/db, and use-case orchestration belongs in application/.
Current Domains
| Domain | Purpose |
|---|---|
auth | User authentication, principal extraction, session management, OAuth providers |
organization | Organization CRUD, members, onboarding state, invitations |
team | Teams (workspaces) within organizations, team members, brand settings, review tokens |
role | Role + permission management, role-permission policy matrix |
billing | Stripe subscriptions, credit ledger, usage caps, welcome credit, auto-recharge |
assets | Media uploads (R2), deduplication, thumbnails, collections, storage metering |
notifications | In-app notification delivery, fan-out from domain events |
email_campaigns | Transactional + campaign email reference implementation (kept as a non-trivial CRUD example) |
Cross-Domain Boundaries
Events are public nouns. Ports are public verbs. Errors are private semantics.
| What | Cross-domain? | How |
|---|---|---|
Domain events (events.ts at domain root) | Yes | The ONLY file other domains may import from this domain |
Ports (ports/) | No — wired via DI | Adapters bridge repos to ports; services depend on their own ports |
| Domain errors | No — translated at seams | Rich typed ADT errors inside a domain; translated at port/app boundary |
| Shared infra errors | Yes | CoreError (unauthorized, notFound, etc.) at core package level |
The events.ts contract exposes event type discriminants, typed payload shapes, and version constants:
// packages/core/src/domains/billing/events.ts
export type {
BillingCreditsPurchasedEventPayload,
BillingUsageCapExceededEventPayload,
BillingWelcomeCreditGrantedEventPayload
} from './domain/billing-events.js'
export const billingEvents = {
creditsPurchased: 'billing.credits_purchased',
usageCapExceeded: 'billing.usage_cap_exceeded',
welcomeCreditGranted: 'billing.welcome_credit_granted'
} as const
export const billingEventVersions = {
'billing.credits_purchased': 1,
'billing.usage_cap_exceeded': 1,
'billing.welcome_credit_granted': 1
} as constConsumers import only with import type — events are data contracts, not runtime code. ESLint + structural lint enforce that events.ts is the only cross-domain importable file at a domain root.
Layer Dependency Direction
Dependencies must flow inward. The enforcement script validates every import path:
domain -> domain only
ports -> domain, ports
application -> domain, ports, application
adapters -> domain, ports, adapters
runtime -> all layers
ui -> all layersCross-domain imports are forbidden except through events.ts. Each domain is otherwise a self-contained unit.
Domain Layer
The domain layer defines entities, value objects, and pure business rules. It must not import anything outside its own domain layer.
// packages/core/src/domains/organization/domain/organization.ts
export interface OrganizationRecord {
id: string
name: string
billingEmail: string | null
isSubscribed: boolean
subscriptionStatus: SubscriptionStatus
createdAt: Date
updatedAt: Date
}
export const isValidOrganizationName = (name: string): boolean => {
const trimmed = name.trim()
return trimmed.length >= 2 && trimmed.length <= 64
}Determinism constraint: Date.now(), new Date(), and Math.random() are forbidden in domain-layer code. Inject time and randomness through ports.
Ports Layer
Ports define abstract capability contracts using Effect's Context.Tag:
// packages/core/src/domains/organization/ports/organization-ports.ts
export class OrganizationStorePort extends Context.Tag('OrganizationStorePort')<
OrganizationStorePort,
{
list: (userId: string, params: ListParams) => Effect.Effect<PaginatedResult<OrganizationRecord>, unknown>
getById: (id: string) => Effect.Effect<OrganizationRecord | null, unknown>
create: (input: CreateOrganizationInput) => Effect.Effect<OrganizationRecord | null, unknown>
update: (input: UpdateOrganizationInput) => Effect.Effect<OrganizationRecord | null, unknown>
remove: (id: string) => Effect.Effect<{ deleted: true }, unknown>
}
>() {}Ports must return Effect.Effect (never Promise), declare an explicit kind marker (crud or custom), avoid implementing layers with Layer.succeed/Layer.effect, and import port types from the domain layer via *Record interfaces.
Application Layer
Application modules orchestrate use cases by combining ports:
// packages/core/src/domains/organization/application/organization-service.ts
export class OrganizationService extends Context.Tag('OrganizationService')<OrganizationService, {
getById: (principal: Principal, organizationId: string) => Effect.Effect<Organization, CoreError>
create: (principal: Principal, input: CreateOrganizationCommand) => Effect.Effect<Organization, CoreError>
// ...
}>() {}Governance Rules
All domain layer files must use named exports; export default is forbidden. Environment reads must go through dedicated config modules rather than accessing process.env directly. Type assertions to any are not permitted. Model unknowns explicitly and decode with schema at boundaries. Suppression directives such as @ts-ignore are likewise forbidden; fix the underlying type issue instead of suppressing the error.