Back to catalog

tzurot-architecture

Microservices architecture for Tzurot v3 - Service boundaries, responsibilities, dependency rules, and anti-patterns from v2. Use when deciding where code belongs or designing new features.

  • Category: discovered
  • Tags: github, community
  • Version: 1.0.0
  • Author: walid
  • Downloads: 0
  • GitHub stars: 6
  • Updated: 2025-11-21

Download the latest version (v1.0.0) from this page.

SKILL.md

---
name: tzurot-architecture
description: Microservices architecture for Tzurot v3 - Service boundaries, responsibilities, dependency rules, and anti-patterns from v2. Use when deciding where code belongs or designing new features.
lastUpdated: '2025-11-19'
---

# Tzurot v3 Architecture

**Use this skill when:** Adding new features, deciding where code belongs, designing system interactions, or refactoring service boundaries.

## Architecture Overview

```
Discord User
    ↓
bot-client (Discord.js)
    ↓ HTTP
api-gateway (Express + BullMQ)
    ↓ Redis Queue
ai-worker (AI + pgvector)
    ↓
OpenRouter/Gemini API
```

## Core Principles

1. **Simple, clean classes** - No DDD over-engineering (learned from v2)
2. **Clear service boundaries** - Each service has single responsibility
3. **No circular dependencies** - Services can't import from each other
4. **Shared code in common-types** - Cross-service types, utils, services
5. **Constructor injection** - Simple dependency passing, no DI containers

## Three Microservices

### bot-client (Discord Interface)

**Responsibility:** Handle ALL Discord interactions, manage webhooks

**What it does:**

- Listen to Discord events (messages, interactions, commands)
- Register slash commands
- Create and manage webhooks (unique avatar/name per personality)
- Send HTTP requests to api-gateway
- Receive responses and send to Discord
- Format messages (chunking, embeds, typing indicators)
- Cache webhook instances

**What it does NOT do:**

- ❌ Business logic (personality selection, memory retrieval)
- ❌ AI API calls
- ❌ Database writes (except via api-gateway)
- ❌ Job queue operations (only triggers them)

**Key files:**

```
services/bot-client/src/
├── index.ts               # Discord client setup
├── handlers/              # Event handlers
│   ├── messageCreate.ts
│   └── interactionCreate.ts
├── commands/              # Slash command definitions
├── webhooks/              # Webhook management
└── redis.ts               # Webhook message tracking
```

**Dependencies:**

- Discord.js 14
- Redis (for webhook message tracking)
- HTTP client (for api-gateway calls)

### api-gateway (HTTP API + Job Queue)

**Responsibility:** HTTP endpoints, job queue orchestration, request validation

**What it does:**

- Expose HTTP endpoints (`/ai/generate`, `/health`, `/metrics`)
- Validate incoming requests
- Create BullMQ jobs
- Wait for job completion
- Return results to bot-client
- Serve personality avatars
- Manage request deduplication
- Handle cache invalidation subscriptions

**What it does NOT do:**

- ❌ AI processing (that's ai-worker's job)
- ❌ Discord interactions (that's bot-client's job)
- ❌ Long-running AI calls directly (uses queue)

**Key files:**

```
services/api-gateway/src/
├── index.ts               # Express app
├── routes/
│   ├── ai.ts             # POST /ai/generate
│   └── admin.ts          # Admin endpoints
├── queue.ts              # BullMQ queue setup
├── services/             # Business logic services
└── utils/                # Request deduplication, etc.
```

**Dependencies:**

- Express
- BullMQ (queue client)
- Redis
- Prisma (database)
- Common-types

### ai-worker (AI Processing + Memory)

**Responsibility:** Process AI jobs, manage vector memory, call AI APIs

**What it does:**

- Listen to BullMQ queue
- Retrieve personality configurations
- Search pgvector for relevant memories
- Build conversation context
- Call AI providers (OpenRouter, Gemini)
- Generate embeddings
- Store new memories
- Handle preprocessing (image description, audio transcription)
- Job timeout and retry management

**What it does NOT do:**

- ❌ HTTP requests from external clients (queue-based only)
- ❌ Discord interactions
- ❌ Direct webhook replies (goes through api-gateway)

**Key files:**

```
services/ai-worker/src/
├── index.ts               # BullMQ worker setup
├── jobs/                  # Job processors
│   ├── LLMGenerationJob.ts
│   ├── AudioTranscriptionJob.ts
│   └── ImageDescriptionJob.ts
├── providers/             # AI provider clients
│   ├── OpenRouterClient.ts
│   └── GeminiClient.ts
└── services/              # Memory, embeddings, etc.
```

**Dependencies:**

- BullMQ (worker)
- Redis
- Prisma (database + pgvector)
- OpenRouter/Gemini SDKs
- Common-types

