This article was originally published on Jo4 Blog.
A publisher submits a bid. The brand counters. The publisher accepts. Then the brand tries to withdraw. Is that legal? What if two bids are active on the same campaign — which one "wins" for display?
If you're reaching for a bunch of if/else checks scattered across your codebase, stop. You need a state machine.
TL;DR
We built BidStateMachineService to manage the full lifecycle of bid negotiations: DRAFT → SUBMITTED → ACCEPTED/REJECTED/COUNTERED → WITHDRAWN/EXPIRED. Each transition is validated. Each state has a displayPriority so we can resolve multiple bids on the same campaign to a single canonical status. 1,782 lines of tests cover every transition path.
The States
public enum BidStatus {
DRAFT(10),
SUBMITTED(70),
COUNTERED(80),
ACCEPTED(100),
REJECTED(50),
WITHDRAWN(20),
EXPIRED(30);
private final int displayPriority;
BidStatus(int displayPriority) {
this.displayPriority = displayPriority;
}
}
That displayPriority field solves a real UI problem. When a publisher has three bids on the same campaign — one submitted, one countered, one rejected — what do you show in the dashboard? The answer: whichever has the highest priority.
public BidStatus getCanonicalStatus(List<BidEntity> bids) {
return bids.stream()
.map(BidEntity::getStatus)
.max(Comparator.comparingInt(BidStatus::getDisplayPriority))
.orElse(null);
}
ACCEPTED (100) beats COUNTERED (80) beats SUBMITTED (70). The publisher sees the most actionable state. Simple, deterministic, no special cases.
The Transition Map
Not every state can reach every other state. Here's the valid transition map:
DRAFT → SUBMITTED, WITHDRAWN
SUBMITTED → ACCEPTED, REJECTED, COUNTERED, WITHDRAWN, EXPIRED
COUNTERED → ACCEPTED, REJECTED, WITHDRAWN, EXPIRED
ACCEPTED → (terminal)
REJECTED → (terminal)
WITHDRAWN → (terminal)
EXPIRED → (terminal)
In code, the state machine validates transitions before executing them:
private static final Map<BidStatus, Set<BidStatus>> VALID_TRANSITIONS = Map.of(
BidStatus.DRAFT, Set.of(BidStatus.SUBMITTED, BidStatus.WITHDRAWN),
BidStatus.SUBMITTED, Set.of(BidStatus.ACCEPTED, BidStatus.REJECTED,
BidStatus.COUNTERED, BidStatus.WITHDRAWN, BidStatus.EXPIRED),
BidStatus.COUNTERED, Set.of(BidStatus.ACCEPTED, BidStatus.REJECTED,
BidStatus.WITHDRAWN, BidStatus.EXPIRED),
BidStatus.ACCEPTED, Set.of(),
BidStatus.REJECTED, Set.of(),
BidStatus.WITHDRAWN, Set.of(),
BidStatus.EXPIRED, Set.of()
);
public void transition(BidEntity bid, BidStatus newStatus) {
Set<BidStatus> allowed = VALID_TRANSITIONS.get(bid.getStatus());
if (!allowed.contains(newStatus)) {
throw new InvalidStateTransitionException(
"Cannot transition from %s to %s".formatted(bid.getStatus(), newStatus)
);
}
bid.setStatus(newStatus);
bid.setModifiedTime(Instant.now());
}
No if/else chains. No scattered validation. One map, one check, one exception type. Every invalid transition gets the same clear error.
Bid Templates: Reusable Pitches
Publishers pitch to multiple campaigns. Writing the same "Here's my audience, here's my reach, here's what I charge" pitch every time is tedious. Bid templates solve this:
@Entity
public class BidTemplateEntity {
private Long publisherProfileId;
private String slug;
private String name;
private String pitchText;
private CommissionType preferredCommissionType; // Optional preset
private BigDecimal preferredRate; // Optional preset
private boolean deleted;
}
A publisher creates a template once, then applies it to any campaign. The template pre-fills the pitch and optionally suggests commission terms. The brand still negotiates — the template just eliminates copy-paste.
The Security Fix: Timing-Attack Oracle
This one was subtle and I'm a little embarrassed it shipped in the first place.
The original getTemplate() endpoint:
// BEFORE — vulnerable
public BidTemplateDTO getTemplate(String slug, Long requestingPublisherId) {
BidTemplateEntity template = templateRepository.findBySlugAndDeletedFalse(slug)
.orElseThrow(() -> new NotFoundException("Template not found"));
if (!template.getPublisherProfileId().equals(requestingPublisherId)) {
throw new ForbiddenException("Not your template");
}
return mapper.toDTO(template);
}
See the problem? If the template exists but belongs to someone else, you get 403 FORBIDDEN. If it doesn't exist at all, you get 404 NOT_FOUND. An attacker can enumerate template slugs by checking the HTTP status code. 404 = doesn't exist. 403 = exists, owned by someone else.
This is a classic timing-attack oracle. The fix is a single-query approach:
// AFTER — fixed
public BidTemplateDTO getTemplate(String slug, Long requestingPublisherId) {
BidTemplateEntity template = templateRepository
.findBySlugAndPublisherProfileIdAndDeletedFalse(slug, requestingPublisherId)
.orElseThrow(() -> new NotFoundException("Template not found"));
return mapper.toDTO(template);
}
Now both cases — doesn't exist and not yours — return the same 404. No information leakage. The query does the ownership check, not the application code.
This pattern applies everywhere: never fetch then check ownership. Query with ownership as a filter.
Notification Failures Don't Roll Back Bids
When a bid transitions, we send notifications — email, in-app, webhook. Early on, we had a bug where a notification failure (say, the email service was down) would roll back the entire bid state transition.
Publisher submits a bid. Email service times out. Bid reverts to DRAFT. Publisher is confused. Brand never sees the bid.
The fix: wrap notification calls in try/catch. The bid state transition is the primary operation. Notifications are secondary.
public void submitBid(BidEntity bid) {
transition(bid, BidStatus.SUBMITTED);
bidRepository.save(bid);
try {
notificationService.notifyBidSubmitted(bid);
} catch (Exception e) {
log.error("Failed to send bid notification for bid {}: {}",
bid.getId(), e.getMessage());
// Bid is already saved. Notification can be retried.
}
}
This is a general principle: don't let secondary side effects roll back primary state changes. If the notification matters enough to guarantee delivery, use an outbox pattern or a retry queue. Don't couple it to the transaction.
1,782 Lines of Tests
The test file for BidStateMachineService is 1,782 lines. That sounds excessive until you think about what it covers:
- Every valid transition (7 states × their valid targets = 10 transitions)
- Every invalid transition (7 states × their invalid targets = 32 cases)
- Display priority resolution with every combination of 2 and 3 concurrent bids
- Template CRUD with ownership checks
- The timing-attack fix (verifying both cases return 404)
- Notification failure isolation (bid persists when notification throws)
Each test method reads like a specification:
@Test
void submitted_bid_can_be_countered() { ... }
@Test
void accepted_bid_cannot_be_withdrawn() { ... }
@Test
void display_priority_shows_accepted_over_countered() { ... }
@Test
void get_template_returns_404_for_other_publishers_template() { ... }
State machines are one of those patterns where exhaustive testing actually pays off. Every edge case you miss is a user stuck in a broken state with no way out.
When to Use a State Machine
Use a state machine when:
- An entity has more than 3 statuses
- Not every status can transition to every other status
- Multiple actors can change the status (brand accepts, publisher withdraws)
- You need an audit trail of transitions
- The "what can I do next?" question depends on current state
Don't use a state machine when:
- You have 2-3 simple statuses (ACTIVE/INACTIVE)
- Transitions are always linear (PENDING → PROCESSING → DONE)
- There's only one actor
For bid negotiations, the state machine eliminated an entire class of bugs: impossible transitions that the old if/else code allowed because someone forgot to check one condition in one handler.
Have you used state machines for business workflows? What patterns worked (or didn't work) for you? I'd love to hear about it.
Building jo4.io — where every bid state transition is validated, tested, and auditable.
Top comments (0)