DEV Community

MongoDB Guests for MongoDB

Posted on

Introduction to Events using MongoDB

This article was written by Otavio Santana.

Keeping software simple is one of the hardest challenges for any software architect or senior engineer. Not because of technology, but because of decisions we often overlook—especially coupling. Many systems start simple, but as new requirements emerge, the same code begins to orchestrate multiple responsibilities. What once felt clean becomes fragile, and small changes require touching multiple parts of the system. This is how architectures silently degrade. The good news is that there are ways to address this problem. In this tutorial, we will explore an architectural approach that helps reduce coupling and improve extensibility, and how MongoDB can effectively support this style.

In this tutorial, you’ll:

  • Model a simple payment system
  • Write events sync and async events
  • Explore how MongoDB can help you on archive Event-driven design.

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

git clone git@github.com:soujava/helidon-mongodb-event-driven.git
Enter fullscreen mode Exit fullscreen mode

Prerequisites

For this tutorial, you’ll need:

  • Java 21.
  • Maven.
  • A MongoDB cluster.
  • MongoDB Atlas (Option 1)
  • Docker (Option 2)

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

docker run --rm -d --name mongodb-instance -p 27017:27017 mongo
Enter fullscreen mode Exit fullscreen mode

When we discuss event-driven design, we change the approach or the question: Instead of asking “who should I call next?”, we start asking: “What just happened?”

This change allows modules to interact without direct dependencies. Different parts of the system can react independently—either synchronously or asynchronously—without modifying the original flow.
As a first step, we need to understand what an event is. An event represents a fact that has already happened in the system. It is immutable and carries enough information for other components to react. Instead of telling the system what to do, events describe what occurred, allowing behavior to emerge through reactions.

In Domain-Driven Design (DDD), events take on a more specific role. They represent meaningful events in the business domain, using the same language as the business itself. These domain events can also act as integration points, enabling different parts of the system, including across bounded contexts—or even external systems—to respond to business changes. Event-driven goes beyond DDD, allowing facts on both the technical and the domain side.

In this tutorial, we will create an over-simplified payment system where, given a payment for a product, we will issue a payment request to an external provider, which will start an async event. Based on that, we will generate an event depending on whether the payment succeeded or failed. The whole process starts at the JAX-RS endpoint, generating a sequence of events and states inside the payment.

Sequence of events and states

In this tutorial, we will simplify using CDI events, but you can naturally enhance it or use another service to handle those events, such as Kafka or a JMS Broker. Thus, we will use MongoDB and Helidon. Creating a Helidon project is simple: use the Helidon Starter, and in the wizard progress flow, you will select Helidon MP, QuickStart, and JSON-B. Feel free to define the groupid, artifactId, version, and package name as you wish. After downloading, the next step is including the MongoDB integration, in this case, Eclipse JNoSQL, in the pom.xml file in the root of the downloaded project:

<dependency>
   <groupId>org.eclipse.jnosql.databases</groupId>
   <artifactId>jnosql-mongodb</artifactId>
   <version>1.1.13</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

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://localhost:27017
# mandatory: define the database name
jnosql.document.database=ecommerce
Enter fullscreen mode Exit fullscreen mode

Step 1: Create the entities

With the project defined, the next step is to create the entities and repositories that will support it.

In the src/main/java directory, create a Product class:

package com.acme;

import jakarta.nosql.Column;
import jakarta.nosql.Embeddable;

import java.util.UUID;

@Embeddable(Embeddable.EmbeddableType.GROUPING)
public record Product(@Column UUID code, @Column String name) {

}
Enter fullscreen mode Exit fullscreen mode

Create a PaymentCounter class:

package com.acme.statistics;

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