## Shared Code (common-types)

**Responsibility:** Types, interfaces, utilities, services used across multiple microservices

**What belongs here:**

```
packages/common-types/src/
├── types/                 # TypeScript interfaces
│   ├── discord-types.ts  # Discord-specific types
│   ├── ai-types.ts       # AI request/response types
│   └── queue-types.ts    # BullMQ job types
├── constants/             # All application constants
│   ├── timing.ts
│   ├── queue.ts
│   └── discord.ts
├── services/              # Shared service classes
│   ├── PersonalityService.ts
│   ├── ConversationHistoryService.ts
│   └── CacheInvalidationService.ts
├── utils/                 # Utility functions
│   ├── retry.ts
│   ├── logger.ts
│   └── redis.ts
└── errors/                # Custom error classes
    └── RetryError.ts
```

**Dependency rule:** Services CAN import from common-types. Common-types CANNOT import from services.

## Service Boundaries

### Data Flow: Discord Message → AI Response

```
1. User sends Discord message
   ↓
2. bot-client receives messageCreate event
   ↓
3. bot-client sends POST to api-gateway/ai/generate
   ↓
4. api-gateway validates request
   ↓
5. api-gateway creates BullMQ job
   ↓
6. api-gateway waits for job completion
   ↓
7. ai-worker picks up job from queue
   ↓
8. ai-worker retrieves personality + memories
   ↓
9. ai-worker calls OpenRouter/Gemini API
   ↓
10. ai-worker stores new memory
    ↓
11. ai-worker completes job with response
    ↓
12. api-gateway receives completion
    ↓
13. api-gateway returns response to bot-client
    ↓
14. bot-client sends message to Discord via webhook
```

### Where to Put New Code

**Discord-related code:**

- Webhook management → bot-client
- Message formatting → bot-client
- Slash command handlers → bot-client
- Discord type guards → common-types

**HTTP/API code:**

- New endpoints → api-gateway/routes/
- Request validation → api-gateway
- Job creation → api-gateway/queue.ts

**AI/Memory code:**

- AI provider clients → ai-worker/providers/
- Memory retrieval → ai-worker/services/
- Embedding generation → ai-worker/services/
- Job processors → ai-worker/jobs/

**Shared utilities:**

- Retry logic → common-types/utils/
- Type guards → common-types/types/
- Error classes → common-types/errors/
- Constants → common-types/constants/

**Services used by multiple microservices:**

- PersonalityService → common-types/services/
- ConversationHistoryService → common-types/services/
- Logger → common-types/utils/

## Dependency Injection Pattern

**Simple constructor injection** - No DI containers!

```typescript
// ✅ GOOD - Simple, explicit
class MyService {
  constructor(
    private prisma: PrismaClient,
    private redis: Redis
  ) {}

  async doSomething(): Promise<void> {
    await this.prisma.user.findMany();
    await this.redis.get('key');
  }
}

// Usage
const prisma = getPrismaClient();
const redis = getRedisClient();
const service = new MyService(prisma, redis);
```

```typescript
// ❌ BAD - DI container (v2 over-engineering)
@injectable()
class MyService {
  constructor(
    @inject('PrismaClient') private prisma: PrismaClient,
    @inject('Redis') private redis: Redis
  ) {}
}
```

## Anti-Patterns from v2

**Why v3 abandoned DDD:** V2's Domain-Driven Design was over-engineered for a one-person project.

### ❌ Don't Create These v2 Patterns:

**1. Generic Repository Interfaces**

```typescript
// ❌ v2 pattern - Too abstract
interface IRepository<T> {
  findById(id: string): Promise<T>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
}

// ✅ v3 pattern - Concrete, simple
class PersonalityService {
  async getPersonality(id: string): Promise<Personality | null> {
    return this.prisma.personality.findUnique({ where: { id } });
  }
}
```

**2. Dependency Injection Containers**

```typescript
// ❌ v2 pattern - Container hell
container.bind('PersonalityService').to(PersonalityService);
const service = container.get('PersonalityService');

// ✅ v3 pattern - Direct instantiation
const service = new PersonalityService(prisma);
```

**3. Excessive Abstraction Layers**

```typescript
// ❌ v2 pattern - Too many layers
Controller → UseCase → Service → Repository → ORM

// ✅ v3 pattern - Direct access
Route Handler → Service → Prisma
```

**4. Complex Domain Events**

```typescript
// ❌ v2 pattern - Event bus complexity
eventBus.emit('personality.updated', { id });

// ✅ v3 pattern - Redis pub/sub for cross-service only
cacheInvalidationService.invalidatePersonality(id);
```

**5. Value Objects Everywhere**

