DEV Community

Cover image for I Open-Sourced My Production Backend Architecture — Here's What's Inside
Typosbro
Typosbro

Posted on

I Open-Sourced My Production Backend Architecture — Here's What's Inside

Most "starter templates" give you a hello-world endpoint and wish you luck. When I started building my production app on Cloudflare Workers, I spent weeks figuring out how to structure the codebase so it wouldn't become a mess at 50+ endpoints.

I extracted all of that into an open-source template. No AI inference, no business logic — just the infrastructure patterns that took me months to get right.

GitHub: cloudflare-hono-starter

The Problem

Every Cloudflare Workers tutorial shows you this:

export default {
  fetch(request: Request, env: Env) {
    return new Response("Hello World!");
  },
};
Enter fullscreen mode Exit fullscreen mode

Then you're on your own for:

  • Where do routes go? How do I organize 30 endpoints?
  • How do I validate requests and generate API docs?
  • Where does business logic live vs database queries?
  • How do I add auth, rate limiting, cron jobs?
  • How do I test any of this?

I needed answers to all of these. So I built them.

The Stack

Layer Technology
Runtime Cloudflare Workers
Framework Hono + Zod OpenAPI
Database Cloudflare D1 (SQLite at the edge)
ORM Drizzle ORM
Validation Zod
Docs Auto-generated Swagger UI

Zero Node.js dependencies. Password hashing uses the Web Crypto API. JWT uses Hono's built-in utilities. Everything runs natively on the Workers runtime.

The Architecture: Vertical Slices

Instead of grouping by technical layer (all controllers together, all services together, all models together), each feature is a self-contained folder:

src/features/posts/
├── api/
│   ├── posts.contract.ts    # Zod schemas + OpenAPI route definitions
│   └── posts.routes.ts      # HTTP handlers
├── core/
│   └── posts.service.ts     # Business logic
└── data/
    └── posts.repository.ts  # Database queries (Drizzle)
Enter fullscreen mode Exit fullscreen mode

Data flows in one direction:

HTTP Request → Contract (validates) → Route → Service → Repository → D1
Enter fullscreen mode Exit fullscreen mode

Why this matters: when you need to add a "comments" feature, you create src/features/comments/ and you're done. You don't touch auth, posts, or anything else. The Open/Closed Principle in action.

Show Me the Code

1. Contracts — Define Once, Validate Everywhere

Each route starts with a Zod schema that doubles as OpenAPI documentation:

// src/features/posts/api/posts.contract.ts
export const CreatePostSchema = z
  .object({
    title: z.string().min(1, "Title is required").max(200)
      .openapi({ example: "Getting Started with Hono" }),
    content: z.string().min(1, "Content is required")
      .openapi({ example: "Hono is a fast web framework..." }),
    published: z.boolean().default(false),
  })
  .openapi("CreatePost");

