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'. Then declare an EventPublisher port with 'visitorLinkedToAccount(visitorId: string, accountId: string): Promise'. Awaiting an acknowledgement does not by itself make database write and publication atomic, but it prevents a rejected publication from being silently discarded.

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 }' and 'events: { consentGranted(accountId: string, kind: string): Promise }'. - input is { accountId: string; kind: string }. - It awaits consent.append(...), then awaits events.consentGranted(...), then returns nothing. Explain why an outbox is still required for atomic reliable delivery.

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: ' and name = 'AccountNotFound'. Then write an inbound-adapter helper that maps it to a 404 and rethrows anything else.

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?

Solution *import type* is for things that exist only at compile time: interfaces, type aliases, and a class used *only* as a type. A regular *import* is required when you need the thing at runtime: calling *normalizeEmail(...)*, constructing *new EmailAlreadyRegistered(...)*, or referencing a class with *instanceof*. So: use *import type* whenever you only annotate with the symbol; use a value *import* the moment you call/construct/*instanceof* it.