DEV Community

Cover image for Simply Order (Part 5) — Hands-On: Building the Outbox Pattern for Reliable Event
Mohamed Hassan
Mohamed Hassan

Posted on

Simply Order (Part 5) — Hands-On: Building the Outbox Pattern for Reliable Event

This is the fifth article in our series, where we design a simple order solution for a hypothetical company called Simply Order. The company expects high traffic and needs a resilient, scalable, and distributed order system.

In the previous articles:

We built the core services — Order, Payment, and Inventory — and discussed different approaches for handling distributed transactions across multiple services. Then, we designed and implemented the Saga workflow. Finally, we introduced the problem of dual-write consistency and how the Outbox Pattern can solve it.

In this article, we’ll show how to implement the Outbox Pattern with a polling relay using PostgreSQL. Then, we’ll integrate this logic into our Order Service and see it in action.

The code for this project can be found in this repository:
https://github.com/hassan314159/simply-order

Since this repository is continuously updated, the code specific to this lesson can be found in the add_persistence_to_order_service. branch. Start with:

git checkout add_persistence_to_order_service
Enter fullscreen mode Exit fullscreen mode

Outbox Entity Definition

Lets have a look about our OutboxEntity definition. You can find the code in OrderService under the package dev.simplyoder.order.infra.outbox

@Entity
@Table(name = "outbox")
public class OutboxEntity {
    @Id
    private UUID id;

    @Column(name = "event_type", nullable = false, length = 100)
    private String eventType;

    @Column(name = "aggregate_id", nullable = false)
    private UUID aggregateId;

    @Column(name = "payload", columnDefinition = "jsonb", nullable = false)
    @JdbcTypeCode(SqlTypes.JSON)
    private String payload;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 16)
    private Status status;

    @Column(nullable = false)
    private int attempts;

    @Column(name = "available_at", nullable = false)
    private Instant availableAt;

    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @Version
    private long version;

    public enum Status { PENDING, SENT, FAILED }

Enter fullscreen mode Exit fullscreen mode

Where

  • id: the ID of the outbox record.
  • eventType: the type of event, e.g., OrderCreated
  • aggregateId: the ID of the domain object, e.g., OrderId
  • payload: the event payload, e.g., the created order record
  • status: the status of the outbox record; one of:
    • PENDING: new record to be polled by the relay
    • SENT: polled and executed successfully
    • FAILED: polled but execution failed (after a number of attempts)
  • attempts: number of attempts before marking the record as FAILED
  • createdAt: the creation timestamp of the outbox record
  • availableAt: when the record becomes eligible for execution. It starts as createdAt (i.e., available immediately) and is updated on failure to support a backoff mechanism—delaying the next attempt by some time.

Outbox Relay Logic

@Scheduled(fixedDelay = 500)
public void orderWorkFlowSchedular() {
    List<OutboxEntity> items = outboxRepo.lockNextBatch(Instant.now(), batchSize); // 1
    for (OutboxEntity ob : items) { // 2
        try {
            String workflowId = "order-" + ob.getAggregateId();

            WorkflowOptions options = WorkflowOptions.newBuilder()
                    .setTaskQueue("order-task-queue")
                    .setWorkflowId(workflowId)
                    .setWorkflowIdReusePolicy(WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE)
                    .build();

            OrderWorkflow wf = temporalClient.newWorkflowStub(OrderWorkflow.class, options);

            wf.placeOrder(ob.getPayload());
            ob.markSent(); // 3

        } catch (WorkflowServiceException e) {
            // Workflow already exists with the same ID? Treat as success (idempotent start).
            if (isAlreadyStarted(e)) { // 4
                ob.markSent();
            } else {
                // backoff
                if (ob.getAttempts() + 1 >= maxAttempts) {
                    ob.fail();
                } else {
                    ob.reschedule(nextBackoff(ob.getAttempts()));  // 5
                }
            }
        } catch (Exception e) {
            if (ob.getAttempts() + 1 >= maxAttempts) {
                ob.fail(); // 6
            } else {
                ob.reschedule(nextBackoff(ob.getAttempts()));
            }
        }
    }
    outboxRepo.saveAll(items);
}

