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 path | Resolves to | Contents |
|---|---|---|
@workspace/training | src/index.ts | Cross-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.ts | Entities, value objects, constants and guards, pure rules, the module's own AppError subclasses |
@workspace/training/application/<module> | src/application/<module>/index.ts | createXxxService factories and their XxxService types, input DTOs, query objects and projections, the module's repository ports |
@workspace/training/application/shared and /application/ports | the matching index.ts | The query abstractions every port is built from (Queries) and the cross-cutting ports (Ports) |
@workspace/training/factories, @workspace/training/testing | src/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
| Layer | Folder | Holds | May import |
|---|---|---|---|
| Domain | src/domain/<module> | Entities, value objects, constants, pure functions, domain errors | Other domain modules, @workspace/bridge, @workspace/rich-text, @workspace/shared |
| Application | src/application/<module> | Services, input DTOs, query objects, repository ports | Domain 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:
- The port, a type declared next to the service under
application/<module>/ports/, for exampleStudentsRepositoryorDealCollectionsRepository. - The adapter, implemented in
packages/databaseundersrc/repositories/drizzle/and exported by@workspace/database/repositories/drizzleascreateDrizzleXxxRepository(db). Some modules return several repositories at once (createDrizzleBoardRepositories(db)givesboardandboardRead). - The consumer, which builds the adapter on the right connection, calls the factory and threads a
ServiceContextas the last argument of every call.
// 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:
// 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:
// 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.tsbuilds 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.userIdis 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.tsdeclares thePublicApi,PublicPagesApiandAuthenticatedApishapes.getApi(c)returns the services of the current request andgetCtx(c)returns{ userId: c.var.userId }.apps/api/src/modules/<module>/<module>.handlers.tscallgetApi(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.tswires the admin surface in onegetServices()function on the admin connection (createAdminDb()): boards write and claim services, editor boards, entitlements, plans, subscriptions, ads, announcements. The file starts withimport "server-only"(see below).apps/admin/src/lib/auth-gate.tsresolves 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.tsper port plus the mappers (board-where.mapper.ts,board-sort.mapper.ts,plans.mapper.ts, …) that translate query objects into SQL.index.tsis 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/adminonly, 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 andimport "server-only"second. - It never goes in
@workspace/training,@workspace/queue,@workspace/databaseor 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.
Overview
What @workspace/training is, how its domain and application layers are organised, which sub-paths exist, and where each module is documented.
Service Context
The ctx argument every user-scoped service method takes last, what it carries, why it is required on id-scoped methods and where it does not go.