03: TypeScript: ports, dependency injection, error classes¶
This is the heart of the hexagonal style in the TS services (identity-service, personalization-service). The use case depends on interfaces (ports), and the configured adapters (Keycloak, Postgres, Kafka) are passed in. You practice wiring that without a dependency-injection framework.
3.1: A port expressed as an interface¶
Concept. A port is an interface the application layer declares and depends on. Infrastructure provides a concrete implementation. The use case never names the implementation, only the interface, so replacement stays behind the port boundary.
Example.
// Shape used under identity-service/src/application/ports/ (illustrative names).
import type { Account } from '../domain/identity/account.js';
export interface AccountRepository {
insert(account: Account): Promise<void>;
exists(accountId: string): Promise<boolean>;
}
// IdentityProvider owns credentials (Keycloak). Returns the new subject id.
export interface IdentityProvider {
createUser(registration: { email: string; password: string }): Promise<string>;
}
Your exercise. Declare an outbound port VisitorRepository with a single method
'link(visitorId: string, accountId: string): Promise
Solution
export interface VisitorRepository {
link(visitorId: string, accountId: string): Promise<void>;
}
export interface EventPublisher {
visitorLinkedToAccount(visitorId: string, accountId: string): Promise<void>;
}
Reliable delivery requires a transactional outbox or equivalent durable mechanism. The current
best-effort publisher is a documented implementation gap, not the pattern this exercise recommends.
3.2: Dependency injection via a Deps object¶
Concept. Instead of a use case importing concrete adapters, it takes a deps object whose fields are ports. The composition root builds the configured deps; tests pass fakes. registerAccount follows this pattern.
Example.
// Illustrative use case following identity-service/src/application/use-cases/.
export interface RegisterAccountDeps {
identityProvider: IdentityProvider;
accounts: AccountRepository;
visitors: VisitorRepository;
events: EventPublisher;
}
export async function registerAccount(
deps: RegisterAccountDeps,
input: { email: string; password: string; visitorId?: string },
): Promise<Account> {
const subject = await deps.identityProvider.createUser(input); // 1. credentials first (uniqueness check)
const account: Account = { id: subject, email: input.email, displayName: null };
await deps.accounts.insert(account); // 2. persist the account
if (input.visitorId) {
await deps.visitors.link(input.visitorId, subject); // 3. link prior activity
await deps.events.visitorLinkedToAccount(input.visitorId, subject);
}
return account;
}
Read the ordering: the identity provider is the authoritative uniqueness check, so it runs first. A production design also needs idempotency and compensation/reconciliation if the Keycloak create succeeds but account persistence fails.
Your exercise. Write a use case grantConsent(deps, input) where:
- deps has 'consent: { append(entry): Promise
Solution
interface GrantConsentDeps {
consent: { append(entry: { accountId: string; kind: string }): Promise<void> };
events: { consentGranted(accountId: string, kind: string): Promise<void> };
}
async function grantConsent(
deps: GrantConsentDeps,
input: { accountId: string; kind: string },
): Promise<void> {
await deps.consent.append(input);
await deps.events.consentGranted(input.accountId, input.kind);
}
3.3: Implementing a port with a fake (for tests)¶
Concept. Because a port is an interface, a test fake is any object with the same methods. No mocking library needed. This is why ports make the application testable in isolation.
Example.
const fakeAccounts: AccountRepository = {
insert: async () => {}, // no-op
exists: async () => false,
};
Your exercise. Write an in-memory AccountRepository fake backed by a Set of ids: insert adds the id; exists checks membership. Then show a one-liner that inserts an account and asserts exists returns true.
Solution
function makeAccounts(): AccountRepository {
const ids = new Set<string>();
return {
insert: async (a) => { ids.add(a.id); },
exists: async (id) => ids.has(id),
};
}
const repo = makeAccounts();
await repo.insert({ id: 'u1', email: 'a@b.c', displayName: null });
console.assert(await repo.exists('u1') === true);
That closure-over-a-*Set* is a complete, dependency-free fake adapter.
3.4: Custom error classes as domain signals¶
Concept. A domain error is a class extends Error with a stable name. The application layer catches it and maps it to an HTTP status. The error is the contract between layers: no string matching.
Example.
// From identity-service domain/identity/account.ts.
export class EmailAlreadyRegistered extends Error {
constructor(email: string) {
super(`email already registered: ${email}`);
this.name = 'EmailAlreadyRegistered'; // useful for logs; not a cross-boundary contract
}
}
Your exercise. Write AccountNotFound extends Error taking an accountId,
producing the message 'account not found:
Solution
export class AccountNotFound extends Error {
constructor(accountId: string) {
super(`account not found: ${accountId}`);
this.name = 'AccountNotFound';
}
}
function toHttp(err: unknown): { status: number } {
if (err instanceof AccountNotFound) return { status: 404 };
throw err; // not ours, let it propagate
}
*instanceof* works within this runtime because *AccountNotFound* is a runtime class. The inbound HTTP
adapter maps the application/domain error to a status; neither the domain nor use case knows about
HTTP. Use an explicit stable error code for serialized or cross-process errors.
3.5: import type and the layering boundary¶
Concept. import type { X } imports only the type, erased at compile time: no runtime dependency is created. The TS services use this so a port file can reference domain types without pulling runtime code across a boundary the ESLint no-restricted-imports rules guard.
Example.
import type { Account, Registration } from '../domain/identity/account.js';
// ^ types only: this line vanishes in the compiled JS. No import cycle, no runtime coupling.
Note the .js extension on a .ts import: that's required by this repo's ESM/NodeNext module setup: you write the output extension.
Your exercise (reading, not writing). Why does the application layer use import type for ports/domain but a regular import for a value like normalizeEmail? When is import type impossible?