Do you want your ad here?

Contact us to get your ad seen by thousands of users every day!

[email protected]

Introduction to CQRS using MongoDB

  • June 09, 2026
  • 7 min read
Likes ...
Comments ...
Table of Contents
PrerequisitesStep 1: Create the entitiesStep 2: Creating CommandStep 3: Create QueryConclusion

In enterprise environments, projects often begin with a simple structure: one model, one service, and one document, using a single class and data transfer object for both read and write operations. While this unified approach works at first, it becomes problematic as requirements grow. Operations become more complex, requiring additional validations, rules, and constraints. Over time, read operations may demand different formats, such as aggregations, summaries, or custom views. Relying on a single model for both reading and writing leads to maintenance challenges and inefficient queries. This approach can result in returning unnecessary data or omitting required information, violating the single responsibility principle and making the design less effective.

The distinction between read and write operations becomes increasingly important as their goals diverge. Command Query Responsibility Segregation (CQRS) addresses this by separating read (Query) and write (Command) operations into distinct models. This architectural pattern allows each model to be optimized, scaled, and secured independently, reducing conflicts and improving overall system efficiency.

This tutorial introduces the CQRS architectural pattern and demonstrates how to implement it with MongoDB.

In this tutorial, you’ll:

  • Model a simple payment system.
  • Model and interact with MongoDB using Java.
  • Explore how MongoDB can help you achieve a CQRS design.

You can find all the code presented in this tutorial in the GitHub repository:

git clone [email protected]:soujava/helidon-mongodb-cqrs.git

Prerequisites

For this tutorial, you’ll need:

  • Java 21.
  • Maven.
  • A MongoDB cluster.

You can use the following Docker command to start a standalone MongoDB instance:

docker run --rm -d --name mongodb-instance -p 27017:27017 mongo

By decoupling read and write operations, CQRS assigns each its own responsibility. Instead of using one model to manage both state changes and data retrieval, CQRS separates these into commands and queries. Commands represent actions and enforce business rules, while queries retrieve information from purpose-built data models. This separation avoids conflicts and allows each side to evolve independently.

This tutorial focuses on a specific, instructional use case: authorizing card usage. Instead of building a complete financial system, we concentrate on authorizing transactions and retrieving transaction history. This limited scope helps demonstrate the process of creating both command and query components.To maintain consistency with the previous post, we will use Helidon with MicroProfile. You may define the groupId, artifactId, version, and package name as you prefer. After downloading the project, include the MongoDB integration—Eclipse JNoSQL—in the pom.xml file at the project root:

To maintain consistency with the previous post, we will use Helidon with MicroProfile. You may define the groupId, artifactId, version, and package name as you prefer. After downloading the project, include the MongoDB integration—Eclipse JNoSQL—in the pom.xml file at the project root:

<dependency>
   <groupId>org.eclipse.jnosql.databases</groupId>
   <artifactId>jnosql-mongodb</artifactId>
   <version>1.1.13</version>
</dependency>

With the project defined, the next step is to set the database configuration. You can either use a local database or explore MongoDB Atlas; either is fine. We will use locally, thus, run this Docker command to start a MongoDB instance:

Include those new properties:

# configure the MongoDB client for a replica set of two nodes
jnosql.mongodb.url=mongodb+srv://admin:<db_password>@cluster0.gblhb3d.mongodb.net/?appName=devrel-article-java-jnosql
# mandatory define the database name
jnosql.document.database=cards
jnosql.mongodb.application.name=devrel-article-java-jnosql

PRO TIP: MongoDB Atlas is a valuable Database-as-a-Service option. It simplifies operations by delegating database management to MongoDB experts.

Step 1: Create the entities

The first step is to create the necessary entities. We need entities for managing status and a separate entity for queries, which can serve as an aggregator or summary. On the command side, we define two entities: Card, which holds the current status and available amount, and OperationResult, which records each card operation attempt. OperationResult is immutable and cannot be changed once it is stored in the database.

package com.acme.cards.command;

import com.acme.cards.infraestructure.JsonFieldStrategy;
import jakarta.json.bind.annotation.JsonbVisibility;
import jakarta.nosql.Column;
import jakarta.nosql.Entity;
import jakarta.nosql.Id;