export const createPostRoute = createRoute({
  method: "post",
  path: "/",
  tags: ["Posts"],
  security: [{ BearerAuth: [] }],
  request: {
    body: { content: { "application/json": { schema: CreatePostSchema } } },
  },
  responses: {
    201: {
      description: "Post created",
      content: { "application/json": { schema: PostSchema } },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

This single definition gives you:

  • Request validation — invalid payloads get a 400 with field-level errors automatically
  • TypeScript typesc.req.valid("json") is fully typed
  • Swagger UI — interactive docs at /docs with zero extra work

2. Services — Business Logic, No HTTP

Services don't know about HTTP. They receive a repository through the constructor and focus purely on business rules:

// src/features/posts/core/posts.service.ts
export class PostsService {
  constructor(private repo: PostsRepository) {}

  async create(authorId: string, data: { title: string; content: string; published?: boolean }) {
    const now = new Date().toISOString();
    return this.repo.create({
      id: crypto.randomUUID(),
      title: data.title,
      content: data.content,
      published: data.published ?? false,
      authorId,
      createdAt: now,
      updatedAt: now,
    });
  }

  async getById(id: string, authorId: string) {
    const post = await this.repo.findByIdAndAuthor(id, authorId);
    if (!post) throw new PostNotFoundError();
    return post;
  }
}
Enter fullscreen mode Exit fullscreen mode

Because the service depends on the repository interface (not a concrete DB implementation), testing is trivial:

// posts.service.test.ts
const repo = {
  findByIdAndAuthor: vi.fn(),
  create: vi.fn(),
  // ...
};
const service = new PostsService(repo);

it("throws when post not found", async () => {
  repo.findByIdAndAuthor.mockResolvedValue(undefined);
  await expect(service.getById("missing", "user-1"))
    .rejects.toThrow(PostNotFoundError);
});
Enter fullscreen mode Exit fullscreen mode

3. Repositories — Type-Safe Queries with Drizzle

No raw SQL. Everything goes through Drizzle ORM:

// src/features/posts/data/posts.repository.ts
export class PostsRepository {
  constructor(private db: Database) {}

  async listByAuthor(authorId: string, limit: number, offset: number) {
    const [data, countResult] = await Promise.all([
      this.db.query.posts.findMany({
        where: eq(posts.authorId, authorId),
        orderBy: desc(posts.createdAt),
        limit,
        offset,
      }),
      this.db
        .select({ count: sql<number>`count(*)` })
        .from(posts)
        .where(eq(posts.authorId, authorId)),
    ]);
    return { data, total: countResult[0].count };
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Dependency Injection — Per Request, Zero Cost

The ServiceContainer lazily creates services. Unused services never get instantiated:

// src/di.ts
export class ServiceContainer {
  private _posts?: PostsService;

  constructor(private env: Bindings) {
    this.db = createDb(env.DB);
  }

  get posts(): PostsService {
    if (!this._posts) {
      this._posts = new PostsService(new PostsRepository(this.db));
    }
    return this._posts;
  }
}

// In a route handler — one line
const services = new ServiceContainer(c.env);
const post = await services.posts.getById(id, userId);
Enter fullscreen mode Exit fullscreen mode

No DI framework. No decorators. Just lazy getters. The container is instantiated per request, so there's no shared mutable state between requests.

What's Included Beyond CRUD

The template isn't just a to-do app. It includes patterns I needed in production:

Rate Limiting (IP-Based Sliding Window)

// Presets ready to use
app.use("/api/auth/*", authRateLimit);       // 20 req/min
app.use("/api/sensitive/*", strictRateLimit); // 5 req/5min
Enter fullscreen mode Exit fullscreen mode

Uses D1 for state — no Redis, no external services. The rate limiter gracefully degrades if the table doesn't exist yet.

Composable Auth Middleware

The auth middleware is a factory function, not a hardcoded singleton:

// Default — uses the main DB
postsApp.use("/*", protectAndLoadUser);

// Custom — point at a different D1 database
const customAuth = createAuthMiddleware((env) => env.OTHER_DB);
otherApp.use("/*", customAuth);
Enter fullscreen mode Exit fullscreen mode

A/B Testing with Sticky Assignments

Full feature with weighted random variant selection:

// User always gets the same variant (sticky)
const assignment = await service.getOrAssignVariant(userId, "pricing-test");
// → { variantKey: "treatment", config: { price: 14.99 } }
Enter fullscreen mode Exit fullscreen mode

Includes admin endpoints for managing experiments, updating weights, and viewing assignment stats.

Cron Jobs, Queues, and Durable Objects

// Type-safe cron registry
const CRON_JOBS: CronRegistry = {
  "0 3 * * *": cleanupExpiredSubscriptions,
  "0 4 * * *": cleanupRateLimits,
};

// Typed queue consumer with per-message ack/retry
for (const msg of batch.messages) {
  try {
    await processJob(msg.body); // fully typed
    msg.ack();
  } catch {
    msg.retry(); // Cloudflare retries with backoff
  }
}
Enter fullscreen mode Exit fullscreen mode

Fire-and-Forget with waitUntil()

Session tracking, analytics, and cleanup never block the response:

c.executionCtx.waitUntil(
  trackSession(db, userId) // runs after response is sent
);
Enter fullscreen mode Exit fullscreen mode

Getting Started

It's a GitHub template — one click:

# Option 1: GitHub CLI
gh repo create my-api --template TyposBro/cloudflare-hono-starter --clone
cd my-api && npm install

# Option 2: GitHub UI
# Click "Use this template" on the repo page
Enter fullscreen mode Exit fullscreen mode

Then:

npx wrangler d1 create my-app-db    # create your database
cp .dev.vars.example .dev.vars      # set JWT secrets
npm run db:migrate:local             # run migrations
npm run dev                          # http://localhost:8787
Enter fullscreen mode Exit fullscreen mode

Swagger UI is at http://localhost:8787/docs. Every endpoint is documented automatically.

Adding Your Own Feature

The whole point of the template is that adding features is mechanical:

  1. mkdir -p src/features/comments/{api,core,data}
  2. Add a table to src/db/schema.ts
  3. Run npm run db:generate to create the migration
  4. Build the slice: repository → service → contract → routes
  5. Add a getter to src/di.ts
  6. Mount with one line: app.route("/api/comments", commentsApp)

No global files to hunt through. No "where does this go?" Just follow the pattern.

The Numbers

  • 37 source files covering auth, CRUD, A/B testing, cron, queues, durable objects
  • 8 middleware (auth, admin, rate limit, CORS, error handling, session tracking, subscription, version check)
  • 20 unit tests with vitest
  • CI pipeline with typecheck + tests on every push
  • Zero Node.js dependencies — runs natively on Workers

If you've been looking for a serious starting point for Cloudflare Workers that goes beyond hello-world, give it a try. PRs welcome.

GitHub: TyposBro/cloudflare-hono-starter

Top comments (0)