Bridge Training

Usage

How to consume @workspace/training from an app, wire a service to its adapters and keep the domain out of the wire and the UI.

Installation

Add the workspace dependency to the consuming app or package:

{
  "dependencies": {
    "@workspace/training": "workspace:*"
  }
}

The package ships TypeScript sources; there is no build step and nothing runtime-specific inside. It runs the same under Node (apps/api, apps/cli), Next.js (apps/admin) and Vitest.

Sub-path exports

The package exports map resolves five kinds of paths. Each one points at an index.ts barrel, which is the only public surface of that folder.

Import pathResolves toContents
@workspace/trainingsrc/index.tsCross-cutting domain/shared only: the AppError family and ErrorCode, AppEvent, ServiceContext, localized fields, supported languages, calendar and month helpers, label sorting. See Errors, Service Context and Shared.
@workspace/training/domain/<module>src/domain/<module>/index.tsEntities, value objects, constants and guards, pure rules, the module's own AppError subclasses
@workspace/training/application/<module>src/application/<module>/index.tscreateXxxService factories and their XxxService types, input DTOs, query objects and projections, the module's repository ports
@workspace/training/application/shared and /application/portsthe matching index.tsThe query abstractions every port is built from (Queries) and the cross-cutting ports (Ports)
@workspace/training/factories, @workspace/training/testingsrc/factories/index.ts, src/testing/index.ts(faker, overrides) factories; fakes and mocks of the ports (Factories, Testing)

There is no module-level wildcard beyond domain/* and application/*: a path like @workspace/training/application/groups/ports/groups does not resolve. Import the port type from the module barrel instead.

Two layers

LayerFolderHoldsMay import
Domainsrc/domain/<module>Entities, value objects, constants, pure functions, domain errorsOther domain modules, @workspace/bridge, @workspace/rich-text, @workspace/shared
Applicationsrc/application/<module>Services, input DTOs, query objects, repository portsDomain and application

The rule is one-directional: domain never imports application. A domain file that needs a port, a service or a DTO is a sign the code belongs in application. The reverse is normal: a service imports the entities it returns and the errors it throws. Both layers are pure TypeScript: no database client, no HTTP, no React.

Wiring a service

A service is a plain factory: createXxxService(deps) takes the ports it needs and returns an object of methods. The three parties are:

  1. The port, a type declared next to the service under application/<module>/ports/, for example StudentsRepository or DealCollectionsRepository.
  2. The adapter, implemented in packages/database under src/repositories/drizzle/ and exported by @workspace/database/repositories/drizzle as createDrizzleXxxRepository(db). Some modules return several repositories at once (createDrizzleBoardRepositories(db) gives board and boardRead).
  3. The consumer, which builds the adapter on the right connection, calls the factory and threads a ServiceContext as the last argument of every call.
usage.ts
// The app builds the adapters (packages/database) and hands them to the factory.
export function listStudents(
  deps: StudentsServiceDeps,
  ctx: ServiceContext
): Promise<Student[]> {
  const students = createStudentsService(deps);
  return students.list(undefined, ctx); // ctx is always the last argument
}

A factory takes as many ports as the service needs. The collections service, for instance, needs its repository, the boards read model and the stock overflow resolver from entitlements:

usage.ts
// A service takes every port it needs; here the write, read and junction repositories, a read model and a resolver.
export function createCollections(
  ports: Pick<
    DealCollectionsServiceDeps,
    "collections" | "collectionsRead" | "collectionBoards" | "uow"
  >,
  boardsRead: BoardReadRepository,
  entitlements: StockOverflowResolverDeps["entitlements"]
) {
  return createDealCollectionsService({
    ...ports,
    boardsRead,
    overflow: createStockOverflowResolver({ entitlements }),
    entitlements: allowAllEntitlements,
  });
}

The handler side resolves the user, builds the context and calls the service. userId is always server-derived, never read from the request body:

usage.ts
// What an HTTP handler does: resolve the user, build ctx, call the service.
export function createStudentHandler(
  deps: StudentsServiceDeps,
  resolvedUserId: string,
  body: { firstName: string; lastName: string }
) {
  const ctx: ServiceContext = { userId: resolvedUserId }; // server-derived
  const students = createStudentsService(deps);
  return students.create(body, ctx);
}

See Service Context for what ctx carries and why it is required on id-scoped methods too.

Where to look in the real apps

apps/api

  • apps/api/src/create-router.ts builds every service. Public routes (/public/*, the plans catalogue) build their services on the owner pool, where the public visibility predicate is the only gate. Authenticated routes build theirs on the owner pool too, with no request transaction: c.var.userId is set to the resolved account (the impersonation target when a valid token is presented), not the session subject, and the services scope every read to it.
  • apps/api/src/lib/context.ts declares the PublicApi, PublicPagesApi and AuthenticatedApi shapes. getApi(c) returns the services of the current request and getCtx(c) returns { userId: c.var.userId }.
  • apps/api/src/modules/<module>/<module>.handlers.ts call getApi(c).xxxService.method(input, getCtx(c)) and map the returned entities to DTOs with the mappers of @workspace/api-contract.

apps/admin

  • apps/admin/src/lib/services.ts wires the admin surface in one getServices() function on the admin connection (createAdminDb()): boards write and claim services, editor boards, entitlements, plans, subscriptions, ads, announcements. The file starts with import "server-only" (see below).
  • apps/admin/src/lib/auth-gate.ts resolves the acting admin account. Admin methods that take a user id receive it as data; the domain never sees the caller-versus-target split.

packages/database

  • packages/database/src/repositories/drizzle/ holds one <entity>.repository.ts per port plus the mappers (board-where.mapper.ts, board-sort.mapper.ts, plans.mapper.ts, …) that translate query objects into SQL. index.ts is the barrel the apps import.

Boundaries

Domain errors never reach the wire

Services throw AppError subclasses (see Errors). In apps/api, a handler wraps the service call in translateDomainErrors(fn) from apps/api/src/errors/translate-domain-error.ts, which maps each family to an apiErrors.* factory of @workspace/api-contract. Anything that is not translated reaches the error middleware and is shipped as a 500 UNEXPECTED_ERROR with a scrubbed message. The wire format and the public codes are documented on API errors.

UI packages never import training

ui-training, ui-bridge and the other ui-* packages are React and DOM only. They receive ViewModels built by the apps from the entities and DTOs; no ui-* package depends on @workspace/training. The dependency rules are on Architecture.

import "server-only" is a consumer-side marker

The server-only package throws at import time unless the bundler resolves the react-server condition, which only Next's compiler provides. @workspace/training is also loaded by apps/api and apps/cli on vanilla Node, where the marker would crash the process at boot. The rule is therefore:

  • The marker lives in apps/admin only, at the Next consumer boundaries: src/lib/services.ts, src/lib/db.ts, src/lib/queue.ts, the auth gate and every Server Action or Route Handler that reaches them. In a "use server" file the directive comes first and import "server-only" second.
  • It never goes in @workspace/training, @workspace/queue, @workspace/database or any other package consumed by a non-Next runtime.
  • Plain Server Components that only receive already-fetched view models do not need it.
  • If a client-rendered host ever needs domain symbols directly, the answer is a separate server-only package, not the marker in @workspace/training.