Skip to content

DOMAIN:BACKEND:ARCHITECTURE_PATTERNS

OWNER: urszula (Team Alfa), maxim (Team Bravo)
UPDATED: 2026-03-24
SCOPE: backend architecture patterns for GE client projects
STACK: TypeScript, Hono, Drizzle, PostgreSQL, Redis


ARCH:VERTICAL_SLICE_VS_LAYERED

LAYERED_ARCHITECTURE (TRADITIONAL)

src/
  controllers/     ← all controllers
  services/        ← all services
  repositories/    ← all repositories
  models/          ← all models
  middleware/       ← all middleware

PROBLEM: changing one feature touches files across many directories
PROBLEM: high coupling between layers, large blast radius per change
PROBLEM: developers must hold mental model of entire layer to modify one feature

VERTICAL_SLICE_ARCHITECTURE (GE DEFAULT)

src/
  features/
    users/
      users.routes.ts      ← Hono routes
      users.service.ts     ← business logic
      users.repository.ts  ← data access
      users.schemas.ts     ← Zod schemas + types
      users.test.ts        ← tests
    projects/
      projects.routes.ts
      projects.service.ts
      projects.repository.ts
      projects.schemas.ts
      projects.test.ts
    tasks/
      ...
  shared/
    db/                     ← Drizzle schema + connection
    middleware/              ← shared middleware (auth, logging)
    lib/                    ← shared utilities (Result, branded types)

ADVANTAGE: one feature = one directory — easy to understand, test, and delete
ADVANTAGE: low coupling between features — changes are contained
ADVANTAGE: aligns with how GE work packages are structured (feature-first)
RULE: GE projects use vertical slice architecture unless project is trivially small

DECISION_MATRIX

CHECK: does the project have more than 3 feature areas?
IF: yes THEN: vertical slices — mandatory
IF: no (e.g., single-purpose microservice) THEN: flat structure is acceptable

CHECK: will multiple agents work on different features simultaneously?
IF: yes THEN: vertical slices — prevents merge conflicts across layers


ARCH:REPOSITORY_PATTERN_WITH_DRIZZLE

PURPOSE

PURPOSE: abstract data access behind typed interface
WHY: testable (mock repository in tests), swappable (change DB without touching services)
WHEN: every project — even small ones benefit from test isolation

IMPLEMENTATION

// features/users/users.repository.ts
import { eq, and, ilike, desc, count } from 'drizzle-orm';
import { db } from '../../shared/db';
import { users } from '../../shared/db/schema';
import type { User, NewUser, UserUpdate } from './users.schemas';

export const userRepository = {
  async findById(id: string): Promise<User | undefined> {
    return db.query.users.findFirst({
      where: eq(users.id, id),
    });
  },

  async findByEmail(email: string): Promise<User | undefined> {
    return db.query.users.findFirst({
      where: eq(users.email, email.toLowerCase()),
    });
  },

  async findMany(opts: {
    page: number;
    pageSize: number;
    search?: string;
    role?: string;
  }): Promise<{ items: User[]; total: number }> {
    const conditions = [];
    if (opts.search) conditions.push(ilike(users.name, `%${opts.search}%`));
    if (opts.role) conditions.push(eq(users.role, opts.role));

    const where = conditions.length > 0 ? and(...conditions) : undefined;

    const [items, [{ total }]] = await Promise.all([
      db.select().from(users)
        .where(where)
        .orderBy(desc(users.createdAt))
        .limit(opts.pageSize)
        .offset((opts.page - 1) * opts.pageSize),
      db.select({ total: count() }).from(users).where(where),
    ]);

    return { items, total };
  },

  async create(data: NewUser): Promise<User> {
    const [user] = await db.insert(users).values(data).returning();
    return user;
  },

  async update(id: string, data: UserUpdate): Promise<User | undefined> {
    const [user] = await db.update(users)
      .set({ ...data, updatedAt: new Date() })
      .where(eq(users.id, id))
      .returning();
    return user;
  },

  async upsertByEmail(data: NewUser): Promise<User> {
    const [user] = await db.insert(users)
      .values(data)
      .onConflictDoUpdate({
        target: users.email,
        set: { name: data.name, updatedAt: new Date() },
      })
      .returning();
    return user;
  },

  async softDelete(id: string): Promise<void> {
    await db.update(users)
      .set({ deletedAt: new Date(), isActive: false })
      .where(eq(users.id, id));
  },
};

REPOSITORY_INTERFACE_FOR_TESTING

// features/users/users.repository.interface.ts
export interface IUserRepository {
  findById(id: string): Promise<User | undefined>;
  findByEmail(email: string): Promise<User | undefined>;
  findMany(opts: PaginationOpts): Promise<{ items: User[]; total: number }>;
  create(data: NewUser): Promise<User>;
  update(id: string, data: UserUpdate): Promise<User | undefined>;
}

