A join request is a state machine wearing a button
“Let people join groups” is one sentence in a ticket and about nine states in a database.
The deceptively small ask
Communities: users create them, other users join, admins moderate, announcements go out to members. Everyone has used this feature. It reads as a weekend of work.
Then you enumerate what can actually be true of one person relative to one community, and it isn't "member or not":
- Not a member, community is public — can join immediately.
- Not a member, community is private — can request, and must wait.
- Requested, pending — cannot request again, and needs to see that they already asked.
- Requested, rejected — can they re-request? Immediately, or after a cooldown?
- Member — can leave, and leaving must be distinguishable from being removed.
- Removed by an admin — can they re-request, or are they blocked?
- Admin — a member with extra verbs, not a separate kind of thing.
The UI has to render each of those differently, and the API has to refuse the transitions that don't make sense from each.
Privacy changes retroactively
The state that caused the most thought wasn't a membership state at all. An admin can change a community from public to private after people have already joined, and after requests are already pending.
Existing members obviously stay. But what about the pending requests — do they survive, or does privacy invalidate them? And who could previously see the member list, versus who can now?
We settled on: privacy governs discovery and joining, not existing relationships. Members stay members. Pending requests stay pending. The member list stays viewable without joining, because being able to see who's in a group before asking to join it is the point of the feature.
Any setting an admin can toggle after the fact needs an answer for the records created under the old value. "It only applies going forward" is a valid answer — but it has to be a decision, not an accident.
Counters lie
Member counts get denormalised, because computing them per render doesn't scale. Denormalised counters drift, and they drift in one specific way: something decrements when it shouldn't.
The bug we hit was a removal path that decremented the count whether or not a membership record was actually removed. Call it twice, or call it for someone who had already left, and the count drifts below reality — permanently, because nothing recomputes it.
// Decrement only when a membership row was genuinely removed.
const { deletedCount } = await memberships.deleteOne({ community, account });
if (deletedCount > 0) {
await communities.update(community, { $inc: { memberCount: -1 } });
}
The general rule: a counter update must be conditional on the write that justifies it having happened. Any counter incremented or decremented unconditionally next to a write that might be a no-op will drift, and you will find out from a screenshot months later.
Announcements are a fan-out
An admin posting to a community is a write to one record and a notification to everyone in it. At small scale you can do that inline and never notice. It stops being free quickly, and it fails in an unhelpful way — the admin's request times out while some members have been notified and others haven't.
Posting the announcement and delivering it are separate concerns: the write returns as soon as the announcement exists, and delivery happens on a queue that can retry. Which means the same reliability questions as any other worker — terminal failure, retries and dead-lettering.
What I'd do differently
Draw the state machine before writing the first endpoint. We arrived at the full set of states incrementally, which meant each new one was a patch to code shaped around the states we already knew about. Twenty minutes of diagramming would have produced a smaller, more obviously correct implementation than the one we grew.
← All engineering notes