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