When the Catalyst PSA platform started experiencing performance bottlenecks with complex reporting queries slowing down transaction processing, we knew we needed a better approach. That's when we implemented Command Query Responsibility Segregation (CQRS), and it fundamentally changed how we architected our enterprise application.
The Problem: Reads and Writes Don't Scale the Same Way
Traditional CRUD applications use the same models and database structures for both reading and writing data. This works fine for simple apps, but enterprise applications have different characteristics:
- Writes are typically transactional, need strong consistency, and involve complex business logic
- Reads need to be fast, can tolerate eventual consistency, and often require data denormalized across multiple entities
In Catalyst PSA, we had project managers running complex reports that joined 10+ tables, locking rows and slowing down time entry submissions from field workers. The same database schema that was perfect for maintaining data integrity was terrible for reporting performance.
What is CQRS?
CQRS separates your application into two distinct paths:
- Command Side: Handles all state changes (Creates, Updates, Deletes)
- Query Side: Handles all data retrieval (Reads)
Each side can use different models, databases, and optimization strategies. The key insight is that the way you write data doesn't have to be the way you read it.
Our CQRS Implementation in Catalyst PSA
Here's how we implemented CQRS in our NestJS application:
Command Side: Write Models
Commands represent intentions to change state. They're explicit about what needs to happen:
// commands/create-project.command.ts
export class CreateProjectCommand {
constructor(
public readonly name: string,
public readonly clientId: string,
public readonly startDate: Date,
public readonly managerId: string,
public readonly budget: number
) {}
}
// command-handlers/create-project.handler.ts
@CommandHandler(CreateProjectCommand)
export class CreateProjectHandler implements ICommandHandler<CreateProjectCommand> {
constructor(
@Inject('ProjectRepositoryPort')
private readonly repository: ProjectRepositoryPort,
private readonly eventBus: EventBus
) {}
async execute(command: CreateProjectCommand): Promise<string> {
// Validate business rules
const project = Project.create(
command.name,
command.clientId,
command.startDate,
command.managerId,
command.budget
);
await this.repository.save(project);
// Publish event for query side
this.eventBus.publish(
new ProjectCreatedEvent(
project.id,
project.name,
project.clientId,
command.managerId
)
);
return project.id;
}
}Notice how commands are fire-and-forget operations that return minimal data. They focus purely on executing the business logic and publishing events.
Query Side: Read Models
Queries are optimized for specific use cases. We created read models denormalized for fast retrieval:
// queries/get-project-dashboard.query.ts
export class GetProjectDashboardQuery {
constructor(public readonly projectId: string) {}
}
// query-handlers/get-project-dashboard.handler.ts
@QueryHandler(GetProjectDashboardQuery)
export class GetProjectDashboardHandler implements IQueryHandler<GetProjectDashboardQuery> {
constructor(private readonly db: DrizzleService) {}
async execute(query: GetProjectDashboardQuery): Promise<ProjectDashboardDTO> {
// Direct database query with denormalized data
const result = await this.db
.select({
projectId: projectDashboards.id,
projectName: projectDashboards.name,
clientName: projectDashboards.clientName,
managerName: projectDashboards.managerName,
totalHours: projectDashboards.totalHours,
totalBilled: projectDashboards.totalBilled,
budgetRemaining: projectDashboards.budgetRemaining,
activeTaskCount: projectDashboards.activeTaskCount,
completedTaskCount: projectDashboards.completedTaskCount,
teamMembers: projectDashboards.teamMembers, // JSON array
})
.from(projectDashboards)
.where(eq(projectDashboards.id, query.projectId))
.limit(1);
return result[0];
}
}The query side reads from denormalized tables specifically designed for each view. No joins, no complex aggregations—just fast lookups.
Event Handlers: Keeping Read Models in Sync
The magic happens in event handlers that update read models when commands change state:
@EventsHandler(ProjectCreatedEvent)
export class ProjectCreatedReadModelUpdater implements IEventHandler<ProjectCreatedEvent> {
constructor(private readonly db: DrizzleService) {}
async handle(event: ProjectCreatedEvent): Promise<void> {
// Fetch additional data needed for read model
const client = await this.db
.select()
.from(clients)
.where(eq(clients.id, event.clientId))
.limit(1);
const manager = await this.db
.select()
.from(users)
.where(eq(users.id, event.managerId))
.limit(1);
// Insert denormalized record
await this.db.insert(projectDashboards).values({
id: event.projectId,
name: event.name,
clientId: event.clientId,
clientName: client[0].name,
managerId: event.managerId,
managerName: `${manager[0].firstName} ${manager[0].lastName}`,
totalHours: 0,
totalBilled: 0,
budgetRemaining: event.budget,
activeTaskCount: 0,
completedTaskCount: 0,
teamMembers: [],
});
}
}When time entries are added, tasks completed, or team members assigned, separate event handlers update the relevant parts of the read model.
Structuring the Codebase
We organized our CQRS code by feature:
src/projects/
commands/
create-project.command.ts
update-project.command.ts
command-handlers/
create-project.handler.ts
update-project.handler.ts
queries/
get-project-dashboard.query.ts
get-project-list.query.ts
query-handlers/
get-project-dashboard.handler.ts
get-project-list.handler.ts
events/
project-created.event.ts
project-updated.event.ts
event-handlers/
project-created-read-model-updater.ts
project-updated-read-model-updater.ts
This made it easy to see all the ways you could interact with projects.
The Results: Dramatic Performance Improvements
After implementing CQRS for our most-used features:
- Dashboard load times: Reduced from 3-5 seconds to under 200ms
- Report generation: 10x faster (from 30s to 3s for complex reports)
- Write throughput: Unchanged—no performance penalty on the command side
- Database contention: Eliminated—reads and writes hit different tables
We also gained the ability to scale reads and writes independently. The query side could use read replicas, caching, even different databases like MongoDB for document-heavy reads.
Challenges and Lessons Learned
Challenge 1: Eventual Consistency
CQRS means your read models are eventually consistent, not immediately consistent. For Catalyst PSA, this was mostly fine—users understood that dashboards might lag by a second or two. But for some operations (like "create project and redirect to project page"), we needed to ensure the read model was updated first.
Our solution was to make critical event handlers synchronous:
@EventsHandler(ProjectCreatedEvent)
export class ProjectCreatedReadModelUpdater implements IEventHandler<ProjectCreatedEvent> {
async handle(event: ProjectCreatedEvent): Promise<void> {
await this.updateReadModel(event);
// Handler won't complete until read model is updated
}
}Challenge 2: Data Duplication
Denormalized read models mean data duplication. When a client changes their name, we had to update it in multiple read models. This was manageable with good event handlers, but it required discipline.
Challenge 3: Choosing What to CQRS
We didn't use CQRS everywhere—that would have been overkill. We applied it to:
- Complex dashboards and reports
- High-traffic features (time entry lists)
- Features where read/write patterns differed significantly
Simple CRUD operations stayed traditional. Not every feature needs CQRS.
Challenge 4: Testing Event Handlers
Event handlers became critical infrastructure. If they failed, read models would be stale. We wrote extensive integration tests:
describe('ProjectCreatedReadModelUpdater', () => {
it('should create denormalized dashboard record', async () => {
const event = new ProjectCreatedEvent(
'proj-1',
'Test Project',
'client-1',
'manager-1'
);
await handler.handle(event);
const dashboard = await db
.select()
.from(projectDashboards)
.where(eq(projectDashboards.id, 'proj-1'));
expect(dashboard[0]).toMatchObject({
name: 'Test Project',
clientName: 'Test Client',
managerName: 'John Doe',
});
});
});When to Use CQRS
CQRS isn't for every application. Use it when:
- Read and write operations have different performance characteristics
- You need to scale reads and writes independently
- Your read models need different structures than your write models
- You're building complex reporting on top of transactional data
Don't use it when:
- Your app is simple CRUD with no performance issues
- Read and write patterns are similar
- You're building an MVP and need to move fast
Beyond CQRS: Event Sourcing
We explored Event Sourcing (storing events as the source of truth, not current state) but decided against it for Catalyst PSA. Event Sourcing adds significant complexity and was overkill for our needs. CQRS alone gave us 90% of the benefits without the complexity.
Conclusion
Implementing CQRS in the Catalyst PSA platform was one of our best architectural decisions. It solved real performance problems, made our codebase more maintainable by making read and write operations explicit, and gave us the flexibility to optimize each side independently.
The key is not to use CQRS everywhere, but to apply it strategically where read/write patterns diverge. Combined with Hexagonal Architecture and DDD, CQRS helped us build a platform that scaled while staying maintainable and performant.
After 27 years of building applications, I can confidently say that CQRS is an essential pattern for any enterprise application that needs to handle both complex transactions and high-performance reads.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.