Skip to main content

Overview

Prompts.dev uses a modular monolith architecture. The codebase is organized into focused internal modules with clear boundaries, but deployed as a single application.

System Components

The system consists of three main components:
┌─────────┐          ┌──────────────┐          ┌─────────────┐
│   CLI   │ ────────>│  API Server  │ ────────>│  PostgreSQL │
└─────────┘   HTTP   └──────────────┘   SQL    └─────────────┘

                            │ S3 API
                            v
                      ┌─────────────┐
                      │ Cloudflare  │
                      │     R2      │
                      └─────────────┘
  • CLI (cmd/cli) - Command-line interface for developers
  • API Server (cmd/api) - HTTP REST API built with Gin
  • PostgreSQL - Relational database for metadata storage
  • Cloudflare R2 - Object storage for prompt tarballs (S3-compatible)

Internal Modules

The internal/ directory contains focused modules, each with specific responsibilities:

Core Business Logic

// internal/auth
// OAuth login/callback flows
// JWT token issuance and validation
// Authentication middleware

Infrastructure

// internal/storage
// Cloudflare R2 client abstraction
// Presigned URL generation
// S3-compatible interface

Request Lifecycle

Every API request flows through these stages:
1

Request enters Gin router

All routes are mounted under /v1 for versioning:
v1 := r.Group("/v1")
v1.GET("/prompts", promptsHandler.Search)
v1.POST("/prompts", authMiddleware, promptsHandler.Create)
2

Request ID middleware

Assigns a unique UUID to each request and sets X-Request-ID header:
func requestIDMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        id := uuid.NewString()
        c.Set(requestIDKey, id)
        c.Writer.Header().Set("X-Request-ID", id)
        c.Next()
    }
}
3

Authentication middleware (protected routes only)

Validates JWT bearer token on protected routes:
authorized := v1.Group("/")
authorized.Use(authMiddleware.Authenticate())
{
    authorized.POST("/prompts", promptsHandler.Create)
    authorized.POST("/prompts/:id/versions", versionsHandler.Upload)
}
4

Handler executes business logic

Handler calls repository methods to interact with the database:
func (h *Handler) Create(c *gin.Context) {
    // Validate request
    // Extract user ID from context
    // Call repository
    p, err := h.repo.Create(ctx, prompt, tags)
    // Return response
}
5

Structured JSON response

Responses use a consistent envelope format:
{
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "data": { ... }
}
6

Request logging

Structured JSON logs include status code, latency, and metadata:
{
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "method": "POST",
  "path": "/v1/prompts",
  "status": 201,
  "latency_ms": 45,
  "user_id": "user_123"
}

Layer Architecture

The codebase follows a layered architecture pattern:
┌─────────────────────────────────────┐
│     API Handlers (HTTP Layer)       │  ← Gin handlers, request/response
├─────────────────────────────────────┤
│   Business Logic (Service Layer)    │  ← Services, domain logic
├─────────────────────────────────────┤
│  Data Access (Repository Layer)     │  ← GORM repositories
├─────────────────────────────────────┤
│      Database (PostgreSQL)           │  ← Persistent storage
└─────────────────────────────────────┘

Example: Prompts Module

// HTTP layer - handles requests and responses
type Handler struct {
    repo Repository
}

func (h *Handler) Create(c *gin.Context) {
    var req createPromptRequest
    c.ShouldBindJSON(&req)
    
    p, err := h.repo.Create(ctx, prompt, tags)
    
    server.RespondJSON(c, http.StatusCreated, p)
}

Authentication Architecture

Prompts.dev uses provider-agnostic OAuth with JWT tokens:
1

OAuth Login Initiation

Client requests login for a provider:
GET /v1/auth/{provider}/login
# Supported: github, google
Server redirects to OAuth provider’s authorization page.
2

OAuth Callback

Provider redirects back with authorization code:
GET /v1/auth/{provider}/callback?code=...
Server exchanges code for access token and fetches user profile.
3

