Elegant Refactoring: A Journey to Better Domain-Driven Architecture
In any growing project, there comes a time when the initial architecture starts showing signs of strain. As features expand and requirements evolve, what once seemed like a clear structure can become muddled. Today, I want to share our journey of refactoring TON Arcana, focusing on creating a more cohesive, domain-driven architecture.
Here are 10 elegant improvements we're implementing to make our codebase cleaner, more maintainable, and better aligned with domain concepts.
1. Unifying Domain Names and File Organization
Difficulty: 7/10 | Value: 9/10
One of the first issues we tackled was scattered logic for tarot readings, AI, and deck management. Our analysis of the current codebase showed domain logic spread across multiple top-level directories with mixed infrastructure concerns. While the refactoring effort is significant, the improved maintainability and clearer domain boundaries justify the work.
Our solution? A more cohesive domain-based structure:
apps/ton-arcana/
├─ src/
│ ├─ main.ts
│ ├─ app.module.ts
│ ├─ core/ # Core domain logic
│ │ ├─ reading/
│ │ │ ├─ tarot-reading.service.ts
│ │ │ ├─ tarot-reading.entity.ts
│ │ ├─ deck/
│ │ │ ├─ tarot-deck.service.ts
│ │ │ ├─ tarot-deck.entity.ts
│ │ └─ ai/
│ │ ├─ tarot-ai.service.ts
│ │ ├─ ollama.client.ts
│ ├─ infrastructure/ # Technical concerns
│ │ ├─ entities/
│ │ ├─ cache/
│ │ └─ migrations/
Implementation Note: Consider incremental migration, starting with one domain area (e.g., reading) and gradually moving others while maintaining functionality.
2. Domain-Driven Interfaces
Difficulty: 5/10 | Value: 8/10
Our analysis revealed that while we have many interfaces, they're primarily focused on technical concerns rather than domain concepts. The reading service, for example, works with raw entity types and primitive values instead of domain-specific interfaces.
Let's introduce explicit interfaces that better represent our domain:
// Core domain types
export interface TarotReading {
id: number;
userId: number;
query: string;
cardsDrawn: string[];
spreadType: 'three-card' | 'celtic-cross';
interpretation: string;
metadata: ReadingMetadata;
}
export interface ReadingMetadata {
userHash: string;
blockHash: string;
model: string;
costUsd: number;
priceTaro: number;
processingTime: string;
deck?: {
name: string;
description: string;
};
}
export interface TarotAIResponse {
prompt: string;
response: string;
modelUsed: string;
costUsd: number;
priceTaro: number;
processingDurationMs: number;
}
Implementation Note: Start by introducing these interfaces alongside existing entities, then gradually refactor services to use them. This provides better type safety and domain clarity without breaking changes.
3. Specialized AI Services
Difficulty: 8/10 | Value: 9/10
Our analysis of AIService (553 lines) revealed it handles multiple responsibilities: model selection, pricing, text generation, image generation, and formatting. This makes the service hard to maintain and test.
Let's break it down into focused components:
@Injectable()
export class TarotAIPromptService {
buildTarotPrompt(query: string, spread: SpreadType): string {
/* ... */
}
buildTeachingPrompt(card: string, deck: string): string {
/* ... */
}
buildImagePrompt(reading: string, style: string): string {
/* ... */
}
}
@Injectable()
export class TarotAIClient {
constructor(
private readonly ollamaService: OllamaService,
private readonly openaiClient: OpenAI,
private readonly modelSelector: AIModelSelector,
) {}
async generateReading(prompt: string): Promise<AIResponse> {
/* ... */
}
async generateTeaching(prompt: string): Promise<AIResponse> {
/* ... */
}
}
@Injectable()
export class AIModelSelector {
private readonly AVAILABLE_MODELS = [
'llama3.2:3b',
'gemma2:2b',
'marco-o1:latest',
];
selectModelForJob(requestedModel?: string): string {
/* ... */
}
}
Implementation Note: Start by extracting the model selection logic, then prompt building, and finally the core AI client functionality. This allows for gradual refactoring while maintaining existing functionality.
4. Purpose-Driven Service Names
Difficulty: 3/10 | Value: 7/10
Our codebase analysis showed generic service names that don't clearly communicate their role in the domain. For example:
ollama.service.tshandles both model management and inferenceredis.service.tsis used for both caching and queuingformatting.service.tsmixes Telegram and general text formatting
Let's rename them to better reflect their domain purpose:
// Before
import { OllamaService } from './ollama.service';
import { RedisService } from './redis.service';
import { FormattingService } from './formatting.service';
// After
import { AIModelClient } from './ai-model.client';
import { RedisCacheService } from './redis-cache.service';
import { RedisQueueService } from './redis-queue.service';
import { TelegramFormatter } from './telegram-formatter.service';
Implementation Note: Use the IDE's refactoring tools to rename services. This is a relatively safe change that immediately improves code clarity.
5. Centralized Utilities
Difficulty: 6/10 | Value: 8/10
Our codebase has utilities spread across multiple locations:
- App-level utilities in
src/util/ - Multiple utility packages (
common-utils,openai-utils) - Duplicated functionality between packages
- Mixed domain and technical utilities
Let's reorganize them into a more cohesive structure:
packages/
├─ common-utils/ # Shared technical utilities
│ ├─ src/
│ │ ├─ env/ # Environment helpers
│ │ ├─ hash/ # Cryptographic functions
│ │ ├─ format/ # Generic formatters
│ │ └─ validation/ # Common validators
├─ domain-utils/ # Domain-specific utilities
│ ├─ src/
│ │ ├─ tarot/ # Tarot-related helpers
│ │ ├─ telegram/ # Telegram formatting
│ │ └─ ton/ # TON blockchain utils
└─ ai-utils/ # AI-related utilities
├─ src/
│ ├─ openai/ # OpenAI helpers
│ ├─ ollama/ # Ollama helpers
│ └─ prompt/ # Prompt builders
Implementation Note: Move utilities gradually, starting with the most commonly used ones. Create a migration guide for the team to understand where to find and place utilities.
6. Intent-Based Telegram Handlers
Difficulty: 4/10 | Value: 8/10
Our analysis of the Telegram handlers shows a flat structure with mixed concerns:
- Generic handlers (
start,help,about) mixed with domain-specific ones - Large
telegram.service.ts(466 lines) handling multiple responsibilities - No clear separation between commands and business logic
Let's restructure around user intent and domain concepts:
telegram/
├─ modules/ # Group by domain intent
│ ├─ core/ # Core bot functionality
│ │ ├─ start.handler.ts
│ │ ├─ help.handler.ts
│ │ └─ about.handler.ts
│ ├─ tarot/ # Tarot-specific commands
│ │ ├─ reading.handler.ts
│ │ ├─ teaching.handler.ts
│ │ └─ daily.handler.ts
│ └─ admin/ # Administrative features
│ ├─ stats.handler.ts
│ └─ settings.handler.ts
├─ common/ # Shared functionality
│ ├─ base.handler.ts
│ └─ formatter.service.ts
└─ telegram.module.ts # Module configuration
Implementation Note: Move handlers one domain at a time, starting with core commands. Update the module imports accordingly.
7. Domain Module Integration
Difficulty: 7/10 | Value: 9/10
Our app module currently imports many loosely related modules. The app.module.ts has over 40 imports, making it hard to understand domain boundaries. Let's create focused domain modules:
// Before: Everything imported in app.module.ts
@Module({
imports: [
TelegramModule,
TonModule,
DeckModule,
ReadingModule,
TokenModule,
// ... many more imports
],
})
export class AppModule {}
// After: Domain-focused modules
@Module({
imports: [
TarotModule, // Combines deck, reading, teaching
BlockchainModule, // Combines ton, token functionality
BotModule, // Telegram-related features
InfraModule, // Redis, Bull, etc.
],
})
export class AppModule {}
// Tarot domain module
@Module({
imports: [DeckModule, ReadingModule, TeachingModule, AIModule],
providers: [TarotService, TarotAIService, PricingService],
exports: [TarotService],
})
export class TarotModule {}
Implementation Note: Create new domain modules gradually, moving related features together. Test thoroughly after each module reorganization.
8. Lifecycle Management
Difficulty: 4/10 | Value: 7/10
Our analysis of services like RedisService shows inconsistent lifecycle management. Some services properly implement OnModuleInit/OnModuleDestroy, while others handle initialization ad-hoc.
Let's standardize lifecycle management:
@Injectable()
export abstract class BaseService implements OnModuleInit, OnModuleDestroy {
protected abstract readonly serviceName: string;
protected abstract readonly logger: Logger;
async onModuleInit() {
try {
await this.initializeResources();
this.logger.log(`${this.serviceName} initialized`);
} catch (error) {
this.logger.error(
`Failed to initialize ${this.serviceName}: ${error.message}`,
);
throw error;
}
}
async onModuleDestroy() {
try {
await this.cleanupResources();
this.logger.log(`${this.serviceName} destroyed`);
} catch (error) {
this.logger.error(
`Failed to cleanup ${this.serviceName}: ${error.message}`,
);
}
}
protected abstract initializeResources(): Promise<void>;
protected abstract cleanupResources(): Promise<void>;
}
// Example implementation
@Injectable()
export class RedisService extends BaseService {
protected readonly serviceName = 'RedisService';
protected readonly logger = new Logger(RedisService.name);
private client: Redis;
protected async initializeResources() {
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
this.client = new Redis(redisUrl);
await this.client.ping(); // Verify connection
}
protected async cleanupResources() {
await this.client?.quit();
}
}
Implementation Note: Start with critical services that manage external resources (Redis, database connections, etc.).
9. Infrastructure Layer Organization
Difficulty: 5/10 | Value: 8/10
Our infrastructure code (entities, repositories, migrations) is currently mixed with domain logic. Let's create a clear separation:
src/
├─ infrastructure/ # All infrastructure concerns
│ ├─ database/
│ │ ├─ entities/ # Database entities
│ │ │ ├─ reading.entity.ts
│ │ │ ├─ user.entity.ts
│ │ │ └─ ai-response.entity.ts
│ │ ├─ repositories/ # Custom repositories
│ │ │ └─ reading.repository.ts
│ │ └─ migrations/ # Database migrations
│ ├─ cache/
│ │ ├─ redis.service.ts
│ │ └─ cache.module.ts
│ └─ queue/
│ ├─ queue.module.ts
│ └─ processors/
├─ core/ # Domain logic
│ ├─ reading/
│ └─ deck/
└─ api/ # API layer
├─ controllers/
└─ dtos/
Implementation Note: Move infrastructure code gradually, updating imports as you go. Consider using path aliases to make imports cleaner.
10. Domain-Oriented Job Types
Difficulty: 3/10 | Value: 8/10
Our analysis shows scattered job type definitions:
- Job interfaces in
job.types.ts - Queue and job name enums in
queue.types.ts - Some job types use
anyfor options - No clear domain boundaries in job naming
Let's create a more domain-oriented job system:
// packages/kaido-utils/src/queue/types.ts
// Core job interface that all domain jobs extend
export interface BaseJob<T = unknown> {
id?: string;
timestamp?: Date;
userId?: string;
metadata?: Record<string, unknown>;
data: T;
}
// Domain-specific job types
export namespace TarotDomain {
export enum JobType {
GenerateReading = 'tarot.reading.generate',
ReleaseReading = 'tarot.reading.release',
GenerateTeaching = 'tarot.teaching.generate',
GenerateImage = 'tarot.image.generate',
}
export interface ReadingJobData {
query: string;
userHash: string;
blockHash: string;
spread: 'three-card' | 'celtic-cross';
deck: string;
model?: string;
version?: string;
}
export interface TeachingJobData {
card: string;
deck: string;
model?: string;
}
export interface ImageJobData {
reading: string;
character: {
description: string;
hashtag: string;
style: string;
};
}
}
export namespace TelegramDomain {
export enum JobType {
SendMessage = 'telegram.message.send',
DeleteMessage = 'telegram.message.delete',
}
export interface MessageJobData {
chatId: number | string;
text?: string;
image?: {
path: string;
caption?: string;
};
options?: {
parseMode?: 'HTML' | 'Markdown';
replyTo?: number;
keyboard?: unknown;
};
}
}
// Type-safe job registry
export interface JobRegistry {
[TarotDomain.JobType.GenerateReading]: BaseJob<TarotDomain.ReadingJobData>;
[TarotDomain.JobType.ReleaseReading]: BaseJob<TarotDomain.ReadingJobData>;
[TarotDomain.JobType.GenerateTeaching]: BaseJob<TarotDomain.TeachingJobData>;
[TarotDomain.JobType.GenerateImage]: BaseJob<TarotDomain.ImageJobData>;
[TelegramDomain.JobType.SendMessage]: BaseJob<TelegramDomain.MessageJobData>;
[TelegramDomain.JobType
.DeleteMessage]: BaseJob<TelegramDomain.MessageJobData>;
}
// Type helpers
export type JobType = keyof JobRegistry;
export type JobDataFor<T extends JobType> = JobRegistry[T]['data'];
// Queue configuration
export const QUEUE_CONFIG = {
[TarotDomain.JobType.GenerateReading]: {
name: 'tarot',
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
},
[TelegramDomain.JobType.SendMessage]: {
name: 'telegram',
attempts: 2,
backoff: { type: 'fixed', delay: 5000 },
},
} as const;
// Usage example
@Injectable()
export class TarotService {
constructor(
@InjectQueue('tarot') private tarotQueue: Queue,
@InjectQueue('telegram') private telegramQueue: Queue,
) {}
async generateReading(data: TarotDomain.ReadingJobData) {
const job: BaseJob<TarotDomain.ReadingJobData> = {
id: uuid(),
timestamp: new Date(),
data,
};
await this.tarotQueue.add(
TarotDomain.JobType.GenerateReading,
job,
QUEUE_CONFIG[TarotDomain.JobType.GenerateReading],
);
}
}
Implementation Note: Start by creating the new types in a shared package, then gradually migrate existing job types. Consider adding validation decorators for job data and runtime type checking.
Conclusion
This refactoring journey has taught us valuable lessons about domain-driven design and clean architecture. By focusing on clear naming, proper separation of concerns, and domain-oriented structure, we've created a more maintainable and extensible codebase.
The improvements we've made not only make the code easier to understand but also provide a solid foundation for future features. Remember, good architecture isn't about following rules blindly—it's about making your code tell a clear story about your domain.
Bonus: Essential Utility Functions
While restructuring the architecture is crucial, having a solid set of utility functions is equally important. Here are 10 essential functions that will help streamline your codebase and reduce duplication.
1. Centralized Environment Getter
Difficulty: 2/10 | Value: 9/10
Our analysis shows inconsistent environment variable handling:
- Direct
process.envaccess in some places configService.get()with different type assertions- Mixed usage of defaults and error handling
- Scattered environment checks across the codebase
Let's create a unified approach:
// packages/kaido-utils/src/env-utils.ts
export class EnvironmentService {
constructor(private configService: ConfigService) {}
/**
* Get environment variable with type safety and validation
*/
get<T>(
key: string,
options?: {
default?: T;
required?: boolean;
transform?: (value: string) => T;
},
): T {
const value = this.configService.get<T>(key, options?.default);
if (!value && options?.required) {
throw new Error(`Required environment variable not set: ${key}`);
}
if (value && options?.transform) {
return options.transform(value as string);
}
return value;
}
/**
* Environment check utilities
*/
isProduction(): boolean {
return this.get('NODE_ENV') === 'production';
}
isDevelopment(): boolean {
return !this.isProduction();
}
isTest(): boolean {
return this.get('NODE_ENV') === 'test';
}
}
// Usage example
@Injectable()
export class AIService {
constructor(private env: EnvironmentService) {
this.apiKey = this.env.get('OPENAI_API_KEY', { required: true });
this.modelConfig = this.env.get('AI_MODEL_CONFIG', {
default: defaultConfig,
transform: JSON.parse,
});
}
}
Implementation Note: Start by creating the service in common-utils, then gradually replace direct ConfigService usage. Consider adding validation for critical environment variables.
2. Unified Logging Helper
Difficulty: 3/10 | Value: 8/10
Our analysis shows inconsistent logging patterns:
- Direct Logger usage with varying contexts
- Inconsistent error handling in catch blocks
- Mixed logging levels (debug/log/warn/error)
- No standardized format for metadata/details
Let's create a unified logging service:
// packages/kaido-utils/src/logging.service.ts
@Injectable()
export class LoggingService {
constructor(
private readonly logger: Logger,
private readonly env: EnvironmentService,
) {}
private formatDetails(details: Record<string, any> = {}): string {
return this.env.isProduction()
? JSON.stringify(details)
: JSON.stringify(details, null, 2);
}
log(message: string, context?: string, details?: Record<string, any>) {
this.logger.log(
`${message}${details ? ` | ${this.formatDetails(details)}` : ''}`,
context,
);
}
error(error: unknown, context?: string, details?: Record<string, any>) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(
`${errorMessage}${details ? ` | ${this.formatDetails(details)}` : ''}`,
error instanceof Error ? error.stack : undefined,
context,
);
}
warn(message: string, context?: string, details?: Record<string, any>) {
this.logger.warn(
`${message}${details ? ` | ${this.formatDetails(details)}` : ''}`,
context,
);
}
debug(message: string, context?: string, details?: Record<string, any>) {
if (!this.env.isProduction()) {
this.logger.debug(
`${message}${details ? ` | ${this.formatDetails(details)}` : ''}`,
context,
);
}
}
}
// Usage example
@Injectable()
export class TelegramService {
constructor(private readonly logger: LoggingService) {}
async sendMessage(chatId: number, text: string) {
try {
this.logger.debug('Sending message', 'TelegramService', { chatId, text });
await this.bot.sendMessage(chatId, text);
this.logger.log('Message sent successfully', 'TelegramService', {
chatId,
});
} catch (error) {
this.logger.error(error, 'TelegramService', { chatId, text });
throw error;
}
}
}
Implementation Note: Start by creating the service in common-utils, then gradually replace direct Logger usage. Consider adding correlation IDs for request tracing.
3. Resilient Error Handler
Difficulty: 4/10 | Value: 9/10
Our analysis shows scattered error handling patterns:
- Direct throws with string messages
- Inconsistent error formatting in catch blocks
- Mixed error handling strategies across services
- No standardized error types for domain errors
Let's create a unified error handling system:
// packages/kaido-utils/src/errors/
// Base error types
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly details?: Record<string, any>,
) {
super(message);
this.name = this.constructor.name;
}
}
export class ValidationError extends AppError {
constructor(message: string, details?: Record<string, any>) {
super(message, 'VALIDATION_ERROR', details);
}
}
export class ResourceNotFoundError extends AppError {
constructor(resource: string, id: string | number) {
super(`${resource} with id ${id} not found`, 'RESOURCE_NOT_FOUND', {
resource,
id,
});
}
}
// Error handler utility
@Injectable()
export class ErrorHandlingService {
constructor(private readonly logger: LoggingService) {}
handle(
error: unknown,
context: string,
details?: Record<string, any>,
): never {
if (error instanceof AppError) {
this.logger.error(error, context, {
code: error.code,
...error.details,
...details,
});
throw error;
}
const wrappedError = new AppError(
error instanceof Error ? error.message : String(error),
'INTERNAL_ERROR',
details,
);
this.logger.error(wrappedError, context, details);
throw wrappedError;
}
}
// Usage example
@Injectable()
export class DeckService {
constructor(private readonly errorHandler: ErrorHandlingService) {}
getDeck(deckKey: string): DeckConfig {
const deck = this.decks.get(deckKey);
if (!deck) {
throw new ResourceNotFoundError('Deck', deckKey);
}
return deck;
}
async createReading(userId: string, query: string) {
try {
const user = await this.userRepo.findOne(userId);
if (!user) {
throw new ResourceNotFoundError('User', userId);
}
// ... reading creation logic
} catch (error) {
this.errorHandler.handle(error, 'DeckService.createReading', {
userId,
query,
});
}
}
}
Implementation Note: Start by defining core error types, then gradually replace generic errors with domain-specific ones. Consider adding error codes for better error handling on the client side.
4. Telegram Message Formatting
Difficulty: 3/10 | Value: 8/10
Our analysis shows scattered Telegram formatting logic:
- Basic escaping in telegram-utils
- Token formatting in FormatterService
- No unified approach to message length limits
- Mixed HTML and Markdown formatting
Let's create a comprehensive formatting service:
// packages/kaido-utils/src/telegram/formatting.service.ts
export type ParseMode = 'HTML' | 'Markdown' | 'Plain';
@Injectable()
export class TelegramFormattingService {
private readonly MAX_MESSAGE_LENGTH = 4096;
formatMessage(
text: string,
options: {
mode?: ParseMode;
bold?: boolean;
emoji?: string;
splitLongMessages?: boolean;
} = {},
): string | string[] {
const {
mode = 'Markdown',
bold = false,
emoji,
splitLongMessages = true,
} = options;
let formatted = text;
// Add emoji if provided
if (emoji) {
formatted = `${emoji} ${formatted}`;
}
// Apply formatting based on mode
formatted = this.applyFormatting(formatted, mode, bold);
// Handle message length limits
if (splitLongMessages && formatted.length > this.MAX_MESSAGE_LENGTH) {
return this.splitMessage(formatted);
}
return formatted;
}
private applyFormatting(
text: string,
mode: ParseMode,
bold: boolean,
): string {
switch (mode) {
case 'HTML':
text = this.escapeHtml(text);
return bold ? `<b>${text}</b>` : text;
case 'Markdown':
text = this.escapeMarkdownV2(text);
return bold ? `*${text}*` : text;
default:
return text;
}
}
private escapeMarkdownV2(text: string): string {
return text.replace(/[_*[\]()~`>#+=|{}.!-]/g, '\\$&');
}
private escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
private splitMessage(text: string): string[] {
const chunks: string[] = [];
let currentChunk = '';
const lines = text.split('\n');
for (const line of lines) {
if (currentChunk.length + line.length + 1 <= this.MAX_MESSAGE_LENGTH) {
currentChunk += (currentChunk ? '\n' : '') + line;
} else {
if (currentChunk) chunks.push(currentChunk);
currentChunk = line;
}
}
if (currentChunk) chunks.push(currentChunk);
return chunks;
}
}
// Usage example
@Injectable()
export class TelegramService {
constructor(private readonly formatter: TelegramFormattingService) {}
async sendTokenAmount(chatId: number, amount: number, symbol: string) {
const message = this.formatter.formatMessage(
`${amount.toFixed(2)} ${symbol}`,
{
mode: 'HTML',
bold: true,
emoji: '💎',
},
);
await this.bot.sendMessage(chatId, message);
}
}
Implementation Note: Start by creating the service in common-utils, then gradually replace existing formatting logic. Consider adding support for more complex formatting like tables and code blocks.
5. AI Prompt Builder
Create consistent AI prompts for tarot readings:
export function buildTarotPrompt(
userQuery: string,
deckInfo: { name: string; description: string },
spreadType: 'three-card' | 'celtic-cross',
): string {
return `
User Query: ${userQuery}
Deck Name: ${deckInfo.name} - ${deckInfo.description}
Spread: ${spreadType}
Instructions: Provide an insightful reading with minimal repetition...
`;
}
6. Queue Job Adder
Generic function for adding jobs to BullMQ queues:
import { Queue, JobsOptions, Job } from 'bullmq';
export async function addJobToQueue<T>(
queue: Queue<T>,
jobName: string,
data: T,
opts?: JobsOptions,
): Promise<Job<T>> {
try {
return await queue.add(jobName, data, opts);
} catch (error) {
handleError(error, 'QueueJobAddition');
}
}
7. Standard Reading Creation
Orchestrate the creation of tarot readings:
export async function createReading(
userId: string,
query: string,
deckKey: string,
spreadType: 'three-card' | 'celtic-cross',
): Promise<Reading> {
const userHash = hashUserQuery(query);
const blockHash = await tonService.getLatestBlockHash();
const cards = deckService.selectCards(userHash, blockHash, deckKey, 3);
const reading = em.create(Reading, {
userId,
userQuery: query,
selectedCards: cards,
spreadType,
});
await em.persistAndFlush(reading);
return reading;
}
8. Global Admin Check
Centralize admin permission logic:
export function isUserAdmin(userId: number): boolean {
return ADMIN_IDS.includes(userId) || userId === primaryAdminId;
}
9. Token Formatting Utility
Standardize token amount formatting:
export function formatTokenAmount(
amount: number,
symbol = 'TARO',
decimals = 2,
): string {
return `${(amount / 10 ** decimals).toFixed(decimals)} ${symbol}`;
}
10. Teaching Generation Orchestrator
Centralize teaching generation logic:
export async function generateTeachingForCard(
cardName: string,
deckKey: string,
): Promise<Teaching> {
const deckConfig = deckService.getDeckConfig(deckKey);
const [teaching, insight] = await aiService.generateCardTeaching(
cardName,
deckKey,
);
const reward = tokenService.calculateTeachingReward();
const newTeaching = em.create(Teaching, {
date: new Date(),
cardName,
deckKey,
deckName: deckConfig?.name || '',
teaching,
teachingModel: 'marco-o1',
insight,
insightModel: 'marco-o1',
tokenReward: reward,
});
await em.persistAndFlush(newTeaching);
return newTeaching;
}
These utility functions complement our architectural improvements by providing clear, reusable building blocks for common operations. They help maintain consistency across the codebase and make it easier to implement new features.
Advanced Architectural Improvements
While we've covered structural changes and utility functions, there are several deeper architectural improvements worth considering. Here are 10 advanced refactoring ideas that can further enhance our codebase.
1. Consolidate Common Interfaces and Utilities
Currently, we have utility functions and interfaces scattered across different modules. Let's consolidate them:
// Before: Scattered across files
// deck/types.ts
interface DeckConfig { ... }
// reading/types.ts
interface ReadingConfig { ... }
// After: Consolidated in shared/interfaces
// shared/interfaces/config.ts
export interface DeckConfig { ... }
export interface ReadingConfig { ... }
export interface AIConfig { ... }
2. Refine Module Boundaries
Instead of having loosely related modules, group them by domain:
@Module({
imports: [DeckModule, ReadingModule, AIModule],
exports: [TarotService],
})
export class TarotModule {
// Handles all tarot-related functionality in one place
}
3. Inject Config at Service Construction
Improve configuration management:
@Injectable()
export class AIService {
private readonly apiKey: string;
private readonly modelConfig: AIModelConfig;
constructor(private configService: ConfigService) {
// Get config once at initialization
this.apiKey = this.configService.getOrThrow('AI_API_KEY');
this.modelConfig = this.configService.get('AI_MODEL_CONFIG');
}
// Use stored config instead of repeated gets
async generateResponse(prompt: string) {
return this.client.generate(prompt, this.modelConfig);
}
}
4. Strengthen External Library Types
Add proper typing for external dependencies:
// Before
const redisClient: any = new Redis(config);
// After
import { Redis, RedisOptions } from 'ioredis';
const redisClient: Redis = new Redis(config as RedisOptions);
5. Use Enums for Constants
Replace string literals with typed enums:
export enum SpreadType {
THREE_CARD = 'three-card',
CELTIC_CROSS = 'celtic-cross',
}
export enum ImageStyle {
VIVID = 'vivid',
NATURAL = 'natural',
}
// Usage
createReading(userId, query, deckKey, SpreadType.THREE_CARD);
6. Unified Lifecycle Management
Create a base service for consistent lifecycle management:
export abstract class BaseService implements OnModuleInit, OnModuleDestroy {
protected abstract serviceName: string;
async onModuleInit() {
await this.initializeResources();
logEvent(`${this.serviceName} initialized`);
}
async onModuleDestroy() {
await this.cleanupResources();
logEvent(`${this.serviceName} destroyed`);
}
protected abstract initializeResources(): Promise<void>;
protected abstract cleanupResources(): Promise<void>;
}
7. Custom Guards and Interceptors
Move repeated checks into reusable guards:
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return isUserAdmin(request.user.id);
}
}
// Usage
@Controller('admin')
@UseGuards(AdminGuard)
export class AdminController {
// All endpoints are protected
}
8. Unified Cache Strategy
Create a unified caching layer:
@Injectable()
export class CacheService {
constructor(
private redis: RedisService,
private cache: CacheManager,
) {}
async get<T>(key: string): Promise<T | null> {
// Try Redis first, fall back to local cache
return (await this.redis.get(key)) || (await this.cache.get(key));
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
await Promise.all([
this.redis.set(key, value, ttl),
this.cache.set(key, value, ttl),
]);
}
}
9. Task-Specific Services
Break down large services into focused ones:
@Injectable()
export class AIPromptService {
buildPrompt(/* ... */) {
/* ... */
}
}
@Injectable()
export class AICostService {
calculateCost(/* ... */) {
/* ... */
}
}
@Injectable()
export class AIService {
constructor(
private promptService: AIPromptService,
private costService: AICostService,
) {}
}
10. Testing Infrastructure
Add comprehensive testing setup:
describe('ReadingService', () => {
let service: ReadingService;
let mockDeckService: jest.Mocked<DeckService>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
ReadingService,
{
provide: DeckService,
useValue: {
selectCards: jest.fn(),
getDeckConfig: jest.fn(),
},
},
],
}).compile();
service = module.get(ReadingService);
mockDeckService = module.get(DeckService);
});
it('should create reading with correct cards', async () => {
mockDeckService.selectCards.mockResolvedValue(['fool', 'magician']);
const reading = await service.createReading(/* ... */);
expect(reading.cards).toEqual(['fool', 'magician']);
});
});
These architectural improvements build upon our earlier refactoring work by addressing deeper structural issues and establishing better patterns for future development. They focus on making the codebase more maintainable, testable, and aligned with TypeScript and NestJS best practices.
What refactoring strategies have worked well in your projects? Share your experiences in the comments below!
Conclusion
After evaluating each refactoring suggestion against the current TON Arcana codebase, here's a summary of our findings:
-
Unifying Domain Names (Difficulty: 7/10, Value: 9/10)
- Current state: Mixed domain logic across multiple top-level directories
- Key challenge: Significant restructuring of files and import paths
- High value due to improved code organization and maintainability
-
Domain-Driven Interfaces (Difficulty: 6/10, Value: 8/10)
- Current state: Scattered interfaces with minimal properties
- Key challenge: Consolidating interfaces into a shared structure
- Strong value in reducing duplication and improving type safety
-
Specialized AI Services (Difficulty: 7/10, Value: 9/10)
- Current state: Large AIService with multiple responsibilities
- Key challenge: Breaking down into focused components
- High value in improved testability and maintainability
-
Purpose-Driven Service Names (Difficulty: 4/10, Value: 8/10)
- Current state: Generic service names like
ollama.service.ts - Key challenge: Renaming services and updating references
- Good value in improved code clarity and developer experience
- Current state: Generic service names like
-
Centralized Utilities (Difficulty: 6/10, Value: 8/10)
- Current state: Scattered utilities across different packages
- Key challenge: Organizing into domain-specific packages
- Strong value in reducing duplication and improving reusability
-
Intent-Based Telegram Handlers (Difficulty: 5/10, Value: 8/10)
- Current state: Scattered command handlers
- Key challenge: Restructuring around user intent
- Good value in improved extensibility and maintenance
-
Domain Module Integration (Difficulty: 6/10, Value: 9/10)
- Current state: Separate modules without clear relationships
- Key challenge: Creating unified domain modules
- High value in improved organization and reduced complexity
-
Lifecycle Management (Difficulty: 4/10, Value: 7/10)
- Current state: Mixed initialization practices
- Key challenge: Standardizing lifecycle handling
- Moderate value in improved resource management
-
Infrastructure Layer Organization (Difficulty: 5/10, Value: 8/10)
- Current state: Mixed domain and persistence logic
- Key challenge: Separating infrastructure concerns
- Strong value in improved maintainability
-
Domain-Oriented Job Types (Difficulty: 3/10, Value: 8/10)
- Current state: Scattered job definitions with some
anytypes - Key challenge: Creating unified job type system
- Strong value in improved type safety and clarity
- Current state: Scattered job definitions with some
Implementation Strategy
Based on these evaluations, we recommend the following implementation order:
-
Start with low-hanging fruit:
- Domain-Oriented Job Types (3/10)
- Purpose-Driven Service Names (4/10)
- Lifecycle Management (4/10)
-
Address core organization:
- Intent-Based Telegram Handlers (5/10)
- Infrastructure Layer Organization (5/10)
- Centralized Utilities (6/10)
-
Tackle major improvements:
- Domain-Driven Interfaces (6/10)
- Domain Module Integration (6/10)
- Specialized AI Services (7/10)
- Unifying Domain Names (7/10)
This approach allows us to:
- Build momentum with quick wins
- Establish patterns for larger changes
- Minimize disruption to ongoing development
- Validate improvements incrementally
Each refactoring should be done in a separate branch and include:
- Updated tests
- Documentation updates
- Migration guides where needed
- Changelog entries
