01 Discoverability 6 chapters

Theme 06 · Agent-Aware

Discoverability

Explanation

Discoverability

Plain Human Explanation

Discoverability means the next person, or the next coding agent, can find the right place to make a change before touching the wrong one.

For users, this reduces accidental behavior changes. If the billing email, invite flow, or export job lives behind vague names like helpers.ts or processThing, a well-meaning change can land in the wrong path and create confusing product behavior.

Technical Explanation

Make important behavior visible through names, folders, exported entrypoints, and small registries. A reader should be able to search for the product word, open the likely module, and see the main path without reverse-engineering unrelated utilities.

Good discoverability is not extra documentation pasted far away. It is code shape that points to ownership: clear module names, explicit route or job registries, stable handler names, and comments only where they explain why a surprising path exists.

Why It Matters

  • User impact: fewer accidental changes to the wrong workflow.
  • Product behavior: names and entrypoints point to the feature people actually experience.
  • Risk: agents often search first and infer ownership from nearby names; vague code gives them bad hints.
  • Decision point: invest in discoverability when a feature has multiple similar paths, high user impact, or a history of wrong-file edits.

The Core Move

Put the important path where a reader will naturally look, then name it in the same language the product uses.

Small Example

Discoverability: Small Example

Bad TypeScript Example

type Handler = (input: unknown) => Promise<void>;

const actions: Record<string, Handler> = {
  run: async input => {
    // Sends the team invitation email.
  },
};

export async function execute(actionName: string, input: unknown) {
  const action = actions[actionName];
  if (!action) return;

  await action(input);
}
type Handler = (
  input: unknown,
) => Promise<void>;

const actions: Record<string, Handler> = {
  run: async (input) => {
    // Sends the team invitation email.
  },
};

export async function execute(
  actionName: string,
  input: unknown,
) {
  const action = actions[actionName];

  if (!action) return;

  await action(input);
}

Good TypeScript Example

type InviteCommand = {
  teamId: string;
  email: string;
};

type InviteHandler = (command: InviteCommand) => Promise<void>;

type InviteAction = {
  id: "send-team-invite";
  productArea: "team-invitations";
  handler: InviteHandler;
};

const sendTeamInvite: InviteAction = {
  id: "send-team-invite",
  productArea: "team-invitations",
  async handler(command) {
    // Sends the team invitation email.
  },
};

export async function sendTeamInviteEmail(command: InviteCommand) {
  await sendTeamInvite.handler(command);
}
type InviteCommand = {
  teamId: string;
  email: string;
};

type InviteHandler = (
  command: InviteCommand,
) => Promise<void>;

type InviteAction = {
  id: "send-team-invite";
  productArea: "team-invitations";
  handler: InviteHandler;
};

const sendTeamInvite: InviteAction = {
  id: "send-team-invite",
  productArea: "team-invitations",
  async handler(command) {
    // Sends the team invitation email.
  },
};

export async function sendTeamInviteEmail(
  command: InviteCommand,
) {
  await sendTeamInvite.handler(command);
}

What Changed

  • The bad version hides a user-facing action behind run and execute.
  • The good version names the product behavior directly: sendTeamInviteEmail.
  • The action metadata gives search, review, and agent edits a clear product-area anchor.

Realistic Example

Discoverability: Realistic Example

A settings page can grow many save paths. If account settings, notification settings, and security settings all pass through one vague function, future changes become guesswork.

Bad TypeScript Example

type Request = {
  body: Record<string, unknown>;
};

type AppServices = {
  db: {
    update(table: string, id: string, values: Record<string, unknown>): Promise<void>;
  };
};

export async function save(req: Request, services: AppServices) {
  const id = String(req.body.id);
  const section = String(req.body.section);

  await services.db.update(section, id, req.body);

  return { ok: true };
}
type Request = {
  body: Record<string, unknown>;
};

type AppServices = {
  db: {
    update(
      table: string,
      id: string,
      values: Record<string, unknown>,
    ): Promise<void>;
  };
};

export async function save(
  req: Request,
  services: AppServices,
) {
  const id = String(req.body.id);

  const section = String(req.body.section);

  await services.db.update(
    section,
    id,
    req.body,
  );

  return {
    ok: true,
  };
}

Good TypeScript Example

type NotificationSettingsInput = {
  userId: string;
  marketingEmails: boolean;
  productEmails: boolean;
};

type NotificationSettingsStore = {
  saveNotificationSettings(input: NotificationSettingsInput): Promise<void>;
};

type SaveNotificationSettingsResult =
  | { ok: true }
  | { ok: false; error: "missing-user-id" };

export async function saveNotificationSettings(
  input: NotificationSettingsInput,
  store: NotificationSettingsStore,
): Promise<SaveNotificationSettingsResult> {
  if (input.userId.length === 0) {
    return { ok: false, error: "missing-user-id" };
  }

  await store.saveNotificationSettings(input);
  return { ok: true };
}
type NotificationSettingsInput = {
  userId: string;
  marketingEmails: boolean;
  productEmails: boolean;
};

type NotificationSettingsStore = {
  saveNotificationSettings(
    input: NotificationSettingsInput,
  ): Promise<void>;
};

type SaveNotificationSettingsResult =
  | {
      ok: true;
    }
  | {
      ok: false;
      error: "missing-user-id";
    };

export async function saveNotificationSettings(
  input: NotificationSettingsInput,
  store: NotificationSettingsStore,
): Promise<SaveNotificationSettingsResult> {
  if (input.userId.length === 0) {
    return {
      ok: false,
      error: "missing-user-id",
    };
  }

  await store.saveNotificationSettings(
    input,
  );

  return {
    ok: true,
  };
}

