Full-Stack Development Practices

SkillProductivity

Full-stack backend architecture and frontend-backend integration guide. TRIGGER when: building a full-stack app, creating REST API with frontend, scaffolding backend service, building todo app, building CRUD app, building real-time app, building chat app, Express + React, Next.js API, Node.js backend, Python backend, Go backend, designing service layers, implementing error handling, managing config/auth, setting up API clients, implementing auth flows, handling file uploads, adding real-time features (SSE/WebSocket), hardening for production. DO NOT TRIGGER when: pure frontend UI work, pure CSS/styling, database schema only.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Full-Stack Development Practices skill

What this skill tells your AI

The instructions your AI receives, as published by infometa/workbuddyskills in skills/fullstack-dev/SKILL.md and read by ahel’s review.

MANDATORY WORKFLOW — Follow These Steps In Order

When this skill is triggered, you MUST follow this workflow before writing any code.

Step 0: Gather Requirements

Before scaffolding anything, ask the user to clarify (or infer from context):

  1. Stack: Language/framework for backend and frontend (e.g., Express + React, Django + Vue, Go + HTMX)
  2. Service type: API-only, full-stack monolith, or microservice?
  3. Database: SQL (PostgreSQL, SQLite, MySQL) or NoSQL (MongoDB, Redis)?
  4. Integration: REST, GraphQL, tRPC, or gRPC?
  5. Real-time: Needed? If yes — SSE, WebSocket, or polling?
  6. Auth: Needed? If yes — JWT, session, OAuth, or third-party (Clerk, Auth.js)?

If the user has already specified these in their request, skip asking and proceed.

Step 1: Architectural Decisions

Based on requirements, make and state these decisions before coding:

DecisionOptionsReference
Project structureFeature-first (recommended) vs layer-firstSection 1
API client approachTyped fetch / React Query / tRPC / OpenAPI codegenSection 5
Auth strategyJWT + refresh / session / third-partySection 6
Real-time methodPolling / SSE / WebSocketSection 11
Error handlingTyped error hierarchy + global handlerSection 3

Briefly explain each choice (1 sentence per decision).

Step 2: Scaffold with Checklist

Use the appropriate checklist below. Ensure ALL checked items are implemented — do not skip any.

Step 3: Implement Following Patterns

Write code following the patterns in this document. Reference specific sections as you implement each part.

Step 4: Test & Verify

After implementation, run these checks before claiming completion:

  1. Build check: Ensure both backend and frontend compile without errors
    # Backend
    cd server && npm run build
    # Frontend
    cd client && npm run build
    
  2. Start & smoke test: Start the server, verify key endpoints return expected responses
    # Start server, then test
    curl http://localhost:3000/health
    curl http://localhost:3000/api/<resource>
    
  3. Integration check: Verify frontend can connect to backend (CORS, API base URL, auth flow)
  4. Real-time check (if applicable): Open two browser tabs, verify changes sync

If any check fails, fix the issue before proceeding.

Step 5: Handoff Summary

Provide a brief summary to the user:

  • What was built: List of implemented features and endpoints
  • How to run: Exact commands to start backend and frontend
  • What's missing / next steps: Any deferred items, known limitations, or recommended improvements
  • Key files: List the most important files the user should know about

Scope

USE this skill when:

  • Building a full-stack application (backend + frontend)
  • Scaffolding a new backend service or API
  • Designing service layers and module boundaries
  • Implementing database access, caching, or background jobs
  • Writing error handling, logging, or configuration management
  • Reviewing backend code for architectural issues
  • Hardening for production
  • Setting up API clients, auth flows, file uploads, or real-time features

