# API Layer

> Use when creating or modifying API functions in */external/ directories or data-fetching hooks. Enforces Supabase patterns, mapping conventions, and N+1 prevention.

- Skill: `bumgeunsong/api-layer` (Agent Skill)
- Install (CLI): `npx skillmds@latest add bumgeunsong/api-layer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bumgeunsong/api-layer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: bumgeunsong (https://skillmd.com/u/bumgeunsong)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bumgeunsong/api-layer

---


# API Layer Patterns

## Boundary Zone: `external/`

All raw Supabase access and Supabase-generated types live **only** in a feature's
`external/` directory (or `shared/external/`). This is lint-enforced:

- `getSupabaseClient` may be imported **only** inside `*/external/**`. hooks,
  components, and utils must call a feature's `external/` `fetch*`/`read*`
  functions — which return **domain types** — never the raw client.
- `@/shared/external/database.types` (generated schema types) may be imported
  **only** inside `external/`. UI code deals in domain types, not row/DTO shapes.

`external/` is the one place a raw row shape or a boundary cast may exist;
everything outside it is structurally confined to domain types. The client is
typed (`createClient<Database>`), so `.from(...).select(...)` results are inferred
from the schema — parse and map at the boundary instead of casting.

## Canonical References

- `apps/web/src/post/external/post.ts`
- `apps/web/src/shared/external/supabaseClient.ts`
- `apps/web/src/comment/external/comment.ts`

## Function Structure

```typescript
import { getSupabaseClient } from '@/shared/external/supabaseClient';
import type { Post } from '../model/Post';

export async function fetchRecentPostsFromSupabase(boardId: string, limitCount: number): Promise<Post[]> {
  const supabase = getSupabaseClient();
  const { data, error } = await supabase
    .from('posts')
    .select(FEED_POST_SELECT)
    .eq('board_id', boardId)
    .order('created_at', { ascending: false })
    .limit(limitCount);

  if (error) throw error;
  return (data || []).map(mapRowToPost);
}
```

## Column Allowlist Pattern (`FEED_*_SELECT`)

```typescript
export const FEED_POST_SELECT =
  'id, board_id, author_id, author_name, title, content_preview, thumbnail_image_url, visibility, count_of_comments, count_of_replies, count_of_likes, engagement_score, week_days_from_first_day, created_at, updated_at, comments(count), replies(count)';
```

Use explicit column strings for feed/list queries. Avoid broad `*` in list contexts.

## Mapping Convention (`mapRowToX`)

```typescript
function mapRowToComment(row: {
  id: string;
  user_id: string;
  user_name: string;
  user_profile_image: string | null;
  created_at: string;
  content: string;
}): Comment {
  return {
    id: row.id,
    userId: row.user_id,
    userName: row.user_name,
    userProfileImage: row.user_profile_image || '',
    content: row.content,
    createdAt: createTimestamp(new Date(row.created_at)),
  };
}
```

Supabase rows stay `snake_case`; domain models stay `camelCase`.

## Write Error Handling (`SupabaseWriteError` + `executeTrackedWrite`)

```typescript
import { executeTrackedWrite, throwOnError } from '@/shared/external/supabaseClient';

export async function createComment(postId: string, content: string, userId: string) {
  const supabase = getSupabaseClient();
  await executeTrackedWrite('createComment', () =>
    supabase.from('comments').insert({
      post_id: postId,
      user_id: userId,
      content,
      created_at: new Date().toISOString(),
    }),
  );
}

export async function deleteComment(commentId: string) {
  const supabase = getSupabaseClient();
  throwOnError(await supabase.from('comments').delete().eq('id', commentId));
}
```

`throwOnError`/`executeTrackedWrite` surface `SupabaseWriteError` and `SupabaseNetworkError` from one shared place.

## N+1 Prevention: Batch-First Hook Convention

Expose list-context data fetchers as one batch hook taking `ids: string[]` and returning `Set<id>` or `Map<id, T>`. Do not create a singular wrapper that delegates to the batch hook with a single-element array.

