Skip to content

Instantly share code, notes, and snippets.

@luismartinezs
Created March 7, 2026 08:51
Show Gist options
  • Select an option

  • Save luismartinezs/9f0ff6018c720850ff497d3043193778 to your computer and use it in GitHub Desktop.

Select an option

Save luismartinezs/9f0ff6018c720850ff497d3043193778 to your computer and use it in GitHub Desktop.

Vertical Slice Architecture — Sample Module: credits

A single feature module from a production Express + Drizzle + Zod monorepo. Shows the patterns without the full template.

Folder Structure

src/modules/credits/
  index.ts                              # Public API (gatekeeper)
  router.ts                             # Express route wiring
  api/
    balance.controller.ts               # Thin HTTP adapter
  core/
    credit.schema.ts                    # Zod validation
    credit.service.ts                   # Pure business logic (DB ops)
  listeners/
    payment-success.listener.ts         # Event bus side-effect

Principles

  1. Group by Feature — everything credits needs lives inside src/modules/credits/.
  2. Public APIindex.ts exports only what other modules may use. Internals stay private.
  3. Zero Horizontal Coupling — modules never import each other's internals. Cross-module communication happens via a typed event bus.
  4. Thin Controllers — controllers parse the request, call a service, return a typed response. No business logic.
  5. Pure Services — all domain logic lives in core/. Pure functions, no classes, Zod for validation.
  6. Event-Driven Side Effects — when a payment succeeds, the payments module emits PAYMENT_SUCCEEDED. The credits listener reacts. Neither module knows the other exists.

Conventions

  • kebab-case for all filenames
  • camelCase for functions/variables
  • UPPER_SNAKE_CASE for constants
  • No classes — pure functions only
  • Zod schemas colocated with the feature, not in a global schemas/ folder
// src/modules/credits/api/balance.controller.ts
// Thin HTTP adapter. Parses request, calls service, returns typed response.
import type { Request } from 'express';
import * as CreditService from '../core/credit.service';
import { err } from '@shared/routes/error';
import type { CreditsBalanceResponse, ApiResponse } from '@repo/api-types';
export async function getBalance(req: Request): Promise<ApiResponse<CreditsBalanceResponse>> {
const userId = req.auth?.userId;
if (!userId) throw err('unauthorized', 'User not found');
const balance = await CreditService.getBalance(userId);
return { success: true, data: { balance } };
}
// src/modules/credits/core/credit.schema.ts
// Zod schemas colocated with the feature — not in a shared folder.
import { z } from 'zod';
export const addCreditsSchema = z.object({
userId: z.string().uuid(),
amount: z.number().int(),
reason: z.string().min(1),
metadata: z.record(z.unknown()).optional(),
});
export type AddCreditsInput = z.infer<typeof addCreditsSchema>;
// src/modules/credits/core/credit.service.ts
// Pure business logic. No HTTP, no Express, no framework coupling.
import { db, creditBalances, creditLedger } from '@repo/db';
import { eq, sql } from 'drizzle-orm';
import { addCreditsSchema, type AddCreditsInput } from './credit.schema';
export async function getBalance(userId: string): Promise<number> {
const result = await db.query.creditBalances.findFirst({
where: eq(creditBalances.userId, userId),
});
return result?.balance ?? 0;
}
export async function addCredits(input: AddCreditsInput): Promise<void> {
const { userId, amount, reason, metadata } = addCreditsSchema.parse(input);
await db.transaction(async (tx) => {
// 1. Append to immutable ledger
await tx.insert(creditLedger).values({ userId, amount, reason, metadata });
// 2. Upsert running balance
await tx
.insert(creditBalances)
.values({ userId, balance: amount })
.onConflictDoUpdate({
target: creditBalances.userId,
set: {
balance: sql`${creditBalances.balance} + ${amount}`,
lastUpdated: new Date(),
},
});
});
}
// src/modules/credits/index.ts
// Public API — the only legal entry point for other modules.
export { registerPaymentSuccessListener } from './listeners/payment-success.listener';
export { getBalance, addCredits } from './core/credit.service';
// src/modules/credits/listeners/payment-success.listener.ts
// Reacts to cross-module events. Credits module has zero knowledge of the payments module.
import { onEvent, type PaymentSucceededPayload } from '@shared/event-bus';
import * as CreditService from '../core/credit.service';
import { PRODUCTS } from '@repo/config';
import { logger } from '@shared/logger';
export function registerPaymentSuccessListener() {
onEvent('PAYMENT_SUCCEEDED', async (payload: PaymentSucceededPayload) => {
const { userId, productKey, amountCents } = payload;
const product = Object.values(PRODUCTS).find((p) => p.key === productKey);
if (!product || !('amount' in product)) return;
logger.info({ userId, amount: product.amount }, 'Granting credits for purchase');
await CreditService.addCredits({
userId,
amount: product.amount as number,
reason: 'purchase',
metadata: { productKey, amountCents, via: 'payment-listener' },
});
});
}
// src/modules/credits/router.ts
// Composition root for this slice — wires routes to controllers.
import { Router } from 'express';
import { getBalance } from './api/balance.controller';
import { requireAuth } from '@modules/auth';
import { route } from '@shared/routes/adapter';
const router = Router();
router.get('/balance', requireAuth, route(getBalance));
export const creditsRouter = router;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment