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:
- Simply Order (Part 1) Distributed Transactions in Microservices: Why 2PC Doesn’t Fit and How Sagas Help
- Simply Order (Part 2) — Designing and Implementing the Saga Workflow with Temporal
- Simply Order (Part 3) — Linking It All Together: Connecting Services and Watching Temporal in Action
- Simply Order (Part 4) — Reliable Events with the Outbox Pattern (Concepts)
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-orderSince 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
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 }
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 ascreatedAt(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);
}
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
lockNextBatchin 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
OrderandOrderItems. 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> {
}
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;
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;
}
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
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)