03 Explicit interfaces 6 chapters

Theme 06 · Agent-Aware

Explicit interfaces

Explanation

Explicit interfaces

Plain Human Explanation

Explicit interfaces make a boundary say what it needs and what it promises back. That matters because a future reader should not need to inspect a database client, framework request, or provider SDK to understand a product workflow.

For users, this keeps behavior steadier. When the contract is clear, a change is less likely to forget a required field, leak private data, or return a shape the caller cannot handle.

Technical Explanation

Use small input types, output types, and port interfaces at boundaries between product logic and outside systems. A port is a small interface for an external dependency, such as storage, email, billing, queues, or logging.

The goal is not to type every internal detail. The goal is to make the contract visible where another person or agent will extend the code.

Why It Matters

  • User impact: fewer broken calls between modules and fewer surprising response shapes.
  • Product behavior: important requirements are visible at the function boundary.
  • Risk: agents fill in hidden requirements from implementation details and miss a caller expectation.
  • Decision point: add explicit interfaces where data crosses a module, route, job, package, or provider boundary.

The Core Move

Name the boundary contract directly. Accept only what the workflow needs, return only what the caller can use, and put provider-specific details behind ports.

Small Example

Explicit interfaces: Small Example

Bad TypeScript Example

export async function sendReceipt(order: any, emailClient: any) {
  await emailClient.send(order.user.email, "Receipt", `Total: ${order.total}`);
}
export async function sendReceipt(
  order: any,
  emailClient: any,
) {
  await emailClient.send(
    order.user.email,
    "Receipt",
    `Total: ${order.total}`,
  );
}

Good TypeScript Example

type ReceiptOrder = {
  customerEmail: string;
  totalCents: number;
};

type ReceiptMailer = {
  send(to: string, subject: string, body: string): Promise<void>;
};

export async function sendReceipt(order: ReceiptOrder, mailer: ReceiptMailer) {
  const dollars = (order.totalCents / 100).toFixed(2);
  await mailer.send(order.customerEmail, "Receipt", `Total: $${dollars}`);
}
type ReceiptOrder = {
  customerEmail: string;
  totalCents: number;
};

type ReceiptMailer = {
  send(
    to: string,
    subject: string,
    body: string,
  ): Promise<void>;
};

export async function sendReceipt(
  order: ReceiptOrder,
  mailer: ReceiptMailer,
) {
  const dollars = (
    order.totalCents / 100
  ).toFixed(2);

  await mailer.send(
    order.customerEmail,
    "Receipt",
    `Total: $${dollars}`,
  );
}

What Changed

  • The bad version relies on a hidden nested order.user.email shape.
  • The good version names the fields the receipt workflow actually needs.
  • The mailer interface keeps the email provider details out of the product function.

Realistic Example

Explicit interfaces: Realistic Example

An export workflow touches permissions, storage, and queues. If those dependencies are passed in as broad objects, a future change can accidentally depend on private framework or provider details.

Bad TypeScript Example

export async function requestExport(ctx: any) {
  const user = await ctx.db.users.find(ctx.session.user.id);

  if (!user.plan.includes("pro")) {
    return { status: 403 };
  }

  const job = await ctx.queue.add("export", {
    user: ctx.session.user,
    includePrivateNotes: ctx.body.includePrivateNotes,
  });

  return { status: 202, job };
}
export async function requestExport(
  ctx: any,
) {
  const user = await ctx.db.users.find(
    ctx.session.user.id,
  );

  if (!user.plan.includes("pro")) {
    return {
      status: 403,
    };
  }

  const job = await ctx.queue.add(
    "export",
    {
      user: ctx.session.user,
      includePrivateNotes:
        ctx.body.includePrivateNotes,
    },
  );

  return {
    status: 202,
    job,
  };
}

Good TypeScript Example

type ExportRequest = {
  userId: string;
  includePrivateNotes: boolean;
};

