diff --git a/config/architecture/layers.json b/config/architecture/layers.json new file mode 100644 index 0000000..5436c8b --- /dev/null +++ b/config/architecture/layers.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "layers": { + "domain": { + "root": "src/domain", + "mayImport": ["src/domain"] + }, + "application": { + "root": "src/application", + "mayImport": ["src/application", "src/domain", "src/contracts"] + }, + "presentation": { + "root": "src/presentation", + "mayImport": ["src/presentation", "src/application", "src/domain", "src/contracts"] + }, + "adapters": { + "root": "src/adapters", + "mayImport": ["src/adapters", "src/application", "src/domain", "src/contracts"] + }, + "bootstrap": { + "root": "src/bootstrap", + "mayImport": ["src"] + } + }, + "forbidden": [ + ["domain", "application"], + ["domain", "presentation"], + ["domain", "adapters"], + ["domain", "bootstrap"], + ["application", "presentation"], + ["application", "adapters"], + ["application", "bootstrap"], + ["presentation", "adapters"], + ["presentation", "bootstrap"], + ["adapters", "presentation"], + ["adapters", "bootstrap"] + ] +} diff --git a/docs/architecture/layers.md b/docs/architecture/layers.md new file mode 100644 index 0000000..1134f27 --- /dev/null +++ b/docs/architecture/layers.md @@ -0,0 +1,34 @@ +# Clean Architecture layer contract + +The import direction is `domain <- application <- presentation`; concrete +adapters implement application-owned ports and are assembled only in +`src/bootstrap`. + +| Layer | Owns | May depend on | +| --- | --- | --- | +| `domain` | framework-neutral models and pure policies | domain siblings | +| `application` | use cases, ports, orchestration, view-models | domain and application siblings | +| `presentation` | routes, components, user interaction and view state | application public API and shared UI | +| `adapters` | browser and third-party implementations of application ports | application ports and limited domain values | +| `bootstrap` | runtime configuration, adapter construction and React mount | all selected runtime modules | + +The following edges are forbidden: + +- domain to application, presentation, adapters, bootstrap, React, or browser globals +- application to presentation, concrete adapters, bootstrap, React, or browser globals +- presentation to concrete adapters, raw DTO schemas, or storage implementations +- an adapter to presentation, bootstrap internals, or another concrete adapter + +`bootstrap` contains composition only. Business rules and page-specific +orchestration belong to domain/application. + +Architecture reports use this shape: + +```json +{ + "schemaVersion": 1, + "generatedAt": "ISO-8601", + "rules": [{ "name": "rule-id", "severity": "error", "violations": 0 }], + "summary": { "errors": 0, "warnings": 0 } +} +``` diff --git a/src/application/create-application.js b/src/application/create-application.js new file mode 100644 index 0000000..8f78a70 --- /dev/null +++ b/src/application/create-application.js @@ -0,0 +1,24 @@ +/** + * Application facade factory. Concrete dependencies are supplied by bootstrap. + * + * @param {{ + * resources: { + * query: import("./ports/resource-ports.js").ResourceQueryPort, + * command: import("./ports/resource-ports.js").ResourceCommandPort + * }, + * cache: import("./ports/query-cache-port.js").QueryCachePort, + * storage: import("./ports/storage-port.js").StoragePort, + * telemetry: import("./ports/telemetry-port.js").TelemetryPort + * }} ports + */ +export function createApplication(ports) { + return Object.freeze({ + resources: Object.freeze({ + query: (query, context) => ports.resources.query.execute(query, context), + command: (command, context) => ports.resources.command.execute(command, context), + }), + cache: ports.cache, + storage: ports.storage, + telemetry: ports.telemetry, + }); +} diff --git a/src/application/ports/auth-session-port.js b/src/application/ports/auth-session-port.js new file mode 100644 index 0000000..9173476 --- /dev/null +++ b/src/application/ports/auth-session-port.js @@ -0,0 +1,16 @@ +/** + * @typedef {"authenticated" | "unauthenticated" | "recovery-pending" | "integration-failed"} SessionState + */ + +/** + * The session is opaque: credentials are attached without exposing tokens. + * + * @typedef {{ + * getState(): SessionState, + * attach(request: Request): Promise, + * recover(): Promise<"restored" | "no-session">, + * onUnauthenticated(): void + * }} AuthSessionPort + */ + +export {}; diff --git a/src/application/ports/clock-port.js b/src/application/ports/clock-port.js new file mode 100644 index 0000000..18f7b75 --- /dev/null +++ b/src/application/ports/clock-port.js @@ -0,0 +1,28 @@ +/** + * @typedef {{ + * now(): number, + * sleep(milliseconds: number, signal?: AbortSignal): Promise + * }} ClockPort + */ + +export const systemClock = Object.freeze({ + now: () => Date.now(), + sleep(milliseconds, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + + const timer = setTimeout(resolve, milliseconds); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(signal.reason); + }, + { once: true }, + ); + }); + }, +}); diff --git a/src/application/ports/query-cache-port.js b/src/application/ports/query-cache-port.js new file mode 100644 index 0000000..872d857 --- /dev/null +++ b/src/application/ports/query-cache-port.js @@ -0,0 +1,9 @@ +/** + * @typedef {{ + * read(key: readonly unknown[]): unknown, + * write(key: readonly unknown[], value: unknown): void, + * invalidate(namespace: readonly unknown[]): Promise + * }} QueryCachePort + */ + +export {}; diff --git a/src/application/ports/release-info-port.js b/src/application/ports/release-info-port.js new file mode 100644 index 0000000..b34b41a --- /dev/null +++ b/src/application/ports/release-info-port.js @@ -0,0 +1,13 @@ +/** + * @typedef {{ + * getCurrent(): Promise<{ + * buildId: string, + * configSchemaVersion: string, + * apiContractVersion: string, + * assetManifestHash: string, + * releaseId: string + * }> + * }} ReleaseInfoPort + */ + +export {}; diff --git a/src/application/ports/resource-ports.js b/src/application/ports/resource-ports.js new file mode 100644 index 0000000..1290758 --- /dev/null +++ b/src/application/ports/resource-ports.js @@ -0,0 +1,28 @@ +/** + * @template Query + * @template Model + * @typedef {{ execute(query: Query, context?: RequestContext): Promise> }} ResourceQueryPort + */ + +/** + * @template Command + * @template Model + * @typedef {{ execute(command: Command, context?: RequestContext): Promise> }} ResourceCommandPort + */ + +/** + * @typedef {{ + * operationId: string, + * routeId: string, + * signal?: AbortSignal, + * idempotencyKey?: string + * }} RequestContext + */ + +/** + * @template Value + * @typedef {{ ok: true, value: Value, meta?: Record } | + * { ok: false, error: import("../../contracts/errors.js").ApiFailure }} Result + */ + +export {}; diff --git a/src/application/ports/storage-port.js b/src/application/ports/storage-port.js new file mode 100644 index 0000000..98dae43 --- /dev/null +++ b/src/application/ports/storage-port.js @@ -0,0 +1,9 @@ +/** + * @typedef {{ + * read(logicalName: string): { ok: true, value: unknown } | { ok: false, error: unknown }, + * write(logicalName: string, value: unknown): { ok: true } | { ok: false, error: unknown }, + * remove(logicalName: string): { ok: true } | { ok: false, error: unknown } + * }} StoragePort + */ + +export {}; diff --git a/src/application/ports/telemetry-port.js b/src/application/ports/telemetry-port.js new file mode 100644 index 0000000..e660e62 --- /dev/null +++ b/src/application/ports/telemetry-port.js @@ -0,0 +1,5 @@ +/** + * @typedef {{ emit(eventName: string, attributes: Record): void }} TelemetryPort + */ + +export {}; diff --git a/src/bootstrap/composition-root.js b/src/bootstrap/composition-root.js new file mode 100644 index 0000000..0bbee54 --- /dev/null +++ b/src/bootstrap/composition-root.js @@ -0,0 +1,23 @@ +import { createApplication } from "../application/create-application.js"; + +/** + * This is the only module allowed to join concrete adapters to application + * ports. Boot phases are explicit so failures can stop before product mount. + * + * @param {{ + * loadConfig(): Promise>, + * loadRelease(config: Record): Promise>, + * createAdapters(context: { + * config: Record, + * release: Record + * }): Promise[0]> + * }} factories + */ +export async function createCompositionRoot(factories) { + const config = await factories.loadConfig(); + const release = await factories.loadRelease(config); + const ports = await factories.createAdapters({ config, release }); + const application = createApplication(ports); + + return Object.freeze({ config, release, ports, application }); +}