// In tests — mock implementation
const mockUserRepo: IUserRepository = {
  findById: vi.fn(),
  findByEmail: vi.fn(),
  findMany: vi.fn(),
  create: vi.fn(),
  update: vi.fn(),
};

ARCH:SERVICE_PATTERN

PURPOSE

PURPOSE: encapsulate business logic, enforce rules, orchestrate repository calls
RULE: services return Result types for business failures
RULE: services never import Hono types — they are framework-agnostic
RULE: services receive repositories as parameters (dependency injection)

// features/users/users.service.ts
import { Result, Ok, Err } from '../../shared/lib/result';
import type { IUserRepository } from './users.repository.interface';
import type { User, NewUser } from './users.schemas';

export function createUserService(repo: IUserRepository) {
  return {
    async getUser(id: string): Promise<Result<User, 'NOT_FOUND'>> {
      const user = await repo.findById(id);
      if (!user) return Err('NOT_FOUND');
      return Ok(user);
    },

    async createUser(input: NewUser): Promise<Result<User, 'EMAIL_EXISTS'>> {
      const existing = await repo.findByEmail(input.email);
      if (existing) return Err('EMAIL_EXISTS');

      const user = await repo.create({
        ...input,
        email: input.email.toLowerCase(),
      });

      return Ok(user);
    },

    async updateUser(
      id: string,
      input: Partial<NewUser>,
    ): Promise<Result<User, 'NOT_FOUND' | 'EMAIL_EXISTS'>> {
      if (input.email) {
        const existing = await repo.findByEmail(input.email);
        if (existing && existing.id !== id) return Err('EMAIL_EXISTS');
      }

      const user = await repo.update(id, input);
      if (!user) return Err('NOT_FOUND');
      return Ok(user);
    },
  };
}

export type UserService = ReturnType<typeof createUserService>;

SERVICE_TESTING

// features/users/users.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createUserService } from './users.service';
import type { IUserRepository } from './users.repository.interface';

const mockRepo: IUserRepository = {
  findById: vi.fn(),
  findByEmail: vi.fn(),
  findMany: vi.fn(),
  create: vi.fn(),
  update: vi.fn(),
};

const service = createUserService(mockRepo);

describe('createUser', () => {
  beforeEach(() => vi.clearAllMocks());

  it('returns EMAIL_EXISTS when email taken', async () => {
    vi.mocked(mockRepo.findByEmail).mockResolvedValue({ id: '1', email: 'a@b.com' } as any);

    const result = await service.createUser({ email: 'a@b.com', name: 'Test' } as any);
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.error).toBe('EMAIL_EXISTS');
  });

  it('creates user when email available', async () => {
    vi.mocked(mockRepo.findByEmail).mockResolvedValue(undefined);
    vi.mocked(mockRepo.create).mockResolvedValue({ id: '1', email: 'a@b.com', name: 'Test' } as any);

    const result = await service.createUser({ email: 'A@B.com', name: 'Test' } as any);
    expect(result.ok).toBe(true);
    // Verify email was lowercased
    expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ email: 'a@b.com' }));
  });
});

ARCH:DEPENDENCY_INJECTION_WITHOUT_FRAMEWORKS

GE_DECISION: no DI frameworks (no InversifyJS, no tsyringe, no NestJS)
WHY: DI containers add runtime complexity, decorator overhead, and obscure the dependency graph
PATTERN: factory functions with explicit parameter injection

COMPOSITION_ROOT

// src/composition-root.ts
// This is the ONLY place where all dependencies are wired together

import { db } from './shared/db';
import { redis } from './shared/lib/redis';
import { logger } from './shared/lib/logger';

// Repositories (concrete implementations)
import { userRepository } from './features/users/users.repository';
import { projectRepository } from './features/projects/projects.repository';

// Services (receive repositories as dependencies)
import { createUserService } from './features/users/users.service';
import { createProjectService } from './features/projects/projects.service';

export const userService = createUserService(userRepository);
export const projectService = createProjectService(projectRepository, userService);

// Routes receive services
import { createUserRoutes } from './features/users/users.routes';
import { createProjectRoutes } from './features/projects/projects.routes';

export const userRoutes = createUserRoutes(userService);
export const projectRoutes = createProjectRoutes(projectService);
// features/users/users.routes.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import type { UserService } from './users.service';
import { createUserSchema, userParamsSchema } from './users.schemas';