type ExportRequestResult = { ok: true; jobId: string } | { ok: false; error: "plan-not-allowed" | "user-missing" };

type ExportAccess = {
  canRequestExport(userId: string): Promise<"allowed" | "blocked" | "missing">;
};

type ExportQueue = {
  enqueueExport(request: ExportRequest): Promise<{
    jobId: string;
  }>;
};

type ExportPorts = {
  access: ExportAccess;
  queue: ExportQueue;
};

export async function requestExport(request: ExportRequest, ports: ExportPorts): Promise<ExportRequestResult> {
  const access = await ports.access.canRequestExport(request.userId);

  if (access === "missing") return { ok: false, error: "user-missing" };
  if (access === "blocked") return { ok: false, error: "plan-not-allowed" };

  const job = await ports.queue.enqueueExport(request);
  return { ok: true, jobId: job.jobId };
}
type ExportRequest = {
  userId: string;
  includePrivateNotes: boolean;
};

type ExportRequestResult =
  | {
      ok: true;
      jobId: string;
    }
  | {
      ok: false;
      error:
        | "plan-not-allowed"
        | "user-missing";
    };

type ExportAccess = {
  canRequestExport(
    userId: string,
  ): Promise<
    "allowed" | "blocked" | "missing"
  >;
};

type ExportQueue = {
  enqueueExport(
    request: ExportRequest,
  ): Promise<{
    jobId: string;
  }>;
};

type ExportPorts = {
  access: ExportAccess;
  queue: ExportQueue;
};

export async function requestExport(
  request: ExportRequest,
  ports: ExportPorts,
): Promise<ExportRequestResult> {
  const access =
    await ports.access.canRequestExport(
      request.userId,
    );

  if (access === "missing")
    return {
      ok: false,
      error: "user-missing",
    };

  if (access === "blocked")
    return {
      ok: false,
      error: "plan-not-allowed",
    };

  const job =
    await ports.queue.enqueueExport(
      request,
    );

  return {
    ok: true,
    jobId: job.jobId,
  };
}

What Changed

  • The bad version exposes the workflow to the whole request context, database, session, and queue client.
  • The good version defines the request, result, access check, and queue boundary explicitly.
  • A route adapter can translate HTTP into this contract without making the product workflow depend on HTTP.

System Example

Explicit interfaces: System Example

At system scale, explicit interfaces let each layer keep its promise. The API layer handles HTTP, the workflow handles product behavior, and adapters handle provider-specific details.

Larger System-Level Bad TypeScript Example

export class SubscriptionController {
  constructor(private stripe: any, private db: any, private audit: any) {}

  async cancel(req: any, res: any) {
    const subscription = await this.stripe.subscriptions.cancel(req.params.id);
    await this.db.users.update(req.user.id, { plan: "free", subscription });
    await this.audit.write("cancel", { req, subscription });

    res.json({ ok: true, subscription });
  }
}
export class SubscriptionController {
  constructor(
    private stripe: any,
    private db: any,
    private audit: any,
  ) {}

  async cancel(req: any, res: any) {
    const subscription =
      await this.stripe.subscriptions.cancel(
        req.params.id,
      );

    await this.db.users.update(
      req.user.id,
      {
        plan: "free",
        subscription,
      },
    );

    await this.audit.write("cancel", {
      req,
      subscription,
    });

    res.json({
      ok: true,
      subscription,
    });
  }
}

Larger System-Level Good TypeScript Example

type CancelSubscriptionCommand = {
  actorId: string;
  subscriptionId: string;
};

type CancelSubscriptionResult = { ok: true; endedAt: Date } | { ok: false; error: "provider-rejected" };

type BillingProvider = {
  cancelSubscription(subscriptionId: string): Promise<{ endedAt: Date } | null>;
};

type SubscriptionStore = {
  markCanceled(actorId: string, subscriptionId: string, endedAt: Date): Promise<void>;
};

