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
runandexecute. - 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
sectiondecide 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
runExportReadyJobfunction 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.
Related Concepts
- 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.