Refactoring Journey: From GistStateManager to Domain-Driven Components
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.
The Problem: A Growing "God Class"
Our GistStateManager accumulated multiple 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."