import java.util.UUID;

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

    @Id
    private UUID productCode;

    @Column
    private Product product;

    @Column
    private int successfulPayments;

    @Column
    private int failedPayments;

    PaymentCounter(Product product) {
        this.productCode = product.code();
        this.product = product;
    }

    PaymentCounter() {
    }

    public void paymentFailed() {
        this.failedPayments++;
    }

    public void paymentSucceeded() {
        this.successfulPayments++;
    }

    @Override
    public String toString() {
        return "PaymentCounter{" +
                "productCode=" + productCode +
                ", successfulPayments=" + successfulPayments +
                ", failedPayments=" + failedPayments +
                '}';
    }
}
Enter fullscreen mode Exit fullscreen mode

And the Payment and PaymentStatus classes:

package com.acme.payment;

import com.acme.Product;
import com.acme.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 Payment {

    @Id
    private UUID id;

    @Column
    private BigDecimal amount;

    @Column
    private PaymentStatus status;

    @Column
    private Product product;

    Payment() {
    }

    Payment(Product product, BigDecimal amount, PaymentStatus status) {
        this.id = UUID.randomUUID();
        this.amount = amount;
        this.status = status;
        this.product = product;
    }

    public UUID getId() {
        return id;
    }

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

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

    public Product getProduct() {
        return product;
    }

    @Override
    public String toString() {
        return "Payment{" +
                "id='" + id + '\'' +
                ", amount=" + amount +
                ", status=" + status +
                ", product=" + product +
                '}';
    }

    public void failed() {
        this.status = PaymentStatus.FAILED;
    }

    public void confirmed() {
        this.status = PaymentStatus.CONFIRMED;
    }
}


//different file

package com.acme.payment;

public enum PaymentStatus {
    PENDING,
    CONFIRMED,
    FAILED
}
Enter fullscreen mode Exit fullscreen mode

Explanation of annotations:

  • @entity: Marks the Room class as a database entity for management by Jakarta NoSQL.
  • @id: Indicates the primary identifier for the entity, uniquely distinguishing each document in the MongoDB collection.
  • @column: Maps fields (roomNumber, type) for reading from or writing to MongoDB.

With the entities created, the next step is creating the repositories for each entity. In our tutorial, we simplified Product as a Value Object.

package com.acme.payment;

import jakarta.data.repository.BasicRepository;
import jakarta.data.repository.Repository;

import java.util.UUID;

@Repository
public interface PaymentRepository extends BasicRepository<Payment, UUID> {
}

//different file
package com.acme.statistics;

import jakarta.data.repository.BasicRepository;
import jakarta.data.repository.Repository;

import java.util.UUID;

@Repository
public interface PaymentCounterRepository extends BasicRepository<PaymentCounter, UUID> {
}
Enter fullscreen mode Exit fullscreen mode

With both entities and repositories defined, the next step is defining the events that we will handle on this system.

Step 2: Create the events

In this sample, we will have three events to represent three states of the payment: when started (pending) ,because it requires operation on an external payment service; Payment failed; or confirmed based on the response from this external payment provider.

In the src/main/java directory, create a Product class:

package com.acme.payment;

public record PaymentFailedEvent(Payment payment) {
}
//different file
package com.acme.payment;

public record PaymentConfirmedEvent(Payment payment) {
}
//different file
package com.acme.payment;

public record PaymentRequestedEvent(Payment payment) {
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Generate the listeners

At this event, we will have listeners or classes that respond to some facts or events in our system. Using CDI that is pretty simple, we just need to have a parameter with the class to be observed and put either Observes or ObservesAsync to observe synchronous or asynchronous simultaneously.

We will start creating a service to update the status based on both confirmed or failed payment. This service will find the payment by id and then update the status.

In the src/main/java directory, create a PaymentStatusService class:

package com.acme.payment;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;

import java.util.logging.Logger;

@ApplicationScoped
class PaymentStatusService {

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

    private final PaymentRepository repository;

    @Inject
    PaymentStatusService(PaymentRepository repository) {
        this.repository = repository;
    }

    PaymentStatusService() {
        this.repository = null;
    }

    void payed(@Observes PaymentConfirmedEvent event) {
        LOGGER.info("Payment " + event.payment().getId() + " was payed");
        var payment = repository.findById(event.payment().getId()).orElseThrow();
        payment.confirmed();
        repository.save(payment);
        LOGGER.info("Payment " + payment.getId() + " was confirmed");
    }