import java.math.BigDecimal;
import java.util.Objects;
import java.util.UUID;

@Entity
@JsonbVisibility(JsonFieldStrategy.class)
public class Card {

    @Id
    private UUID id;

    @Column
    private BigDecimal availableBalance;

    @Column
    private CardOperationStatus status;

    Card() {
    }

    public Card(UUID id, BigDecimal availableBalance, CardOperationStatus status) {
        this.id = id;
        this.availableBalance = availableBalance;
        this.status = status;
    }

    public UUID getId() {
        return id;
    }

    public BigDecimal getAvailableBalance() {
        return availableBalance;
    }

    public CardOperationStatus getStatus() {
        return status;
    }

    /**
     * Determines whether a card can authorize a transaction for the given amount.
     * The authorization is possible only if the card is active and the available
     * balance is greater than or equal to the specified amount.
     *
     * @param amount the transaction amount to be authorized
     * @return true if the card is active and has enough available balance
     *         to authorize the transaction, false otherwise
     */
    public boolean canAuthorize(BigDecimal amount) {
        return status == CardOperationStatus.ACTIVE
                && availableBalance.compareTo(amount) >= 0;
    }

    /**
     * Deducts the specified amount from the available balance of the card.
     *
     * @param amount the amount to be debited from the card's available balance
     */
    public void debit(BigDecimal amount) {
        this.availableBalance = this.availableBalance.subtract(amount);
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Card card)) {
            return false;
        }
        return Objects.equals(id, card.id);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(id);
    }

    @Override
    public String toString() {
        return "Card{" +
                "id=" + id +
                ", availableBalance=" + availableBalance +
                ", cardOperationStatus=" + status +
                '}';
    }
}
package com.acme.cards.command;

import jakarta.nosql.Column;
import jakarta.nosql.Entity;
import jakarta.nosql.Id;

import java.time.Instant;
import java.util.UUID;

@Entity
public record OperationResult(@Id UUID id,
                              @Column UUID cardId,
                              @Column OperationStatus status,
                              @Column String reason,
                              @Column Instant processedAt) {
}
package com.acme.cards.command;

public enum CardOperationStatus {
    ACTIVE, BLOCKED
}
package com.acme.cards.command;

public enum OperationStatus {
    APPROVED, DECLINED
}

After creating the command operations, the next step is to define the entity where we will store the transactions and operations. This entity will define where the user will read. This one is where we will process the data and make it available to the read operations.

package com.acme.cards.query;

import jakarta.nosql.Column;
import jakarta.nosql.Entity;
import jakarta.nosql.Id;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

@Entity
public record TransactionView(@Id UUID id,
                              @Column UUID cardId,
                              @Column BigDecimal amount,
                              @Column String status,
                              @Column Instant createdAt) {
}

To simplify the structure, we will create a REST API to generate cards. Based on these cards, we will handle debit operations and check if a card has sufficient available balance.

package com.acme.cards.command;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;

import jakarta.ws.rs.core.Response;
import org.eclipse.jnosql.mapping.document.DocumentTemplate;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.logging.Logger;
import java.util.stream.IntStream;

@Path("/cards")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@ApplicationScoped
public class CardResource {

    private static final Logger LOGGER = Logger.getLogger(CardResource.class.getName());
    private static final BigDecimal INITIAL_BALANCE = new BigDecimal("1000");

    private final DocumentTemplate template;

    @Inject
    public CardResource(DocumentTemplate template) {
        this.template = template;
    }

    CardResource() {
        this.template = null;
    }

    @GET
    public List<Card> findAll() {

        LOGGER.info("Fetching all cards");

        List<Card> cards = template.select(Card.class).result();
        if (cards.isEmpty()) {
            LOGGER.warning("No cards found. Seeding initial dataset...");
            cards = generateCards();;
        }
        LOGGER.info("Returning " + cards.size() + " cards");
        return cards;
    }

    @GET
    @Path("/{id}")
    public Card findById(@PathParam("id") UUID id) {

        LOGGER.info("Fetching card with id=" + id);

        return template.find(Card.class, id).orElseThrow(() ->
                   new WebApplicationException("Card not found with the id: " + id, Response.Status.NOT_FOUND)
                );
    }

