Car Dealership — State Pattern и Outbox на реальном коде
Архитектурная схема (archify) →
Не пересказ README, а разбор конкретных классов из order-service: интерфейс состояния заказа, таблица outbox и воркер, который её вычитывает.
State Pattern — интерфейс из трёх методов
Весь контракт состояния — OrderState с методами next, cancel, getName, каждая реализация знает только свой следующий шаг:
public interface OrderState {
void next(Order context);
void cancel(Order context);
String getName(Order context);
}
AwaitingPaymentState реализует это буквально в две строки на переход:
public class AwaitingPaymentState implements OrderState {
@Override
public void next(Order context) {
context.changeState(new PaidState());
}
@Override
public void cancel(Order context) {
context.changeState(new CancelledState());
}
}
Здесь нет if (currentState == X) нигде в бизнес-логике — Order.next() просто делегирует текущему объекту-состоянию, а тот сам знает, куда переходить. Недопустимый переход не проверяется условием — он структурно невозможен: у CancelledState или CompletedState методы next/cancel реализованы так, что бросают доменное исключение, а не молча что-то делают.
Outbox — таблица плюс отдельный воркер
OutboxEvent — сущность на каждое исходящее событие, с флагом sent и traceId для трассировки:
@Column(nullable = false)
private boolean sent;
public static OutboxEvent of(Order order) {
OutboxEvent event = new OutboxEvent();
event.id = UUID.randomUUID();
event.orderId = order.getId();
event.traceId = UUID.randomUUID();
event.sent = false;
// ...
return event;
}
Реальный запрос выборки неотправленных событий — не просто FOR UPDATE, а FOR UPDATE SKIP LOCKED:
SELECT * FROM outbox_events
WHERE sent = false
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT :batchsize
SKIP LOCKED — это не в README, а деталь, которую видно только в коде: без него при двух параллельных инстансах order-service (или двух прогонах воркера подряд, если первый завис) вторая транзакция встала бы в очередь ожидания на те же заблокированные строки.
SKIP LOCKED вместо ожидания просто пропускает уже занятые кем-то строки — это ровно то, чего не хватает outbox relay в другом моём проекте (Avito.Кухня), и здесь эта проблема уже закрыта.
OutboxPublisher — scheduled-воркер, не in-request публикация
@Scheduled(fixedDelay = 5000)
@Transactional
public void publishPendingEvents() {
List<OutboxEvent> events = outboxEventRepository.findBySentFalseForUpdate(100);
for (OutboxEvent event : events) {
try {
rabbitTemplate.convertAndSend(...);
event.setSent(true);
event.setSentAt(Instant.now());
outboxEventRepository.save(event);
} catch (Exception e) {
log.error("Failed to publish outbox event id={}", event.getId(), e);
}
}
}
Публикация происходит раз в 5 секунд отдельным потоком, батчами по 100 строк — не в том же запросе, что создание заказа. Если convertAndSend упадёт, событие остаётся с sent=false и подхватится следующим тиком — это и даёт at-least-once без ручной логики ретраев поверх RabbitMQ.