    void errorOnPayment(@Observes PaymentFailedEvent event) {
        LOGGER.info("Payment " + event.payment().getId() + " failed");
        var payment = repository.findById(event.payment().getId()).orElseThrow();
        payment.failed();
        repository.save(payment);
        LOGGER.info("Payment " + payment.getId() + " was failed");
    }
}
Enter fullscreen mode Exit fullscreen mode

The next step is the counter that will register success and failure based on the product code.

In the src/main/java directory, create a PaymentCounterService class:

package com.acme.statistics;

import com.acme.Product;
import com.acme.payment.Payment;
import com.acme.payment.PaymentFailedEvent;
import com.acme.payment.PaymentConfirmedEvent;
import jakarta.data.Order;
import jakarta.data.Sort;
import jakarta.data.page.Page;
import jakarta.data.page.PageRequest;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;

import java.util.List;
import java.util.function.Consumer;
import java.util.logging.Logger;

@ApplicationScoped
public class PaymentCounterService {

    private static final Order<PaymentCounter> PRODUCT_CODE = Order.by(Sort.asc("productCode"));

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

    private final PaymentCounterRepository repository;

    @Inject
    PaymentCounterService(PaymentCounterRepository repository) {
        this.repository = repository;
    }

    PaymentCounterService() {
        this.repository = null;
    }

    void success(@Observes PaymentConfirmedEvent event) {
        LOGGER.info("Payment successful, incrementing counter: " + event.payment().getId());
        execute(PaymentCounter::paymentSucceeded, event.payment().getProduct());
    }


    void success(@Observes PaymentFailedEvent event) {
        LOGGER.info("Payment failed, incrementing counter: " + event.payment().getId());
        execute(PaymentCounter::paymentFailed, event.payment().getProduct());
    }

    void execute(Consumer<PaymentCounter> action, Product product) {
        PaymentCounter counter = repository.findById(product.code()).orElseGet(() -> new PaymentCounter(product));
        LOGGER.info("Counter found: " + counter);
        action.accept(counter);
        LOGGER.info("Counter incremented: " + counter);
        repository.save(counter);
    }