What Changed

  • The bad version makes section decide which product workflow is being changed.
  • The good version gives notification settings their own named input, store method, and result.
  • A future reader can search for “notification settings” and land on the real workflow quickly.

System Example

Discoverability: System Example

At system scale, discoverability helps people distinguish similar workflows before editing one of them. A route registry, job registry, or module index can be useful when it points to real ownership instead of hiding behavior behind generic strings.

Larger System-Level Bad TypeScript Example

type JobPayload = Record<string, unknown>;

type WorkerServices = {
  db: {
    save(name: string, payload: JobPayload): Promise<void>;
  };
  email: {
    send(to: string, subject: string): Promise<void>;
  };
};

const jobs: Record<string, (payload: JobPayload, services: WorkerServices) => Promise<void>> = {
  sync: async (payload, services) => {
    await services.db.save("sync", payload);
  },
  notify: async (payload, services) => {
    await services.email.send(String(payload.email), "Your export is ready");
  },
};

export async function runJob(name: string, payload: JobPayload, services: WorkerServices) {
  await jobs[name](payload, services);
}
type JobPayload = Record<string, unknown>;

type WorkerServices = {
  db: {
    save(
      name: string,
      payload: JobPayload,
    ): Promise<void>;
  };
  email: {
    send(
      to: string,
      subject: string,
    ): Promise<void>;
  };
};

const jobs: Record<
  string,
  (
    payload: JobPayload,
    services: WorkerServices,
  ) => Promise<void>
> = {
  sync: async (payload, services) => {
    await services.db.save("sync", payload);
  },
  notify: async (payload, services) => {
    await services.email.send(
      String(payload.email),
      "Your export is ready",
    );
  },
};

export async function runJob(
  name: string,
  payload: JobPayload,
  services: WorkerServices,
) {
  await jobs[name](payload, services);
}

Larger System-Level Good TypeScript Example

type ExportReadyPayload = {
  exportId: string;
  requesterEmail: string;
};

type ExportReadyPorts = {
  exports: {
    markReady(exportId: string): Promise<void>;
  };
  email: {
    send(to: string, subject: string): Promise<void>;
  };
};

type JobDefinition<Payload, Ports> = {
  id: string;
  productArea: string;
  ownerModule: string;
  run(payload: Payload, ports: Ports): Promise<void>;
};

export const exportReadyJob: JobDefinition<ExportReadyPayload, ExportReadyPorts> = {
  id: "exports.ready-notification",
  productArea: "exports",
  ownerModule: "exports/export-ready-job.ts",
  async run(payload, ports) {
    await ports.exports.markReady(payload.exportId);
    await ports.email.send(payload.requesterEmail, "Your export is ready");
  },
};

export async function runExportReadyJob(payload: ExportReadyPayload, ports: ExportReadyPorts) {
  await exportReadyJob.run(payload, ports);
}
type ExportReadyPayload = {
  exportId: string;
  requesterEmail: string;
};

type ExportReadyPorts = {
  exports: {
    markReady(
      exportId: string,
    ): Promise<void>;
  };
  email: {
    send(
      to: string,
      subject: string,
    ): Promise<void>;
  };
};

type JobDefinition<Payload, Ports> = {
  id: string;
  productArea: string;
  ownerModule: string;
  run(
    payload: Payload,
    ports: Ports,
  ): Promise<void>;
};

export const exportReadyJob: JobDefinition<
  ExportReadyPayload,
  ExportReadyPorts
> = {
  id: "exports.ready-notification",
  productArea: "exports",
  ownerModule:
    "exports/export-ready-job.ts",
  async run(payload, ports) {
    await ports.exports.markReady(
      payload.exportId,
    );

    await ports.email.send(
      payload.requesterEmail,
      "Your export is ready",
    );
  },
};

export async function runExportReadyJob(
  payload: ExportReadyPayload,
  ports: ExportReadyPorts,
) {
  await exportReadyJob.run(payload, ports);
}

What Changed

  • The bad version has generic job names that do not reveal the product behavior.
  • The good version makes the job id, product area, owner module, payload, and ports visible together.
  • The exported runExportReadyJob function gives agents a safe, obvious edit target.

When To Use It

Discoverability: When To Use It

Use This When

  • A feature has several similar routes, jobs, handlers, or modules.
  • The wrong edit could change customer-facing behavior, data, billing, permissions, or notifications.
  • People regularly ask “where does this live?” before making changes.

Avoid This When

  • The code is already small, local, and obvious from the filename.
  • A registry would become another stale map that nobody trusts.
  • The extra naming ceremony would be larger than the behavior it explains.

Tradeoffs

Discoverability adds a little naming and structure. The payoff is faster, safer edits because readers can find the owned path before they change behavior.

  • Local conventions
  • Explicit interfaces
  • Standards written for coding agents

Practice Prompt

Discoverability: Practice Prompt

Beginner Exercise

Pick one vague file, function, or registry key. Rename it so a reader can tell which user-facing behavior it owns.

Intermediate Exercise

Take one route or job with a broad payload and create a named input type plus a named exported function for the real product action.

Stretch Exercise

Create a small feature index for a module with several similar handlers. Include the handler id, product area, and owner module without adding extra runtime behavior.

Reflection Question

If a coding agent searched for the product term, would it find the right edit target before finding a misleading one?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.