User Creation or Linking

  • New user: Creates entry in users table
  • Existing identity: Links to existing user via user_identities
  • Auto-linking: Only enabled when provider email is verified
4

JWT Token Issuance

Server issues a signed JWT token:
{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "user": {
    "id": "user_123",
    "username": "alice",
    "email": "[email protected]"
  }
}

Database Schema

-- Users table
CREATE TABLE users (
    id UUID PRIMARY KEY,
    username VARCHAR UNIQUE,
    email VARCHAR,
    created_at TIMESTAMP
);

-- Provider identities (OAuth linking)
CREATE TABLE user_identities (
    id UUID PRIMARY KEY,
    user_id UUID REFERENCES users(id),
    provider VARCHAR,  -- 'github' or 'google'
    provider_id VARCHAR,
    UNIQUE(provider, provider_id)
);

Storage Architecture

Prompt tarballs are stored in Cloudflare R2 (S3-compatible object storage):

Upload Flow

1

Client uploads tarball

POST /v1/prompts/:id/versions
Content-Type: multipart/form-data
2

API uploads to R2

key := fmt.Sprintf("%s/%s/%s.tar.gz", owner, name, version)
storageClient.Upload(ctx, key, file, size, "application/gzip")
3

Metadata stored in database

INSERT INTO prompt_versions (id, prompt_id, version, tarball_url)
VALUES ('...', '...', '1.0.0', 's3://bucket/owner/name/1.0.0.tar.gz');

Download Flow

1

Client requests download

GET /v1/prompts/:owner/:name/versions/:version/download
2

API generates presigned URL

url, err := storageClient.GetPresignedURL(ctx, key, 15*time.Minute)
3

API redirects to presigned URL

HTTP 302 Found
Location: https://bucket.r2.cloudflarestorage.com/...?signature=...
4

Client downloads from R2 directly

Download happens directly from R2, reducing API load.
Presigned URLs expire after 15 minutes for security.

Module Boundaries

The architecture enforces clear boundaries:

Dependency Rules

  1. cmd/api composes concrete implementations
    // cmd/api/main.go
    usersRepo := users.NewGORMRepository(gdb)
    authService := auth.NewAuthService(cfg, usersRepo, identitiesRepo)
    authHandler := auth.NewHandler(authService, cfg)
    
  2. Modules interact through interfaces
    // Handler depends on interface, not concrete implementation
    type Handler struct {
        repo Repository  // interface
    }
    
  3. Shared response format in server package
    // All modules use common response helpers
    server.RespondJSON(c, status, data)
    server.RespondError(c, status, code, message)
    server.RespondValidationError(c, fieldErrors)
    
Modules should NOT import each other’s concrete implementations. Use interfaces for dependencies.

Error Handling

All API errors use a standard envelope format:
{
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request parameters",
    "fields": {
      "name": "must match ^[a-z0-9-]+$"
    }
  }
}
Common error codes:
  • VALIDATION_ERROR - Invalid request data
  • UNAUTHORIZED - Missing or invalid authentication
  • NOT_FOUND - Resource not found
  • CONFLICT - Resource already exists
  • INTERNAL_ERROR - Server error

Middleware Stack

The API server uses these middleware in order:
r.Use(requestIDMiddleware())     // 1. Assign request ID
r.Use(recoveryMiddleware())       // 2. Panic recovery
r.Use(loggerMiddleware(logger))   // 3. Structured logging
r.Use(corsMiddleware())           // 4. CORS headers
Protected routes add authentication:
authorized.Use(authMiddleware.Authenticate())

Database Migrations

Migrations are SQL files in the migrations/ directory:
-- 001_create_users.up.sql
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username VARCHAR(255) UNIQUE NOT NULL,
    email VARCHAR(255),
    created_at TIMESTAMP DEFAULT NOW()
);
Migrations run automatically on API startup using the migration runner in internal/db.
Migrations should be idempotent - safe to run multiple times without side effects.

Build docs developers (and LLMs) love