    public List<PaymentCounter> findAll(PageRequest pageRequest) {
        LOGGER.info("Finding all counters, page: " + pageRequest.page());
        Page<PaymentCounter> counters = repository.findAll(pageRequest, PRODUCT_CODE);
        return counters.content();
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we will have the service that will handle an external provider. To simplify, we will set a wait of 2 seconds, and then based on the counter defined as either success or failure, this one uses an asynchronous event, as you can see in the annotation.

In the src/main/java directory, create a PaymentProvider class:

package com.acme.payment.provider;

import com.acme.payment.PaymentFailedEvent;
import com.acme.payment.PaymentRequestedEvent;
import com.acme.payment.PaymentConfirmedEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Event;
import jakarta.enterprise.event.ObservesAsync;
import jakarta.inject.Inject;

import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Logger;

@ApplicationScoped
class PaymentProvider {

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

    private final AtomicLong counter = new AtomicLong();

    private final Event<PaymentConfirmedEvent> successEvent;

    private final Event<PaymentFailedEvent> errorEvent;

    @Inject
    PaymentProvider(Event<PaymentConfirmedEvent> successEvent, Event<PaymentFailedEvent> errorEvent) {
        this.successEvent = successEvent;
        this.errorEvent = errorEvent;
    }

    PaymentProvider() {
        this.successEvent = null;
        this.errorEvent = null;
    }

    void receivePayment(@ObservesAsync PaymentRequestedEvent event) {
        LOGGER.info("Processing payment: " + event.payment().getId());
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        if(counter.get() % 2 == 0) {
            LOGGER.warning("Payment failed: " + event.payment().getId());
            errorEvent.fire(new PaymentFailedEvent(event.payment()));
        } else {
            LOGGER.info("Payment successful: " + event.payment().getId());
            successEvent.fire(new PaymentConfirmedEvent(event.payment()));
        }
        LOGGER.info("Payment processed: " + event.payment().getId() + " - Confirmation #" + counter.incrementAndGet());
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Generating the rest endpoint

The observers are done, but we need one way to start the whole process. In this case, we will use a rest end-point. This resource will have two methods, one to create a payment and another to paginate the current payments.

At src/main/java, create the PaymentResource class.

package com.acme.payment;

import jakarta.data.page.PageRequest;
import jakarta.inject.Inject;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;

import java.util.List;

@Path("/payments")
public class PaymentResource {


    private final PaymentService service;

    @Inject
    public PaymentResource(PaymentService service) {
        this.service = service;
    }

    PaymentResource() {
        this.service = null;
    }

    @POST
    public Response create(PaymentRequest request) {
        return Response.accepted(service.create(request)).build();
    }

    @GET
    public List<Payment> getAll(@QueryParam("page") @DefaultValue("1") int page) {
        var pageRequest = PageRequest.ofPage(page);
        return service.findAll(pageRequest);
    }
}
Enter fullscreen mode Exit fullscreen mode

The resource itself needs the PaymentService that will create a payment as pending status, it will start an asynchronous event, and also, it will provide the payment status.

At src/main/java, create the PaymentService class.

package com.acme.payment;

import com.acme.Product;
import jakarta.data.Order;
import jakarta.data.Sort;
import jakarta.data.page.Page;
import jakarta.data.page.PageRequest;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Event;
import jakarta.inject.Inject;

import java.math.BigDecimal;
import java.util.List;
import java.util.logging.Logger;

@ApplicationScoped
public class PaymentService {

    private static final Logger LOGGER = Logger.getLogger(PaymentService.class.getName());
    private static final Order<Payment> PAYMENT_ORDER = Order.by(Sort.asc("id"));

    @Inject
    private PaymentRepository repository;

    @Inject
    private Event<PaymentRequestedEvent> event;

    public Payment create(PaymentRequest request) {

        LOGGER.info("Creating payment for " + request.productCode());
        Product product = new Product(
                request.productCode(),
                request.productName()
        );

        BigDecimal total = request.unitPrice()
                .multiply(BigDecimal.valueOf(request.quantity()));

        Payment payment = new Payment(
                product,
                total,
                PaymentStatus.PENDING
        );

        repository.save(payment);
        LOGGER.info("Payment created: " + payment);
        event.fireAsync(new PaymentRequestedEvent(payment));
        return payment;
    }

    public List<Payment> findAll(PageRequest pageRequest) {
        LOGGER.info("Finding all payments, page: " + pageRequest.page());
        Page<Payment> payments = repository.findAll(pageRequest, PAYMENT_ORDER);
        return payments.content();
    }
}
Enter fullscreen mode Exit fullscreen mode

The last resource for today is the one to show the statistics of our payment based on the product code.

At src/main/java, create the PaymentCounterResource class.

package com.acme.statistics;

import com.acme.payment.Payment;
import jakarta.data.page.PageRequest;
import jakarta.inject.Inject;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;

import java.util.List;

@Path("/payment-counter")
public class PaymentCounterResource {

    private final PaymentCounterService service;

    @Inject
    public PaymentCounterResource(PaymentCounterService service) {
        this.service = service;
    }

    PaymentCounterResource() {
        this.service = null;
    }

    @GET
    public List<PaymentCounter> getAll(@QueryParam("page") @DefaultValue("1") int page) {
        var pageRequest = PageRequest.ofPage(page);
        return service.findAll(pageRequest);
    }

}
Enter fullscreen mode Exit fullscreen mode

Conclusion

We got it! In this tutorial, we learned a new software architecture pattern and its motivation: the event-driven design, which changes our way of thinking about the system by focusing on the events that occur within it, rather than worrying about orchestrating multiple services and eventually handling coupling. We could explore Domain-Driven design concepts with a lightweight event-driven approach, separating responsibilities, handling asynchronous processes naturally, and keeping the core logic simple and extensible.

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:

Source code

Top comments (0)