    private List<Card> generateCards() {
        List<Card> cards = new ArrayList<>(); ;

        IntStream.range(0, 5)
                .mapToObj(i -> new Card(
                        UUID.randomUUID(),
                        INITIAL_BALANCE,
                        CardOperationStatus.ACTIVE
                ))
                .forEach(card -> {
                    cards.add(template.insert(card));
                    LOGGER.fine("Seeded card with id=" + card.getId()
                            + " and balance=" + card.getAvailableBalance());
                });

        LOGGER.info("Finished seeding cards + " + cards.size() + " cards created");
        return cards;
    }
}

With this structure, we can now work with cards. In a real-world scenario, additional card statuses such as frozen or canceled would be included. Here, we focus on a small part of the problem to highlight the architectural pattern.

Step 2: Creating Command

The next step is to create the command responsible for write operations. Here, we use the AuthorizeCardCommand, which encapsulates the attributes needed for card operations. This approach avoids handling numerous parameters by using a single class.

package com.acme.cards.command;

import java.math.BigDecimal;
import java.util.UUID;

public record AuthorizeCardCommand(UUID cardId, BigDecimal amount, String reason) {
}

The next class is the handler responsible for processing authorizations. When a debit is requested, it checks if sufficient funds are available. If so, it processes the transaction and updates the TransactionView, as addressed in the query section.

package com.acme.cards.command;

import com.acme.cards.query.TransactionView;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import jakarta.ws.rs.WebApplicationException;
import org.eclipse.jnosql.mapping.document.DocumentTemplate;

import java.time.Instant;
import java.util.UUID;
import java.util.logging.Logger;

import static jakarta.ws.rs.core.Response.Status.NOT_FOUND;

@ApplicationScoped
public class AuthorizeCardCommandHandler {

    private static final Logger LOGGER = Logger.getLogger(AuthorizeCardCommandHandler.class.getName());

    private final DocumentTemplate template;

    @Inject
    public AuthorizeCardCommandHandler(DocumentTemplate template) {
        this.template = template;
    }

    AuthorizeCardCommandHandler() {
        this.template = null;
    }

    public OperationResult handle(AuthorizeCardCommand command) {

        LOGGER.info("Processing authorize command for cardId=" + command.cardId()
                + " amount=" + command.amount());

        var card = template.find(Card.class, command.cardId())
                .orElseThrow(() -> new WebApplicationException("Card not found, cardid=" + command.cardId(), NOT_FOUND));

        var operationId = UUID.randomUUID();

        if (!card.canAuthorize(command.amount())) {
            LOGGER.warning("Authorization declined for cardId=" + command.cardId());

            var result = new OperationResult(
                    operationId,
                    card.getId(),
                    OperationStatus.DECLINED,
                    "Insufficient balance or inactive card",
                    Instant.now()
            );

            template.insert(result);

            return result;
        }

        card.debit(command.amount());
        template.update(card);

        LOGGER.info("Authorization approved for cardId=" + command.cardId());

        var result = new OperationResult(
                operationId,
                card.getId(),
                OperationStatus.APPROVED,
                command.reason(),
                Instant.now()
        );

        template.insert(result);
        updateProjection(command, result);

        return result;
    }

    private void updateProjection(AuthorizeCardCommand command, OperationResult result) {

        LOGGER.info("Updating transaction view for id=" + result.id());

        var view = new TransactionView(
                result.id(),
                command.cardId(),
                command.amount(),
                result.status().name(),
                result.processedAt()
        );

        template.insert(view);
    }
}

Finally, we define the resource that initiates the command operation:

package com.acme.cards.command;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;

import java.util.UUID;
import java.util.logging.Logger;

@Path("/cards/{id}/authorize")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@ApplicationScoped
public class CardCommandResource {

    private static final Logger LOGGER = Logger.getLogger(CardCommandResource.class.getName());

    private final AuthorizeCardCommandHandler handler;

    @Inject
    public CardCommandResource(AuthorizeCardCommandHandler handler) {
        this.handler = handler;
    }

    CardCommandResource() {
        this.handler = null;
    }

    @POST
    public OperationResult authorize(@PathParam("id") UUID cardId, AuthorizeRequest request) {

        LOGGER.info("Received authorize request for cardId=" + cardId);

        var command = new AuthorizeCardCommand(cardId, request.amount(), request.reason());
        return handler.handle(command);
    }

}

