← Back to Blog
CQRS Pattern Implementation in Enterprise Applications

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.

Share this article

Help others discover this content


Jason Cochran

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.