Service Architecture
SkillDev toolsStructure every domain using @repo/api-client (preferred) or as three independent tiers — HTTP/Drizzle implementation, business logic factory, and wiring composition
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Service Architecture skill
What this skill tells your AI
The instructions your AI receives, as published by cliqrelay/cliqrelay in .agents/skills/frontend/service-architecture/SKILL.md and read by ahel’s review.
This project has two service architectures depending on whether @repo/api-client covers the domain:
- Primary: Use
@repo/api-clientdirectly — no custom service layers needed - Fallback: Three-tier architecture (implementation → factory → composition)
Primary Architecture: Using @repo/api-client
Web App (TanStack Start): Server Functions
Route loader calls server function, which calls api.*:
Route Loader
│
▼ (calls server function from server-fns/)
createServerFn({...}).handler(async () => {
│
▼ (imports api from @repo/api-client)
return api.guides.*(...)
│
▼
API Server (Go backend)
Folder Structure
apps/web/src/
├── server-fns/
│ ├── guides.ts ← Server functions wrapping api.guides.*
│ └── steps.ts
├── routes/
│ ├── dashboard/
│ │ ├── index.tsx ← Loader calls getAllGuides()
│ │ └── guides/
│ │ └── $guideId.tsx ← Loader calls getGuideById()
│ └── ...
Server Function Example
// server-fns/guides.ts
import { createServerFn } from "@tanstack/react-start";
import { api } from "@repo/api-client";
import type { GuideUpdateInput } from "@/models";
export const getAllGuides = createServerFn({ method: "GET" })
.handler(async () => {
const { data, status } = await api.guides.getAllGuides();
if (status !== 200) {
console.error("Failed to fetch guides:", data);
throw new Error("Failed to fetch guides");
}
return data.guides;
});
export const getGuideById = createServerFn({ method: "GET" })
.inputValidator((guideId: string) => guideId)
.handler(async ({ data: guideId }) => {
const response = await api.guides.getGuideById(guideId);
if (response.status !== 200 || !response.data?.guide) {
return null;
}
return response.data.guide;
});
export const createGuide = createServerFn({ method: "POST" })
.inputValidator((input: { title: string; description?: string }) => input)
.handler(async ({ data }) => {
const response = await api.guides.createGuide({
title: data.title,
description: data.description ?? null,
});
if (response.status !== 201 || !response.data?.guide) {
console.error("Failed to create guide:", response.data);
return null;
}
return response.data.guide;
});
Route Loader Example
// routes/dashboard/guides/index.tsx
import { api } from "@repo/api-client";
// OR import from server-fns:
import { getAllGuides } from "@/server-fns/guides";
export const Route = createFileRoute("/dashboard/guides/")({
loader: async () => {
// Option A: call server-fn
const guides = await getAllGuides();
// Option B: call api-client directly (simpler when no caching needed)
// const guides = await api.guides.getAllGuides();
return { guides };
},
component: GuidesPage,
});
Rules (api-client pattern):
- Server functions go in
server-fns/{domain}.ts - One function per API operation
- Import
apifrom@repo/api-client - Check
response.statusandresponse.datafor error handling - Route loaders call server functions (or api-client directly for simple cases)
- No factory functions, no DI, no composition needed
Extension App (WXT / SPA): Use @repo/api-client Directly
The extension uses @repo/api-client directly for all API calls. The SDK's api.* functions use fetch under the hood, so no manual HTTP implementations, factory functions, or service layers are needed:
// Programmatic usage (background scripts, non-React):
import { api } from "@repo/api-client"
│
api.guides.*(...)
│
▼
API Server
// React components (auto-generated hooks):
import { useGetAllGuides } from "@repo/api-client"
│
useGetAllGuides()
│
▼
API Server
// ✅ DO — Use api.* for programmatic calls
import { api } from "@repo/api-client";
async function handlePublish(guideId: string) {
const response = await api.guides.publishGuide(guideId);
if (response.status >= 400 || !response.data?.guide) {
throw new Error("Failed to publish guide");
}
return response.data.guide;
}
// ✅ DO — Use auto-generated hooks in React components
import { useGetAllGuides, useCreateGuide } from "@repo/api-client";
function GuideList() {
const { data, isLoading } = useGetAllGuides();
const createMutation = useCreateGuide({ ... });
// ...
}
Rules (extension api-client pattern):
- Import
apifrom@repo/api-clientfor programmatic HTTP calls - Import hooks from
@repo/api-clientfor React components - No manual
http-{domain}.service.tsfiles — the SDK usesfetchinternally - No factory functions, no DI, no composition
- No
constants/api-endpoints.ts— URLs are handled by the SDK
Fallback Architecture: Three-Tier (Non-api-client Domains)
For domains not covered by @repo/api-client, use the traditional three-tier architecture.
This is built on the functional-dependency-injection pattern.
Architecture A: Web App (Server-side / Drizzle)
Tier 1: Drizzle Implementation → services/server/drizzle-{domain}.service.ts
Tier 2: Business Logic → services/{domain}/{domain}.service.ts
Tier 3: Composition → services/{domain}/{domain}.composition.ts
Architecture B: Extension App (Client-side / HTTP)
Tier 1: HTTP Implementation → services/{domain}/http-{domain}.service.ts
Tier 2: Business Logic → services/{domain}/{domain}.service.ts
Wiring: → services/{domain}/index.ts
Tier Details (Fallback)
Tier 1 (Web): services/server/drizzle-{domain}.service.ts — One async function per DB operation.
export const drizzleCreateGuide = async (db: DB, input: GuideCreateInput): Promise<Guide> => {
const [guide] = await db.insert(guidesTable).values(input).returning();
return guide;
};
Tier 1 (Web): services/server/drizzle-{domain}.service.ts — One async function per DB operation.
export const drizzleCreateGuide = async (db: DB, input: GuideCreateInput): Promise<Guide> => {
const [guide] = await db.insert(guidesTable).values(input).returning();
return guide;
};
Tier 2: services/{domain}/{domain}.service.ts — Factory functions receiving dependencies via DI.
export const createGuideFactory = (createGuide: CreateGuide) => {
return async (input: GuideCreateInput): Promise<Guide> => createGuide(input);
};
Tier 3 (Web): services/{domain}/{domain}.composition.ts — Wires factories to Drizzle implementations.
export const guideService = {
createGuide: createGuideFactory((input) => drizzleCreateGuide(db, input)),
};
Data Flow Comparison
api-client (Preferred):
Web: Component → Loader → Server Function → api.* → API Server
Ext: Component → use*() Hook → API Server
Ext: Background script → api.* → API Server
Fallback (Web, non-api-client domains):
Component → Composition → Factory → Drizzle → DB
Rules Summary
✅ DO
- Use
@repo/api-client+ server functions (web) as the primary pattern - Use
@repo/api-clientdirectly (bothapi.*and hooks) for the extension - Fall back to three-tier architecture only for non-api-client domains
- Keep tiers independent — factories never import implementations directly
❌ DON'T
- Don't create
http-{domain}.service.tsfiles for domains covered by@repo/api-client - Don't mix api-client and three-tier patterns for the same domain
- Don't create factory/service files for domains covered by
@repo/api-client - Don't define types that already exist in
@repo/api-client - Don't instantiate classes — use factory functions + closures for fallback domains
Signals
- GitHub stars
- 40
- Forks
- 1
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
service-architecture- Source
- github.com/cliqrelay/cliqrelay