| 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
- Simple, clean classes - No DDD over-engineering (learned from v2)
- Clear service boundaries - Each service has single responsibility
- No circular dependencies - Services can't import from each other
- Shared code in common-types - Cross-service types, utils, services
- 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!
// ✅ 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);
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ v2 pattern - Too many layers
Controller → UseCase → Service → Repository → ORM
// ✅ v3 pattern - Direct access
Route Handler → Service → Prisma
4. Complex Domain Events
// ❌ 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
// ❌ 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:
- Shared across multiple microservices - Belongs in common-types
- Complex business logic - Deserves its own class
- Stateful operations - Needs to maintain state
- Testability - Easier to mock as a class
Keep inline when:
- Used in one place only - Simple function is fine
- Stateless utility - Pure function, no dependencies
- Very simple logic - Extracting adds complexity
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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