feat: define clean architecture layers and ports
This commit is contained in:
@@ -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"]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Application facade factory. Concrete dependencies are supplied by bootstrap.
|
||||||
|
*
|
||||||
|
* @param {{
|
||||||
|
* resources: {
|
||||||
|
* query: import("./ports/resource-ports.js").ResourceQueryPort<unknown, unknown>,
|
||||||
|
* command: import("./ports/resource-ports.js").ResourceCommandPort<unknown, unknown>
|
||||||
|
* },
|
||||||
|
* 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Request>,
|
||||||
|
* recover(): Promise<"restored" | "no-session">,
|
||||||
|
* onUnauthenticated(): void
|
||||||
|
* }} AuthSessionPort
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* now(): number,
|
||||||
|
* sleep(milliseconds: number, signal?: AbortSignal): Promise<void>
|
||||||
|
* }} 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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* read(key: readonly unknown[]): unknown,
|
||||||
|
* write(key: readonly unknown[], value: unknown): void,
|
||||||
|
* invalidate(namespace: readonly unknown[]): Promise<void>
|
||||||
|
* }} QueryCachePort
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* getCurrent(): Promise<{
|
||||||
|
* buildId: string,
|
||||||
|
* configSchemaVersion: string,
|
||||||
|
* apiContractVersion: string,
|
||||||
|
* assetManifestHash: string,
|
||||||
|
* releaseId: string
|
||||||
|
* }>
|
||||||
|
* }} ReleaseInfoPort
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* @template Query
|
||||||
|
* @template Model
|
||||||
|
* @typedef {{ execute(query: Query, context?: RequestContext): Promise<Result<Model>> }} ResourceQueryPort
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template Command
|
||||||
|
* @template Model
|
||||||
|
* @typedef {{ execute(command: Command, context?: RequestContext): Promise<Result<Model>> }} ResourceCommandPort
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* operationId: string,
|
||||||
|
* routeId: string,
|
||||||
|
* signal?: AbortSignal,
|
||||||
|
* idempotencyKey?: string
|
||||||
|
* }} RequestContext
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template Value
|
||||||
|
* @typedef {{ ok: true, value: Value, meta?: Record<string, unknown> } |
|
||||||
|
* { ok: false, error: import("../../contracts/errors.js").ApiFailure }} Result
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -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 {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{ emit(eventName: string, attributes: Record<string, unknown>): void }} TelemetryPort
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -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<Record<string, unknown>>,
|
||||||
|
* loadRelease(config: Record<string, unknown>): Promise<Record<string, unknown>>,
|
||||||
|
* createAdapters(context: {
|
||||||
|
* config: Record<string, unknown>,
|
||||||
|
* release: Record<string, unknown>
|
||||||
|
* }): Promise<Parameters<typeof createApplication>[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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user