```typescript
// ❌ v2 pattern - Value object overhead
class PersonalityName {
  constructor(private value: string) {
    if (!this.validate()) throw new Error('Invalid');
  }
  validate() {
    /* complex validation */
  }
}

// ✅ v3 pattern - Simple validation
function validatePersonalityName(name: string): boolean {
  return name.length > 0 && name.length <= 100;
}
```

## When to Extract a Service

**Extract to a new service class when:**

1. **Shared across multiple microservices** - Belongs in common-types
2. **Complex business logic** - Deserves its own class
3. **Stateful operations** - Needs to maintain state
4. **Testability** - Easier to mock as a class

**Keep inline when:**

1. **Used in one place only** - Simple function is fine
2. **Stateless utility** - Pure function, no dependencies
3. **Very simple logic** - Extracting adds complexity

```typescript
// ✅ Extract - Complex, shared, stateful
class ConversationHistoryService {
  constructor(private prisma: PrismaClient) {}

  async addMessage(/* ... */): Promise<void> {
    // Complex logic with database interactions
  }

  async getHistory(/* ... */): Promise<Message[]> {
    // Pagination, filtering, etc.
  }
}

// ✅ Keep inline - Simple, one-off
function formatUsername(username: string): string {
  return `@${username}`;
}
```

## Database Access Patterns

**Direct Prisma access in services** - No repository pattern

```typescript
// ✅ GOOD - Prisma directly in service
class PersonalityService {
  constructor(private prisma: PrismaClient) {}

  async getPersonality(id: string): Promise<Personality | null> {
    return this.prisma.personality.findUnique({
      where: { id },
      include: { llmConfig: true },
    });
  }
}

// ❌ BAD - Generic repository
interface PersonalityRepository {
  findById(id: string): Promise<Personality>;
}
```

## Error Handling Architecture

**Service-level errors** - Let errors bubble up, handle at boundaries

```typescript
// ✅ GOOD - Service throws, route handles
class PersonalityService {
  async getPersonality(id: string): Promise<Personality> {
    const personality = await this.prisma.personality.findUnique({ where: { id } });
    if (!personality) {
      throw new Error(`Personality not found: ${id}`);
    }
    return personality;
  }
}

// Route handler catches and formats
app.get('/personality/:id', async (req, res) => {
  try {
    const personality = await service.getPersonality(req.params.id);
    res.json(personality);
  } catch (error) {
    res.status(404).json({ error: error.message });
  }
});
```

## Configuration Management

**Environment variables for secrets, constants for application config**

```typescript
// ✅ GOOD - Env vars for secrets
const discordToken = process.env.DISCORD_TOKEN;
const databaseUrl = process.env.DATABASE_URL;

// ✅ GOOD - Constants for config
import { TIMEOUTS, RETRY_CONFIG } from '@tzurot/common-types';
const timeout = TIMEOUTS.LLM_INVOCATION;
const maxRetries = RETRY_CONFIG.MAX_ATTEMPTS;

// ❌ BAD - Hardcoded secrets
const discordToken = 'MTIzNDU2Nzg5MA.GhIjKl';

// ❌ BAD - Hardcoded config
const timeout = 480000;
```

## Scaling Considerations

**Current architecture supports:**

- ✅ Horizontal scaling of ai-worker (multiple workers)
- ✅ Horizontal scaling of api-gateway (load balancer)
- ✅ Single bot-client instance (Discord.js limitation)

**Future scaling paths:**

- Add more ai-worker instances for faster job processing
- Add more api-gateway instances behind load balancer
- Shard bot-client if guild count exceeds Discord limits
- Separate read/write database instances

## Testing Architecture

**Each service has its own tests** - No cross-service integration tests yet

```
services/bot-client/src/
├── webhooks/
│   ├── WebhookManager.ts
│   └── WebhookManager.test.ts  # Tests with mocked Discord

services/api-gateway/src/
├── routes/
│   ├── ai.ts
│   └── ai.test.ts              # Tests with mocked queue

services/ai-worker/src/
├── jobs/
│   ├── LLMGenerationJob.ts
│   └── LLMGenerationJob.test.ts  # Tests with mocked AI provider
```

## Related Skills

- **tzurot-async-flow** - Async workflow design patterns
- **tzurot-db-vector** - Database service responsibilities
- **tzurot-shared-types** - Type definitions across services
- **tzurot-gemini-collab** - Consult for major design decisions

## References

- Full architecture: `CLAUDE.md#architecture`
- Service structure: `CLAUDE.md#project-structure`
- Why v3 abandoned DDD: `CLAUDE.md#why-v3-abandoned-ddd`
- Architecture decisions: `docs/architecture/ARCHITECTURE_DECISIONS.md`