02 Local conventions 6 chapters

Theme 06 · Agent-Aware

Local conventions

Explanation

Local conventions

Plain Human Explanation

Local conventions are the patterns already used in the nearby code. They matter because most future changes should feel like they belong in the product, not like a new mini-framework arrived in one file.

For users, consistent local behavior prevents small mismatches: one settings page validates differently, one job retries differently, or one API route returns a shape the client does not expect.

Technical Explanation

Before inventing a new pattern, read the neighboring modules. Match their naming, result shapes, validation style, test style, and dependency boundaries unless there is a real reason to diverge.

This is especially useful for coding agents. Agents often generalize from examples. Strong local conventions turn nearby files into executable guidance.

Why It Matters

  • User impact: similar features behave consistently.
  • Product behavior: new code follows the same contract as the surrounding workflow.
  • Risk: a change that works alone can break expectations because it ignores the local pattern.
  • Decision point: follow local conventions when the feature sits inside an established module, flow, or API surface.

The Core Move

Treat the neighboring files as the first design document. Copy the proven shape, then change only what the feature actually needs.

Small Example

Local conventions: Small Example

Bad TypeScript Example

type ProfileUpdate = {
  displayName?: string;
};

export function updateDisplayName(input: ProfileUpdate) {
  if (!input.displayName) {
    throw new Error("Display name is required");
  }

  return { success: true, name: input.displayName.trim() };
}
type ProfileUpdate = {
  displayName?: string;
};

export function updateDisplayName(
  input: ProfileUpdate,
) {
  if (!input.displayName) {
    throw new Error(
      "Display name is required",
    );
  }

  return {
    success: true,
    name: input.displayName.trim(),
  };
}

Good TypeScript Example

type SettingsResult<Value> = { ok: true; value: Value } | { ok: false; error: "missing-value" | "too-long" };

type DisplayNameUpdate = {
  displayName: string;
};

export function updateDisplayName(input: DisplayNameUpdate): SettingsResult<{
  displayName: string;
}> {
  const displayName = input.displayName.trim();

  if (displayName.length === 0) {
    return { ok: false, error: "missing-value" };
  }

  if (displayName.length > 80) {
    return { ok: false, error: "too-long" };
  }

  return { ok: true, value: { displayName } };
}
type SettingsResult<Value> =
  | {
      ok: true;
      value: Value;
    }
  | {
      ok: false;
      error: "missing-value" | "too-long";
    };

type DisplayNameUpdate = {
  displayName: string;
};

export function updateDisplayName(
  input: DisplayNameUpdate,
): SettingsResult<{
  displayName: string;
}> {
  const displayName =
    input.displayName.trim();

  if (displayName.length === 0) {
    return {
      ok: false,
      error: "missing-value",
    };
  }

  if (displayName.length > 80) {
    return {
      ok: false,
      error: "too-long",
    };
  }

  return {
    ok: true,
    value: {
      displayName,
    },
  };
}

What Changed

  • The bad version invents a throw-based error style inside a settings flow.
  • The good version follows the local result shape that callers already know how to handle.
  • The input is required up front, so validation focuses on product rules instead of missing optional data.

Realistic Example

Local conventions: Realistic Example

A route added to an existing account module should match the account module’s command and result style. Otherwise the frontend and tests need special-case handling for one endpoint.

Bad TypeScript Example

type Request = {
  body: {
    userId?: string;
    email?: string;
  };
};

type Response = {
  json(value: unknown): void;
  status(code: number): Response;
};

export async function changeEmail(req: Request, res: Response, db: any) {
  if (!req.body.email) {
    res.status(400).json({ message: "Email required" });
    return;
  }

  await db.user.update({ id: req.body.userId, email: req.body.email });
  res.json({ changed: true });
}
type Request = {
  body: {
    userId?: string;
    email?: string;
  };
};

type Response = {
  json(value: unknown): void;
  status(code: number): Response;
};

export async function changeEmail(
  req: Request,
  res: Response,
  db: any,
) {
  if (!req.body.email) {
    res.status(400).json({
      message: "Email required",
    });

    return;
  }

  await db.user.update({
    id: req.body.userId,
    email: req.body.email,
  });

  res.json({
    changed: true,
  });
}

Good TypeScript Example

type AccountCommandResult =
  | { ok: true }
  | { ok: false; error: "missing-user" | "invalid-email" };

type ChangeAccountEmailCommand = {
  userId: string;
  email: string;
};

type AccountStore = {
  changeEmail(command: ChangeAccountEmailCommand): Promise<void>;
};

function looksLikeEmail(value: string): boolean {
  return value.includes("@") && value.includes(".");
}

export async function changeAccountEmail(
  command: ChangeAccountEmailCommand,
  store: AccountStore,
): Promise<AccountCommandResult> {
  if (command.userId.length === 0) {
    return { ok: false, error: "missing-user" };
  }

  if (!looksLikeEmail(command.email)) {
    return { ok: false, error: "invalid-email" };
  }

  await store.changeEmail(command);
  return { ok: true };
}
type AccountCommandResult =
  | {
      ok: true;
    }
  | {
      ok: false;
      error:
        | "missing-user"
        | "invalid-email";
    };

type ChangeAccountEmailCommand = {
  userId: string;
  email: string;
};

type AccountStore = {
  changeEmail(
    command: ChangeAccountEmailCommand,
  ): Promise<void>;
};

function looksLikeEmail(
  value: string,
): boolean {
  return (
    value.includes("@") &&
    value.includes(".")
  );
}