NOT for:

  • Pure frontend/UI concerns (use your frontend framework's docs)
  • Pure database schema design without backend context

Quick Start — New Backend Service Checklist

  • Project scaffolded with feature-first structure
  • Configuration centralized, env vars validated at startup (fail fast)
  • Typed error hierarchy defined (not generic Error)
  • Global error handler middleware
  • Structured JSON logging with request ID propagation
  • Database: migrations set up, connection pooling configured
  • Input validation on all endpoints (Zod / Pydantic / Go validator)
  • Authentication middleware in place
  • Health check endpoints (/health, /ready)
  • Graceful shutdown handling (SIGTERM)
  • CORS configured (explicit origins, not *)
  • Security headers (helmet or equivalent)
  • .env.example committed (no real secrets)

Quick Start — Frontend-Backend Integration Checklist

  • API client configured (typed fetch wrapper, React Query, tRPC, or OpenAPI generated)
  • Base URL from environment variable (not hardcoded)
  • Auth token attached to requests automatically (interceptor / middleware)
  • Error handling — API errors mapped to user-facing messages
  • Loading states handled (skeleton/spinner, not blank screen)
  • Type safety across the boundary (shared types, OpenAPI, or tRPC)
  • CORS configured with explicit origins (not * in production)
  • Refresh token flow implemented (httpOnly cookie + transparent retry on 401)

Quick Navigation

Need to…Jump to
Organize project folders1. Project Structure
Manage config + secrets2. Configuration
Handle errors properly3. Error Handling
Write database code4. Database Access Patterns
Set up API client from frontend5. API Client Patterns
Add auth middleware6. Auth & Middleware
Set up logging7. Logging & Observability
Add background jobs8. Background Jobs
Implement caching9. Caching
Upload files (presigned URL, multipart)10. File Upload Patterns
Add real-time features (SSE, WebSocket)11. Real-Time Patterns
Handle API errors in frontend UI12. Cross-Boundary Error Handling
Harden for production13. Production Hardening
Design API endpointsAPI Design
Design database schemaDatabase Schema
Auth flow (JWT, refresh, Next.js SSR, RBAC)references/auth-flow.md
CORS, env vars, environment managementreferences/environment-management.md

Core Principles (7 Iron Rules)

1. ✅ Organize by FEATURE, not by technical layer
2. ✅ Controllers never contain business logic
3. ✅ Services never import HTTP request/response types
4. ✅ All config from env vars, validated at startup, fail fast
5. ✅ Every error is typed, logged, and returns consistent format
6. ✅ All input validated at the boundary — trust nothing from client
7. ✅ Structured JSON logging with request ID — not console.log

1. Project Structure & Layering (CRITICAL)

Feature-First Organization

✅ Feature-first                    ❌ Layer-first
src/                                src/
  orders/                             controllers/
    order.controller.ts                 order.controller.ts
    order.service.ts                    user.controller.ts
    order.repository.ts               services/
    order.dto.ts                        order.service.ts
    order.test.ts                       user.service.ts
  users/                              repositories/
    user.controller.ts                  ...
    user.service.ts
  shared/
    database/
    middleware/

Three-Layer Architecture

Controller (HTTP) → Service (Business Logic) → Repository (Data Access)
LayerResponsibility❌ Never
ControllerParse request, validate, call service, format responseBusiness logic, DB queries
ServiceBusiness rules, orchestration, transaction mgmtHTTP types (req/res), direct DB
RepositoryDatabase queries, external API callsBusiness logic, HTTP types

Dependency Injection (All Languages)

TypeScript:

class OrderService {
  constructor(
    private readonly orderRepo: OrderRepository,    // ✅ injected interface
    private readonly emailService: EmailService,
  ) {}
}

Python:

class OrderService:
    def __init__(self, order_repo: OrderRepository, email_service: EmailService):
        self.order_repo = order_repo                 # ✅ injected
        self.email_service = email_service

Go:

type OrderService struct {
    orderRepo    OrderRepository                      // ✅ interface
    emailService EmailService
}

func NewOrderService(repo OrderRepository, email EmailService) *OrderService {
    return &OrderService{orderRepo: repo, emailService: email}
}

2. Configuration & Environment (CRITICAL)

Centralized, Typed, Fail-Fast

TypeScript:

const config = {
  port: parseInt(process.env.PORT || '3000', 10),
  database: { url: requiredEnv('DATABASE_URL'), poolSize: intEnv('DB_POOL_SIZE', 10) },
  auth: { jwtSecret: requiredEnv('JWT_SECRET'), expiresIn: process.env.JWT_EXPIRES_IN || '1h' },
} as const;

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required env var: ${name}`);  // fail fast
  return value;
}

Python:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str                        # required — app won't start without it
    jwt_secret: str                          # required
    port: int = 3000                         # optional with default
    db_pool_size: int = 10
    class Config:
        env_file = ".env"

settings = Settings()                        # fails fast if DATABASE_URL missing

Rules

✅ All config via environment variables (Twelve-Factor)
✅ Validate required vars at startup — fail fast
✅ Type-cast at config layer, not at usage sites
✅ Commit .env.example with dummy values

❌ Never hardcode secrets, URLs, or credentials
❌ Never commit .env files
❌ Never scatter process.env / os.environ throughout code

3. Error Handling & Resilience (HIGH)

Typed Error Hierarchy

// Base (TypeScript)
class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode: number,
    public readonly isOperational: boolean = true,
  ) { super(message); }
}
class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(`${resource} not found: ${id}`, 'NOT_FOUND', 404);
  }
}
class ValidationError extends AppError {
  constructor(public readonly errors: FieldError[]) {
    super('Validation failed', 'VALIDATION_ERROR', 422);
  }
}
# Base (Python)
class AppError(Exception):
    def __init__(self, message: str, code: str, status_code: int):
        self.message, self.code, self.status_code = message, code, status_code

class NotFoundError(AppError):
    def __init__(self, resource: str, id: str):
        super().__init__(f"{resource} not found: {id}", "NOT_FOUND", 404)

Global Error Handler

// TypeScript (Express)
app.use((err, req, res, next) => {
  if (err instanceof AppError && err.isOperational) {
    return res.status(err.statusCode).json({
      title: err.code, status: err.statusCode,
      detail: err.message, request_id: req.id,
    });
  }
  logger.error('Unexpected error', { error: err.message, stack: err.stack, request_id: req.id });
  res.status(500).json({ title: 'Internal Error', status: 500, request_id: req.id });
});

Rules

✅ Typed, domain-specific error classes
✅ Global error handler catches everything
✅ Operational errors → structured response
✅ Programming errors → log + generic 500
✅ Retry transient failures with exponential backoff

❌ Never catch and ignore errors silently
❌ Never return stack traces to client
❌ Never throw generic Error('something')

4. Database Access Patterns (HIGH)

Migrations Always

# TypeScript (Prisma)           # Python (Alembic)              # Go (golang-migrate)
npx prisma migrate dev          alembic revision --autogenerate  migrate -source file://migrations
npx prisma migrate deploy       alembic upgrade head             migrate -database $DB up
✅ Schema changes via migrations, never manual SQL
✅ Migrations must be reversible
✅ Review migration SQL before production
❌ Never modify production schema manually

N+1 Prevention

// ❌ N+1: 1 query + N queries
const orders = await db.order.findMany();
for (const o of orders) { o.items = await db.item.findMany({ where: { orderId: o.id } }); }

// ✅ Single JOIN query
const orders = await db.order.findMany({ include: { items: true } });

Transactions for Multi-Step Writes

await db.$transaction(async (tx) => {
  const order = await tx.order.create({ data: orderData });
  await tx.inventory.decrement({ productId, quantity });
  await tx.payment.create({ orderId: order.id, amount });
});

Connection Pooling

Pool size = (CPU cores × 2) + spindle_count (start with 10-20). Always set connection timeout. Use PgBouncer for serverless.


5. API Client Patterns (MEDIUM)

The "glue layer" between frontend and backend. Choose the approach that fits your team and stack.

Option A: Typed Fetch Wrapper (Simple, No Dependencies)

// lib/api-client.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';

class ApiError extends Error {
  constructor(public status: number, public body: any) {
    super(body?.detail || body?.message || `API error ${status}`);
  }
}

async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
  const token = getAuthToken();  // from cookie / memory / context

  const res = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...options.headers,
    },
  });

  if (!res.ok) {
    const body = await res.json().catch(() => null);
    throw new ApiError(res.status, body);
  }

  if (res.status === 204) return undefined as T;
  return res.json();
}

export const apiClient = {
  get: <T>(path: string) => api<T>(path),
  post: <T>(path: string, data: unknown) => api<T>(path, { method: 'POST', body: JSON.stringify(data) }),
  put: <T>(path: string, data: unknown) => api<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
  patch: <T>(path: string, data: unknown) => api<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
  delete: <T>(path: string) => api<T>(path, { method: 'DELETE' }),
};

Option B: React Query + Typed Client (Recommended for React)

// hooks/use-orders.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';

interface Order { id: string; total: number; status: string; }
interface CreateOrderInput { items: { productId: string; quantity: number }[] }

export function useOrders() {
  return useQuery({
    queryKey: ['orders'],
    queryFn: () => apiClient.get<{ data: Order[] }>('/api/orders'),
    staleTime: 1000 * 60,  // 1 min
  });
}

export function useCreateOrder() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (data: CreateOrderInput) =>
      apiClient.post<{ data: Order }>('/api/orders', data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    },
  });
}

// Usage in component:
function OrdersPage() {
  const { data, isLoading, error } = useOrders();
  const createOrder = useCreateOrder();
  if (isLoading) return <Skeleton />;
  if (error) return <ErrorBanner error={error} />;
  // ...
}

Option C: tRPC (Same Team Owns Both Sides)

// server: trpc/router.ts
export const appRouter = router({
  orders: router({
    list: publicProcedure.query(async () => {
      return db.order.findMany({ include: { items: true } });
    }),
    create: protectedProcedure
      .input(z.object({ items: z.array(orderItemSchema) }))
      .mutation(async ({ input, ctx }) => {
        return orderService.create(ctx.user.id, input);
      }),
  }),
});
export type AppRouter = typeof appRouter;

// client: automatic type safety, no code generation
const { data } = trpc.orders.list.useQuery();
const createOrder = trpc.orders.create.useMutation();

Option D: OpenAPI Generated Client (Public / Multi-Consumer APIs)

npx openapi-typescript-codegen \
  --input http://localhost:3001/api/openapi.json \
  --output src/generated/api \
  --client axios

Decision: Which API Client?

ApproachWhenType SafetyEffort
Typed fetch wrapperSimple apps, small teamsManual typesLow
React Query + fetchReact apps, server stateManual typesMedium
tRPCSame team, TypeScript both sidesAutomaticLow
OpenAPI generatedPublic API, multi-consumerAutomaticMedium
GraphQL codegenGraphQL APIsAutomaticMedium

6. Authentication & Middleware (HIGH)

Full reference: references/auth-flow.md — JWT bearer flow, automatic token refresh, Next.js server-side auth, RBAC pattern, backend middleware order.

Standard Middleware Order

Request → 1.RequestID → 2.Logging → 3.CORS → 4.RateLimit → 5.BodyParse
       → 6.Auth → 7.Authz → 8.Validation → 9.Handler → 10.ErrorHandler → Response

JWT Rules

✅ Short expiry access token (15min) + refresh token (server-stored)
✅ Minimal claims: userId, roles (not entire user object)
✅ Rotate signing keys periodically

❌ Never store tokens in localStorage (XSS risk)
❌ Never pass tokens in URL query params

RBAC Pattern

function authorize(...roles: Role[]) {
  return (req, res, next) => {
    if (!req.user) throw new UnauthorizedError();
    if (!roles.some(r => req.user.roles.includes(r))) throw new ForbiddenError();
    next();
  };
}
router.delete('/users/:id', authenticate, authorize('admin'), deleteUser);

Auth Token Automatic Refresh

// lib/api-client.ts — transparent refresh on 401
async function apiWithRefresh<T>(path: string, options: RequestInit = {}): Promise<T> {
  try {
    return await api<T>(path, options);
  } catch (err) {
    if (err instanceof ApiError && err.status === 401) {
      const refreshed = await api<{ accessToken: string }>('/api/auth/refresh', {
        method: 'POST',
        credentials: 'include',  // send httpOnly cookie
      });
      setAuthToken(refreshed.accessToken);
      return api<T>(path, options);  // retry
    }
    throw err;
  }
}

7. Logging & Observability (MEDIUM-HIGH)

Structured JSON Logging

// ✅ Structured — parseable, filterable, alertable
logger.info('Order created', {
  orderId: order.id, userId: user.id, total: order.total,
  items: order.items.length, duration_ms: Date.now() - startTime,
});
// Output: {"level":"info","msg":"Order created","orderId":"ord_123",...}

// ❌ Unstructured — useless at scale
console.log(`Order created for user ${user.id} with total ${order.total}`);

Log Levels

LevelWhenProduction?
errorRequires immediate attention✅ Always
warnUnexpected but handled✅ Always
infoNormal operations, audit trail✅ Always
debugDev troubleshooting❌ Dev only

Rules

✅ Request ID in every log entry (propagated via middleware)
✅ Log at layer boundaries (request in, response out, external call)
❌ Never log passwords, tokens, PII, or secrets
❌ Never use console.log in production code

8. Background Jobs & Async (MEDIUM)

Rules

✅ All jobs must be IDEMPOTENT (same job running twice = same result)
✅ Failed jobs → retry (max 3) → dead letter queue → alert
✅ Workers run as SEPARATE processes (not threads in API server)

❌ Never put long-running tasks in request handlers
❌ Never assume job runs exactly once

Idempotent Job Pattern

async function processPayment(data: { orderId: string }) {
  const order = await orderRepo.findById(data.orderId);
  if (order.paymentStatus === 'completed') return;  // already processed
  await paymentGateway.charge(order);
  await orderRepo.updatePaymentStatus(order.id, 'completed');
}

9. Caching Patterns (MEDIUM)

Cache-Aside (Lazy Loading)

async function getUser(id: string): Promise<User> {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);

  const user = await userRepo.findById(id);
  if (!user) throw new NotFoundError('User', id);

  await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 900);  // 15min TTL
  return user;
}

Rules

✅ ALWAYS set TTL — never cache without expiry
✅ Invalidate on write (delete cache key after update)
✅ Use cache for reads, never for authoritative state

❌ Never cache without TTL (stale data is worse than slow data)
Data TypeSuggested TTL
User profile5-15 min
Product catalog1-5 min
Config / feature flags30-60 sec
SessionMatch session duration

10. File Upload Patterns (MEDIUM)

Option A: Presigned URL (Recommended for Large Files)

Client → GET /api/uploads/presign?filename=photo.jpg&type=image/jpeg
Server → { uploadUrl: "https://s3.../presigned", fileKey: "uploads/abc123.jpg" }
Client → PUT uploadUrl (direct to S3, bypasses your server)
Client → POST /api/photos { fileKey: "uploads/abc123.jpg" }  (save reference)

Backend:

app.get('/api/uploads/presign', authenticate, async (req, res) => {
  const { filename, type } = req.query;
  const key = `uploads/${crypto.randomUUID()}-${filename}`;
  const url = await s3.getSignedUrl('putObject', {
    Bucket: process.env.S3_BUCKET, Key: key,
    ContentType: type, Expires: 300,  // 5 min
  });
  res.json({ uploadUrl: url, fileKey: key });
});

Frontend:

async function uploadFile(file: File) {
  const { uploadUrl, fileKey } = await apiClient.get<PresignResponse>(
    `/api/uploads/presign?filename=${file.name}&type=${file.type}`
  );
  await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
  return apiClient.post('/api/photos', { fileKey });
}

Option B: Multipart (Small Files < 10MB)

// Frontend
const formData = new FormData();
formData.append('file', file);
formData.append('description', 'Profile photo');
const res = await fetch('/api/upload', { method: 'POST', body: formData });
// Note: do NOT set Content-Type header — browser sets boundary automatically

Decision

MethodFile SizeServer LoadComplexity
Presigned URLAny (recommended > 5MB)None (direct to storage)Medium
Multipart< 10MBHigh (streams through server)Low
Chunked / Resumable> 100MBMediumHigh

11. Real-Time Patterns (MEDIUM)

Option A: Server-Sent Events (SSE) — One-Way Server → Client

Best for: notifications, live feeds, streaming AI responses.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
292
Forks
96
Last commit
Aug 2026

Others that do the same job

Advanced
Catalog kind
skill
Gateway key
fullstack-dev
Source
github.com/infometa/workbuddyskills