MBL
Go / Проекты / CarDealership / Car Dealership — State Pattern и Outbox на реальном коде
Go сложный

Car Dealership — State Pattern и Outbox на реальном коде

projectsjavamicroservicesrabbitmqoutbox

Архитектурная схема (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.

Самопроверка 0 / 2
Понимаю, почему State Pattern делает недопустимые переходы структурно невозможными, а не просто отловленными
Знаю, зачем в реальном Outbox-запросе SKIP LOCKED, а не просто FOR UPDATE
Как усвоено?