type AuditLog = {
  record(
    event: "subscription.canceled",
    fields: {
      actorId: string;
      subscriptionId: string;
    },
  ): Promise<void>;
};

type CancelSubscriptionPorts = {
  billing: BillingProvider;
  subscriptions: SubscriptionStore;
  audit: AuditLog;
};

export async function cancelSubscription(command: CancelSubscriptionCommand, ports: CancelSubscriptionPorts): Promise<CancelSubscriptionResult> {
  const cancellation = await ports.billing.cancelSubscription(command.subscriptionId);

  if (!cancellation) {
    return { ok: false, error: "provider-rejected" };
  }

  await ports.subscriptions.markCanceled(command.actorId, command.subscriptionId, cancellation.endedAt);
  await ports.audit.record("subscription.canceled", {
    actorId: command.actorId,
    subscriptionId: command.subscriptionId,
  });

  return { ok: true, endedAt: cancellation.endedAt };
}
type CancelSubscriptionCommand = {
  actorId: string;
  subscriptionId: string;
};

type CancelSubscriptionResult =
  | {
      ok: true;
      endedAt: Date;
    }
  | {
      ok: false;
      error: "provider-rejected";
    };

type BillingProvider = {
  cancelSubscription(
    subscriptionId: string,
  ): Promise<{
    endedAt: Date;
  } | null>;
};

type SubscriptionStore = {
  markCanceled(
    actorId: string,
    subscriptionId: string,
    endedAt: Date,
  ): Promise<void>;
};

type AuditLog = {
  record(
    event: "subscription.canceled",
    fields: {
      actorId: string;
      subscriptionId: string;
    },
  ): Promise<void>;
};

type CancelSubscriptionPorts = {
  billing: BillingProvider;
  subscriptions: SubscriptionStore;
  audit: AuditLog;
};

export async function cancelSubscription(
  command: CancelSubscriptionCommand,
  ports: CancelSubscriptionPorts,
): Promise<CancelSubscriptionResult> {
  const cancellation =
    await ports.billing.cancelSubscription(
      command.subscriptionId,
    );

  if (!cancellation) {
    return {
      ok: false,
      error: "provider-rejected",
    };
  }

  await ports.subscriptions.markCanceled(
    command.actorId,
    command.subscriptionId,
    cancellation.endedAt,
  );

  await ports.audit.record(
    "subscription.canceled",
    {
      actorId: command.actorId,
      subscriptionId:
        command.subscriptionId,
    },
  );

  return {
    ok: true,
    endedAt: cancellation.endedAt,
  };
}

What Changed

  • The bad version makes HTTP, provider data, persistence, and audit logging one implicit contract.
  • The good version gives each boundary a small interface with only the operations this workflow needs.
  • Provider-specific subscription objects do not leak into the API response or database update by accident.

When To Use It

Explicit interfaces: When To Use It

Use This When

  • A function crosses a route, job, module, package, provider, or persistence boundary.
  • Callers need a stable result shape.
  • A broad object is being passed mostly so the function can fish out two fields.

Avoid This When

  • The function is a tiny private helper with obvious inputs and no outside dependency.
  • A new interface would only rename a provider method without clarifying product behavior.
  • The contract is still unknown and the team is intentionally prototyping.

Tradeoffs

Explicit interfaces add names. That is worthwhile when the names make hidden requirements visible. If the interface only mirrors one implementation with no product meaning, it is probably noise.

  • Discoverability
  • Local conventions
  • Standards written for coding agents

Practice Prompt

Explicit interfaces: Practice Prompt

Beginner Exercise

Find one function that accepts any, a whole request object, or a whole service container. List the exact fields it actually reads.

Intermediate Exercise

Replace that broad input with a named command type and a named result type. Keep the old adapter outside the product function.

Stretch Exercise

Split one provider-heavy dependency into a small port that names the operation the workflow needs. Add a local fake implementation for a test or example.

Reflection Question

Which hidden requirement becomes visible when the boundary gets an explicit interface?

Suggest an edit

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