Applying CQRS in Practice: Lessons from a Recent Project
We applied a pragmatic CQRS pattern using separately deployable command and query components with a shared database. GraphQL provided flexible querying, but required discipline and careful schema management to remain effective at scale.
In a recent engagement, we applied the CQRS (Command Query Responsibility Segregation) pattern within a microservice architecture. This article highlights two key aspects — CQRS and GraphQL — and how we approached the separation of concerns, the trade-offs involved, and how we adapted the pattern to balance maintainability and complexity.
Project Context
The system was a web-based platform designed to manage and evaluate professional credentials of companies against complex regulatory and client-specific requirements. It was built with Angular and React Native frontends and a .NET backend hosted in Azure.
One of the core business goals was to support rapid onboarding of dozens of client companies and tens of thousands of users within the first year. This placed strong demands on scalability, data integrity, and development speed.
This article focuses on the services responsible for entering and managing credential data — a major portion of the system’s logic and API surface.
Shared Database, Separate Runtime: A Practical CQRS Variant
While CQRS often implies separate services with their own data models or databases, in this project both the Query (GraphQL) and Command (API) services were designed as components within a single microservice. They shared a database but remained separately deployable.
This pattern was used as the basis for most services, with each having its own Query and Command components and a shared database used exclusively by that service pair.
Choices
This approach was driven by several key factors:
- The business required support for tens of thousands of users. But as a greenfield project, we had to balance scalability with development speed and complexity, avoiding overengineering.
- Most of the commands were basic CRUD operations, with data models closely aligned to those used for querying. This avoided the need for an additional projection or transformation layer.
- The majority of use cases placed heavier load on reads than writes.
By structuring services this way, we could scale each component independently. If needed, introducing a read-only replica would be straightforward.
Why Not a Single Runtime?
Technically, we could have combined the GraphQL (HotChocolate) and .NET MVC components into a single application. However, we chose to deploy them separately for several reasons:
- Authentication handling: Combining frameworks with different security models risked unexpected interactions.
- Clearer separation of concerns: Independent services helped enforce CQRS boundaries.
- Deployment flexibility: Allowed scaling and diagnostics to be tailored per component.
The shared database was a deliberate simplification. Full physical separation would have required not just read-only replication, but also distinct models — with limited benefit in our context. Structural changes would still require coordination across both components.
Instead, we introduced separation at the modeling and projection level:
- Database: Entity Framework Core with a code-first model and manually maintained migrations per commit.
- Command service: Used intermediate models and exposed public models via a layered pipeline.
- Query service: Projected directly from entity models to GraphQL types.
This kept both services decoupled from the database model while maintaining alignment where needed.
GraphQL as Query Layer: Power with a Price
We chose GraphQL (via HotChocolate) for its structured schema and powerful client-driven querying. It allowed clients to flexibly select fields, apply filters, and sort results — including across nested relationships — without needing backend changes for every variation.
While REST can support similar behavior via parameterized endpoints, and OData provides a formal query standard, both approaches come with trade-offs. OData’s syntax is verbose and becomes unwieldy for nested or deeply related data. REST can lead to over-fetching or brittle endpoints that must be updated with every change in frontend requirements.
HotChocolate provided strong .NET support in a single, integrated library — allowing us to move quickly with type safety and schema validation.
However, this came with stricter schema governance:
- Advantages
- Efficient, targeted queries
- Strong client contracts via schema
- Avoids over- and under-fetching
- Challenges
- Schema versioning can be tricky — strict validation means querying for non-existent fields results in errors. This makes backward and forward compatibility harder to manage.
- Teams unfamiliar with GraphQL face a learning curve.
One key caveat — more a people factor than technical — is that GraphQL’s efficiency only works if clients use it efficiently. Ideally, a dropdown should only request id and label, a list should request selected columns, and a detail view should request the full record. In practice, however, developers often reused broad queries across contexts. Encouraging specialized queries became a recurring topic during code reviews.
While powerful, GraphQL demands discipline — especially as the public interface must remain stable over time.
Reflections
Applying CQRS helped clarify responsibilities and improve modularity. Our adjustments — particularly around shared data — kept the design practical and maintainable:
- GraphQL provided real value, but required care around versioning and client usage.
- The layered projection model enabled decoupling without introducing unnecessary duplication.
This wasn’t a textbook CQRS implementation — and that was intentional. It was an adaptation grounded in real-world constraints, team capabilities, and long-term maintainability.