export function createUserRoutes(userService: UserService) {
  return new Hono()
    .get('/:id', zValidator('param', userParamsSchema), async (c) => {
      const { id } = c.req.valid('param');
      const result = await userService.getUser(id);
      if (!result.ok) return c.json({ success: false, error: { code: result.error } }, 404);
      return c.json({ success: true, data: result.value });
    })
    .post('/', zValidator('json', createUserSchema), async (c) => {
      const body = c.req.valid('json');
      const result = await userService.createUser(body);
      if (!result.ok) {
        const statusMap = { EMAIL_EXISTS: 409 } as const;
        return c.json({ success: false, error: { code: result.error } }, statusMap[result.error]);
      }
      return c.json({ success: true, data: result.value }, 201);
    });
}

ADVANTAGE: dependency graph is visible in one file
ADVANTAGE: no decorators, no reflection, no runtime magic
ADVANTAGE: easy to test — swap real implementations for mocks


ARCH:CQRS

WHEN_WORTH_IT

CHECK: are read patterns fundamentally different from write patterns?
IF: reads need denormalized views, writes need normalized data THEN: CQRS helps
IF: read/write patterns are similar THEN: single model is fine — CQRS adds complexity

CHECK: do reads vastly outnumber writes (>10:1)?
IF: yes THEN: CQRS allows independent scaling of read/write paths

CHECK: do different consumers need different views of the same data?
IF: yes THEN: CQRS with materialized views

SIMPLE_CQRS_IN_TYPESCRIPT

// features/projects/queries/get-project-dashboard.ts
// Read model — optimized for display, may denormalize
export async function getProjectDashboard(projectId: string) {
  return db.execute(sql`
    SELECT
      p.id, p.name, p.status,
      COUNT(DISTINCT t.id) AS task_count,
      COUNT(DISTINCT t.id) FILTER (WHERE t.status = 'done') AS completed_count,
      json_agg(DISTINCT jsonb_build_object('id', m.id, 'name', m.name)) AS members
    FROM projects p
    LEFT JOIN tasks t ON t.project_id = p.id
    LEFT JOIN project_members pm ON pm.project_id = p.id
    LEFT JOIN users m ON m.id = pm.user_id
    WHERE p.id = ${projectId}
    GROUP BY p.id
  `);
}

// features/projects/commands/create-project.ts
// Write model — validates, applies business rules, emits events
export async function createProject(input: CreateProjectInput): Promise<Result<Project>> {
  // Business rules
  const ownerProjects = await projectRepo.countByOwner(input.ownerId);
  if (ownerProjects >= 10) return Err('MAX_PROJECTS_REACHED');

  const project = await db.transaction(async (tx) => {
    const [p] = await tx.insert(projects).values(input).returning();
    await tx.insert(projectMembers).values({ projectId: p.id, userId: input.ownerId, role: 'owner' });
    return p;
  });

  // Emit event for read model update / side effects
  await publishEvent({ type: 'project.created', payload: { projectId: project.id } });

  return Ok(project);
}

RULE: if using CQRS, keep commands and queries in separate files
RULE: commands return Result types and enforce business rules
RULE: queries can use raw SQL for optimized read paths
RULE: only use CQRS where the complexity is justified — most GE features do not need it


ARCH:EVENT_DRIVEN_WITH_REDIS_STREAMS

WHY_REDIS_STREAMS

WHY: lightweight (already in GE stack), persistent, consumer groups, acknowledgment
WHEN: async processing, event-driven communication between features or services
VS_KAFKA: Kafka is overkill for most GE client projects — Redis Streams sufficient for < 100k events/sec

PUBLISHER

// shared/lib/events.ts
import { redis } from './redis';

interface DomainEvent {
  type: string;
  payload: Record<string, unknown>;
  timestamp: string;
  correlationId: string;
}

export async function publishEvent(event: Omit<DomainEvent, 'timestamp'>) {
  const fullEvent: DomainEvent = {
    ...event,
    timestamp: new Date().toISOString(),
  };

  await redis.xadd(
    `events:${event.type.split('.')[0]}`,   // stream per aggregate: events:project, events:user
    'MAXLEN', '~', '10000',                  // ALWAYS set MAXLEN
    '*',                                      // auto-generate ID
    'type', fullEvent.type,
    'payload', JSON.stringify(fullEvent.payload),
    'timestamp', fullEvent.timestamp,
    'correlationId', fullEvent.correlationId,
  );
}

RULE: ALWAYS set MAXLEN on XADD — unbounded streams consume unbounded memory
RULE: use ~ (approximate) MAXLEN for performance — exact trimming is slower

CONSUMER

// shared/lib/event-consumer.ts
import { redis } from './redis';

interface ConsumerOpts {
  stream: string;
  group: string;
  consumer: string;
  handler: (event: DomainEvent) => Promise<void>;
  batchSize?: number;
}

