Refactoring Journey: From GistStateManager to Domain-Driven Components
The Problem: A Growing "God Class"
In our Mercury trading bot project, we started with a seemingly simple class called GistStateManager. Its initial purpose was to manage state persistence using GitHub gists as a storage mechanism. However, as the project evolved, this class accumulated more and more responsibilities:
- Managing application state in Redis
- Reconstructing state from event streams
- Creating and updating GitHub gists
- Handling Telegram message updates
- Managing UI buttons and formatting
- Coordinating state transitions
This is a classic case of what we call a "god class" - a class that knows too much and does too much. Let's explore how we can refactor this into more focused, domain-driven components.
The Solution: Domain-Driven Decomposition
1. Core State Management: RankingStateManager
@Injectable()
export class RankingStateManager {
constructor(
private readonly redisService: RedisService,
private readonly eventStore: RankingEventStore,
) {}
async createAnalysis(config: RankingConfig): Promise<string> {
const analysisId = generateUniqueId();
// Initialize state
return analysisId;
}
async getState(analysisId: string): Promise<RankingState> {
// Get current state
}
async updateState(
analysisId: string,
state: Partial<RankingState>,
): Promise<void> {
// Update state
}
}
This component focuses solely on managing the core state of our ranking analysis. It:
- Generates unique analysis IDs
- Maintains state in Redis
- Handles state updates
- Manages analysis lifecycle
2. Artifact Management: RankingArtifactManager
@Injectable()
export class RankingArtifactManager {
constructor(
private readonly octokit: Octokit,
private readonly redisService: RedisService,
) {}
async createArtifact(
analysisId: string,
state: RankingState,
): Promise<string> {
// Create GitHub gist
return gistUrl;
}
async updateArtifact(analysisId: string, state: RankingState): Promise<void> {
// Update GitHub gist
}
}
This component handles all GitHub-related operations:
- Creating gists for new analyses
- Updating gists with state changes
- Managing gist mappings
- Handling GitHub API interactions
3. Message Management: RankingMessageManager
@Injectable()
export class RankingMessageManager {
constructor(
private readonly bot: Bot,
private readonly formatter: HermesRankingFormatter,
) {}
async updateMessage(
chatId: number,
messageId: number,
state: RankingState,
): Promise<void> {
// Update Telegram message
}
async createInitialMessage(
chatId: number,
state: RankingState,
): Promise<number> {
// Create initial message
return messageId;
}
}
This component focuses on Telegram interactions:
- Creating and updating messages
- Formatting state for display
- Managing interactive buttons
- Handling Telegram API calls
Benefits of the New Architecture
-
Clear Responsibilities
- Each component has a single, well-defined purpose
- Dependencies are explicit and minimal
- Testing becomes much easier
-
Better Error Handling
- Each component can handle its specific error cases
- Failures in one component don't affect others
- Better error recovery options
-
Improved Maintainability
- Changes to one aspect don't require touching others
- New features can be added without modifying existing code
- Documentation is clearer and more focused
-
Type Safety
- More specific types for each domain
- Better IDE support
- Fewer runtime errors
Implementation Example
Here's how it all comes together in the ranking handler:
@Injectable()
export class MarketRankingHandler {
constructor(
private readonly stateManager: RankingStateManager,
private readonly artifactManager: RankingArtifactManager,
private readonly messageManager: RankingMessageManager,
) {}
async handle(ctx: Context, config: RankingConfig): Promise<void> {
// Create new analysis
const analysisId = await this.stateManager.createAnalysis(config);
// Get initial state
const state = await this.stateManager.getState(analysisId);
// Create message
const messageId = await this.messageManager.createInitialMessage(
ctx.chat.id,
state,
);
// Create artifact
await this.artifactManager.createArtifact(analysisId, state);
// Queue the job
await this.queue.add('market-analysis', {
analysisId,
chatId: ctx.chat.id,
messageId,
});
}
}
Lessons Learned
-
Start with Clear Boundaries
- Define component responsibilities early
- Use interfaces to enforce boundaries
- Keep dependencies explicit
-
Follow Domain-Driven Design
- Let the domain guide your architecture
- Keep related functionality together
- Use domain terminology in your code
-
Embrace Change
- Be ready to refactor as needs evolve
- Keep components small and focused
- Use dependency injection for flexibility
-
Think About Testing
- Make components easily testable
- Use interfaces for external dependencies
- Keep side effects isolated
Next Steps
- Implement the new components one at a time
- Migrate existing code gradually
- Add comprehensive tests
- Update documentation
- Monitor performance and make adjustments
Remember: Good architecture is not about getting it perfect the first time, but about making it easy to change as requirements evolve.
"The only constant in software development is change. Design your systems to embrace it."
Let me analyze both services and suggest a domain-driven split:
Current RedisGistService Problems:
- Handles both Redis operations and state mutations
- Manages GitHub gist mappings
- Mixes state validation with storage
- Handles different types of mutations
Current GistStateManager Problems:
- Manages Redis state
- Handles GitHub gist operations
- Manages Telegram messages/UI
- Handles state transitions and business logic
Suggested Split:
- Redis Layer (Storage):
@Injectable()
export class RankingStateStorage {
// Core Redis operations
async store(id: string, state: RankingState): Promise<void>;
async load(id: string): Promise<RankingState>;
async delete(id: string): Promise<void>;
}
@Injectable()
export class RankingMappingStorage {
// GitHub mapping storage
async storeGithubMapping(id: string, githubId: string): Promise<void>;
async getGithubMapping(id: string): Promise<string | null>;
}
- State Management Layer:
@Injectable()
export class RankingStateMutator {
// Pure state mutation logic
applyMutation(state: RankingState, mutation: RankingMutation): RankingState;
validateMutation(mutation: RankingMutation): boolean;
}
@Injectable()
export class RankingStateValidator {
// State validation logic
validateState(state: RankingState): boolean;
validateTransition(from: RankingState, to: RankingState): boolean;
}
- Domain Operations Layer:
@Injectable()
export class RankingAnalysisService {
// High-level business operations
async createAnalysis(config: RankingConfig): Promise<string>;
async updateAnalysis(id: string, mutation: RankingMutation): Promise<void>;
async finalizeAnalysis(id: string): Promise<void>;
}
@Injectable()
export class RankingPartitionService {
// Partition-specific operations
async updatePartition(id: string, partition: PartitionUpdate): Promise<void>;
async rebalancePartition(id: string): Promise<void>;
}
- Artifact Management Layer:
@Injectable()
export class RankingArtifactService {
// GitHub artifact management
async createArtifact(id: string, state: RankingState): Promise<string>;
async updateArtifact(id: string, state: RankingState): Promise<void>;
async syncArtifact(id: string): Promise<void>;
}
Key Benefits:
- Each service has a single responsibility
- Clear separation between storage, state management, and business logic
- Easier testing and maintenance
- More explicit dependencies
- Better error handling per domain
- Clearer upgrade paths
The names now reflect:
- What they do (Storage, Mutator, Validator, Service)
- Their domain (Ranking)
- Their specific responsibility (State, Partition, Artifact)
This is just a suggestion - we can adjust based on the actual usage patterns and requirements. Would you like me to elaborate on any particular part or suggest a different organization?