Where you are
You can reason about both type power and its limits. Architecture now organizes where that reasoning belongs. A shared types folder is not an architecture, and a generic framework cannot decide domain ownership for you.
Mental model
Domain code describes rules and valid transitions. Application services coordinate use cases. Adapters translate between those rules and external systems. Dependencies point toward the owned contracts, while the entry point supplies concrete implementations.
JavaScript reality
Import graphs execute module initialization. Cycles can expose partially initialized values, cause runtime errors, or make startup order significant. Type-only imports erase runtime edges but do not automatically make conceptual coupling healthy. A global singleton can hide resource ownership even if every property is typed.
TypeScript model
Small interfaces express capabilities a service needs. A storage port can return a domain value or absence without exposing SQL row types. DTOs describe external transfer contracts and may need different fields and versioning from domain objects. Derive types where they genuinely share ownership; keep separate schemas where their changes have different consequences.
Avoid universal entities imported by every package. An authentication identity, a database user row, and a public author card can be different models even if all carry an ID. A narrowly owned contract often scales better than one elaborate conditional type adapting a global model to every context.
Working example
Source: examples/valid/34.ts
type Job = { id: string; label: string };
interface JobReader {
find(id: string): Promise<Job | undefined>;
}
async function describe(reader: JobReader, id: string): Promise<string> {
const job = await reader.find(id);
return job ? `${job.id}: ${job.label}` : "not found";
}
const memory: JobReader = {
async find(id) {
return id === "j1" ? { id, label: "Import" } : undefined;
},
};
console.log(await describe(memory, "j1"));
export {};
Verified runtime output:
j1: Import
The service knows how to label a job and depends on a small reader capability. The memory adapter satisfies that structure without inheritance. An HTTP or database adapter can perform parsing before returning the same trusted domain value.
Type-checker drill
Intentionally invalid: examples/invalid/34.ts
interface JobReader {
find(id: string): Promise<{ label: string } | undefined>;
}
const reader: JobReader = {
async find() {
return { label: 42 };
},
};
export {};
Actual TypeScript 7.0.2 diagnostic:
examples/invalid/34.ts(5,9): error TS2322: Type '() => Promise<{ label: number; }>' is not assignable to type '(id: string) => Promise<{ label: string; } | undefined>'.
Type 'Promise<{ label: number; }>' is not assignable to type 'Promise<{ label: string; } | undefined>'.
Type '{ label: number; }' is not assignable to type '{ label: string; }'.
Types of property 'label' are incompatible.
Type 'number' is not assignable to type 'string'.
The port promises a string label. The storage representation must be translated at its adapter boundary. Widening the domain to accommodate one accidental numeric label spreads uncertainty to every consumer.
Runtime drill
Make the adapter throw after an I/O failure. Decide where the application translates that error, who logs it, and whether retry is safe. Then load malformed persisted data and prove that the adapter rejects it rather than asserting it into the domain model. Interfaces describe the outcome; adapters must establish it.
Professional pattern
Keep public entry points explicit in package exports and avoid sibling deep imports. Track dependency direction in review or a repository rule. Use one application service per coherent use case, not a class for every function. Make resource owners visible in construction and shutdown APIs.
For the capstone, separate job definitions from execution attempts. Retrying creates a new attempt, not a magical rewrite of an old success. Keep cancellation state, persistence revision, and stale-completion checks in domain operations. This prevents a late callback from silently overwriting a newer terminal state.
Exercise
Draw the capstone's package and runtime boundaries in a small diagram. Implement a job reader port and two adapters, one in memory and one persisted. Test the same service contract against both. Add a dependency rule preventing domain imports from HTTP or filesystem modules. Explain where validation, authorization, and error translation belong.
Checkpoint
- You can name the owner of each contract.
- You keep transfer models and domain rules distinct when needed.
- You can swap an adapter without rewriting the use case.
- You avoid global types and hidden resource singletons.