export async function startConsumer(opts: ConsumerOpts) {
  const { stream, group, consumer, handler, batchSize = 10 } = opts;

  // Create consumer group if not exists
  try {
    await redis.xgroup('CREATE', stream, group, '0', 'MKSTREAM');
  } catch (err: any) {
    if (!err.message.includes('BUSYGROUP')) throw err;
  }

  // Read loop
  while (true) {
    try {
      const entries = await redis.xreadgroup(
        'GROUP', group, consumer,
        'COUNT', batchSize,
        'BLOCK', 5000,    // block for 5 seconds
        'STREAMS', stream, '>',
      );

      if (!entries) continue;

      for (const [, messages] of entries) {
        for (const [id, fields] of messages) {
          const event: DomainEvent = {
            type: fields[1],
            payload: JSON.parse(fields[3]),
            timestamp: fields[5],
            correlationId: fields[7],
          };

          try {
            await handler(event);
            await redis.xack(stream, group, id);
          } catch (err) {
            logger.error({ err, eventId: id, eventType: event.type }, 'Event handler failed');
            // Don't ack — will be retried via pending entries
          }
        }
      }
    } catch (err) {
      logger.error({ err }, 'Consumer read error');
      await new Promise((r) => setTimeout(r, 1000));
    }
  }
}

TRANSACTIONAL_OUTBOX

PROBLEM: dual-write problem — if DB commit succeeds but Redis publish fails, state is inconsistent
SOLUTION: transactional outbox — write event to DB in same transaction, then relay to Redis

// Outbox table in Drizzle
export const outboxEvents = pgTable('outbox_events', {
  id: uuid('id').defaultRandom().primaryKey(),
  eventType: text('event_type').notNull(),
  payload: jsonb('payload').notNull(),
  published: boolean('published').notNull().default(false),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});

// Write event in same transaction as data change
await db.transaction(async (tx) => {
  await tx.insert(projects).values(projectData);
  await tx.insert(outboxEvents).values({
    eventType: 'project.created',
    payload: { projectId: projectData.id },
  });
});

// Separate process polls outbox and publishes to Redis
async function relayOutboxEvents() {
  const events = await db.select().from(outboxEvents)
    .where(eq(outboxEvents.published, false))
    .orderBy(outboxEvents.createdAt)
    .limit(100);

  for (const event of events) {
    await publishEvent({ type: event.eventType, payload: event.payload as any, correlationId: event.id });
    await db.update(outboxEvents).set({ published: true }).where(eq(outboxEvents.id, event.id));
  }
}

ARCH:FEATURE_FLAGS

SIMPLE_FEATURE_FLAGS

// shared/lib/features.ts
const featureFlags = {
  'new-dashboard': { enabled: false, rolloutPercent: 0 },
  'v2-api': { enabled: true, rolloutPercent: 100 },
  'ai-suggestions': { enabled: true, rolloutPercent: 25 },
} as const;

type FeatureFlag = keyof typeof featureFlags;

export function isFeatureEnabled(flag: FeatureFlag, userId?: string): boolean {
  const feature = featureFlags[flag];
  if (!feature.enabled) return false;
  if (feature.rolloutPercent === 100) return true;
  if (!userId) return false;

  // Deterministic hash for consistent rollout
  const hash = Array.from(userId + flag).reduce((acc, char) => acc + char.charCodeAt(0), 0);
  return (hash % 100) < feature.rolloutPercent;
}

ARCH:PROJECT_TEMPLATE

FULL_DIRECTORY_STRUCTURE

project-name/
  src/
    features/
      users/
        users.routes.ts
        users.service.ts
        users.repository.ts
        users.repository.interface.ts
        users.schemas.ts
        users.test.ts
      projects/
        ...
    shared/
      db/
        schema/
          users.ts
          projects.ts
          relations.ts
          _columns.ts
        connection.ts
        migrate.ts
      lib/
        result.ts
        logger.ts
        redis.ts
        events.ts
      middleware/
        auth.ts
        error-handler.ts
        rate-limit.ts
        request-id.ts
        metrics.ts
    composition-root.ts
    index.ts
  drizzle/
    migrations/
  vitest.config.ts
  drizzle.config.ts
  tsconfig.json
  package.json
  Dockerfile
  .env.example
  .nvmrc

ARCH:AGENTIC_CHECKLIST

ON_STARTING_NEW_PROJECT:
1. CHECK: is directory structure vertical slices? (features/, shared/)
2. CHECK: are services framework-agnostic? (no Hono imports in service layer)
3. CHECK: is dependency injection via factory functions? (composition-root.ts)
4. CHECK: is the Result type used for business failures?
5. CHECK: are repositories abstracting Drizzle? (interface + implementation)
6. CHECK: does every XADD have MAXLEN?
7. CHECK: is the transactional outbox used for cross-system events?
8. IF: CQRS used THEN: are commands and queries in separate files?
9. IF: event-driven THEN: are consumer groups with acknowledgment used?
10. RUN: vitest — services testable with mock repositories