Step 3: Create Query

The final step is to create the query resource. While the command writes transactions, the query retrieves processed transactions for a given card.

package com.acme.cards.query;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;

import org.eclipse.jnosql.mapping.document.DocumentTemplate;

import java.util.List;
import java.util.UUID;
import java.util.logging.Logger;

@Path("/cards/{id}/transactions")
@Produces(MediaType.APPLICATION_JSON)
@ApplicationScoped
public class CardQueryResource {

    private static final Logger LOGGER = Logger.getLogger(CardQueryResource.class.getName());

    private final DocumentTemplate template;

    @Inject
    public CardQueryResource(DocumentTemplate template) {
        this.template = template;
    }

    CardQueryResource() {
        this.template = null;
    }

    @GET
    public List<TransactionView> findByCardId(@PathParam("id") UUID cardId) {

        LOGGER.info("Fetching transactions for cardId=" + cardId);

        return template.select(TransactionView.class)
                .where("cardId")
                .eq(cardId)
                .orderBy("createdAt")
                .desc()
                .result();
    }
}

Conclusion

CQRS is not simply about dividing code for architectural reasons. It recognizes that reads and writes have distinct purposes. Separating commands from queries allows each to focus on its role: commands enforce business rules and generate decisions, while queries deliver optimized views of the system’s state. In this tutorial, we demonstrated this approach using a basic card authorization flow, highlighting the difference between a decision (OperationResult) and the data used for reading (TransactionView).

This example uses a limited scope to clarify the concept. CQRS is not a default choice, but a response to complexity, particularly when read and write requirements diverge. By understanding this separation in a focused scenario, you can better assess when CQRS is valuable and apply it where it has the most impact.

Ready to explore the benefits of MongoDB Atlas? Get started now by trying MongoDB Atlas.

Access the source code used in this tutorial. Any questions? Come chat with us in the MongoDB Community Forum.

References:

  • June 09, 2026
  • 7 min read
Likes ...
Comments ...
Abstracting Data Access in Java With the DAO Pattern

Table of Contents A simple implementation Prerequisites The domain class The DAO API The ProductDao class Using the DAO Using MongoDB as the persistence layer Setting up MongoDB The MongoDBProductDao class Connecting to MongoDB The application class Advanced considerations Preventing …

Agents Meet Databases: The Future of Agentic Architectures

Table of Contents A Quick Overview of AgentsPath 1: Standardized Integration with MCP serversPath 2: Custom Integrations for Control and FlexibilityAccuracy, Security, and Performance Considerations Accuracy: Ensure Reliable Query Generation Security: Maintain Protection and Guardrails Performance: Manage Unpredictable Agentic Workloads …

AI-Powered Code Review Assistant: Automated Code Analysis with Spring AI and MongoDB

Table of Contents Prerequisites1. Project setup2. Storing and managing review patterns Defining the pattern model Creating the repository Building the service layer Exposing the REST endpoints 3. Embedding patterns with Spring AI and MongoDB Atlas Vector Search Adding Spring AI …

Apache Cassandra 4.0: Taming Tail Latencies with Java 16 ZGC

With Apache Cassandra 4.0, you not only get the direct improvements to performance added by the Apache Cassandra committers, you also unlock the ability to take advantage of seven years of improvements in the JVM itself.

This article focuses on improvements in Java garbage collection that Cassandra 4.0 coupled with Java 16 offers over Cassandra 3.11 on Java 8.

Overview of cluster configuration details on MongoDB Atlas web browser
Atlas Online Archive: Efficiently Manage the Data Lifecycle

Table of Contents Problem statementResolution Advantages Limitations Pre-requisites Online archival configuration setupConclusion Problem statement In the production environment, in a MongoDB Atlas database, a collection contains massive amounts of data stored, including aged and current data. However, aged data is …

Do you want your ad here?

Contact us to get your ad seen by thousands of users every day!

[email protected]

Comments (0)

Highlight your code snippets using [code lang="language name"] shortcode. Just insert your code between opening and closing tag: [code lang="java"] code [/code]. Or specify another language.

No comments yet. Be the first.

Mastodon

Subscribe to foojay updates:

https://foojay.io/feed/
Copied to the clipboard