Enter fullscreen mode Exit fullscreen mode

The Outbox Relay is implemented as a simple scheduled task in our Order Service, running every 500 ms using @Scheduled(fixedDelay = 500).
It performs the following steps:
1- Fetches and locks the next batch of outbox records using outboxRepo.lockNextBatch
2- Loops through each record and starts a new transaction or Temporal workflow using the payload and OrderId (aggregateId).
3- Updates the outbox record status to SENT in case of success — ob.markSent();.
4- If a workflow has already been started for the same OrderId, it marks the outbox record as SENT as well — if (isAlreadyStarted(e))
5- Updates the availableAt field in case of failure (but only if the number of attempts has not yet been exceeded).
6- If the number of attempts exceeds the limit, it updates the status to FAILED

lockNextBatch in OutboxRepository — fetches and locks the selected records to ensure that, if multiple service instances or relays are running, each outbox record is processed by only one relay.

To support idempotency (i.e., preventing the same workflow from running twice for the same ID), we configured our workflow with: .setWorkflowIdReusePolicy(WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE)

In our design, the payload contains the full Order record — including both Order and OrderItems. We chose this approach to make the relay self-contained and independent of the order business logic. In fact, the workflow and outbox code could be completely moved out of the Order Service into a standalone service, but for simplicity, we kept it within the Order Service.

Updated Order Logic

Order Entity

We use JPA to persist order entities in our PostgreSQL database.
Our repositories are implemented using Spring Data:

@Repository
public interface OrderRepository extends JpaRepository<OrderEntity, UUID> {
}
Enter fullscreen mode Exit fullscreen mode

This allows us to handle all basic CRUD operations without writing boilerplate code, while still being able to define custom queries when needed.

Here is the OrderEntity class.

@Entity
@Table(name = "orders")
public class OrderEntity{

    @Id
    private UUID id;

    private UUID customerId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 40)
    private OrderStatus status = OrderStatus.OPEN;

    @Column(nullable = false, precision = 19, scale = 2)
    private BigDecimal total = BigDecimal.ZERO;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    @JsonManagedReference
    private List<OrderItemEntity> items = new ArrayList<OrderItemEntity>();

    @Column(name = "created_at", nullable = false, updatable = false)
    private OffsetDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private OffsetDateTime updatedAt;

Enter fullscreen mode Exit fullscreen mode

Order Service

Right now, the Order Service is much simpler and focused purely on the order domain.

@Transactional
public UUID createOrder(CreateOrderRequest request) throws JsonProcessingException {
    UUID orderId = UUID.randomUUID();
    CreateOrderCommand cmd = CreateOrderCommand.from(orderId, request);
    OrderEntity order = OrderEntity.create(cmd);
    orderRepo.save(order); //1

    String payload = objectMapper.writeValueAsString(order);
    outboxRepo.save(OutboxEntity.pending("OrderCreated", orderId, payload)); //2

    return orderId;
}

Enter fullscreen mode Exit fullscreen mode

The OrderService logic consists of just two operations in a single transaction:
1- Create the Order entity
2- Create the Outboxorder

Run Application

As discussed in Lesson 3,

to run the application

cd ./deploy/local
docker compose up
Enter fullscreen mode Exit fullscreen mode

please follow the same steps mentioned in Lesson 3 to create orders.

Home Work

Try stopping the Temporal service and then create a new order.
After restarting Temporal, you’ll notice that the order gets created and the distributed transaction continues automatically once Temporal becomes healthy again.

Wrapping Up

In this lesson, we implemented the Outbox Pattern with a polling relay using PostgreSQL and integrated it into our Order Service.
We saw how the relay reliably picks up pending events, triggers the corresponding Temporal workflows, and ensures eventual consistency across distributed services — even in cases of temporary failures or service downtime.

Top comments (0)