export async function changeAccountEmail(
  command: ChangeAccountEmailCommand,
  store: AccountStore,
): Promise<AccountCommandResult> {
  if (command.userId.length === 0) {
    return {
      ok: false,
      error: "missing-user",
    };
  }

  if (!looksLikeEmail(command.email)) {
    return {
      ok: false,
      error: "invalid-email",
    };
  }

  await store.changeEmail(command);

  return {
    ok: true,
  };
}

What Changed

  • The bad version mixes HTTP response handling, database access, and account rules in one route.
  • The good version uses the account module’s command and result style.
  • A route can translate HTTP details outside this function, while tests cover the same local pattern as nearby account commands.

System Example

Local conventions: System Example

At system scale, local conventions keep similar workflows predictable. If every billing job has a command, a ports object, and a typed result, the next billing job should usually use the same shape.

Larger System-Level Bad TypeScript Example

export async function retryInvoicePayment(job: any) {
  const stripe = job.container.stripe;
  const db = job.container.db;

  try {
    const invoice = await stripe.invoices.pay(job.data.invoice);
    await db.billing.update(job.data.user, { paid: true, invoice });
    return true;
  } catch (error) {
    console.error(error);
    return false;
  }
}
export async function retryInvoicePayment(
  job: any,
) {
  const stripe = job.container.stripe;

  const db = job.container.db;

  try {
    const invoice =
      await stripe.invoices.pay(
        job.data.invoice,
      );

    await db.billing.update(job.data.user, {
      paid: true,
      invoice,
    });

    return true;
  } catch (error) {
    console.error(error);

    return false;
  }
}

Larger System-Level Good TypeScript Example

type BillingJobResult =
  | { ok: true }
  | { ok: false; error: "payment-failed" | "subscription-missing" };

type RetryInvoicePaymentCommand = {
  userId: string;
  invoiceId: string;
};

type BillingJobPorts = {
  invoices: {
    pay(invoiceId: string): Promise<{ subscriptionId: string } | null>;
  };
  subscriptions: {
    markInvoicePaid(userId: string, subscriptionId: string): Promise<void>;
  };
  log: {
    warn(event: string, fields: Record<string, string>): void;
  };
};

export async function retryInvoicePayment(
  command: RetryInvoicePaymentCommand,
  ports: BillingJobPorts,
): Promise<BillingJobResult> {
  const payment = await ports.invoices.pay(command.invoiceId);

  if (!payment) {
    ports.log.warn("billing.invoice_payment_failed", { invoiceId: command.invoiceId });
    return { ok: false, error: "payment-failed" };
  }

  if (payment.subscriptionId.length === 0) {
    return { ok: false, error: "subscription-missing" };
  }

  await ports.subscriptions.markInvoicePaid(command.userId, payment.subscriptionId);
  return { ok: true };
}
type BillingJobResult =
  | {
      ok: true;
    }
  | {
      ok: false;
      error:
        | "payment-failed"
        | "subscription-missing";
    };

type RetryInvoicePaymentCommand = {
  userId: string;
  invoiceId: string;
};

type BillingJobPorts = {
  invoices: {
    pay(invoiceId: string): Promise<{
      subscriptionId: string;
    } | null>;
  };
  subscriptions: {
    markInvoicePaid(
      userId: string,
      subscriptionId: string,
    ): Promise<void>;
  };
  log: {
    warn(
      event: string,
      fields: Record<string, string>,
    ): void;
  };
};

export async function retryInvoicePayment(
  command: RetryInvoicePaymentCommand,
  ports: BillingJobPorts,
): Promise<BillingJobResult> {
  const payment = await ports.invoices.pay(
    command.invoiceId,
  );

  if (!payment) {
    ports.log.warn(
      "billing.invoice_payment_failed",
      {
        invoiceId: command.invoiceId,
      },
    );

    return {
      ok: false,
      error: "payment-failed",
    };
  }

  if (payment.subscriptionId.length === 0) {
    return {
      ok: false,
      error: "subscription-missing",
    };
  }

  await ports.subscriptions.markInvoicePaid(
    command.userId,
    payment.subscriptionId,
  );

  return {
    ok: true,
  };
}

What Changed

  • The bad version ignores the billing job style and pulls dependencies from a loose container.
  • The good version follows the same command, ports, result, and logging shape a billing module can repeat.
  • A future agent can add the next billing job by copying this local structure instead of inventing a new one.

When To Use It

Local conventions: When To Use It

Use This When

  • You are adding to an established module, route family, job family, or domain area.
  • Nearby code already has a clear result, validation, logging, or dependency pattern.
  • Consistency matters more than theoretical elegance.

Avoid This When

  • The local pattern is clearly broken and the change is specifically meant to repair it.
  • Copying the pattern would spread a security, data, or reliability bug.
  • The new behavior belongs in a different module with different ownership.

Tradeoffs

Local conventions can preserve some imperfect code shape. That is often worth it for small changes because consistency lowers product risk. When the local pattern is harmful, fix the pattern deliberately instead of quietly diverging.

  • Discoverability
  • Explicit interfaces
  • Checklists

Practice Prompt

Local conventions: Practice Prompt

Beginner Exercise

Open three nearby files in one module. Write down the result shape, naming style, and dependency style they share.

Intermediate Exercise

Refactor one outlier function so it follows the local result or command pattern without changing its product behavior.

Stretch Exercise

Add a short module-level example that future contributors can copy. Keep it close to the code and remove any generic rule that does not apply locally.

Reflection Question

When should this module follow the nearby pattern, and what evidence would justify intentionally changing the pattern?

Suggest an edit

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