What building an incident dashboard taught me about clean architecture
Severity levels, ownership, and postmortems forced decisions a CRUD app never would.
I've built a lot of CRUD apps. Create a record, read a list, update a field, delete a row. It's a shape you can draw in your sleep, and for a long time I assumed that shape was basically what "backend architecture" meant — just draw the same rectangle again, with different field names.
Then I built an incident management tool, and about two days in, that assumption fell apart.
The moment it happened was small. I had an Incident entity with a status column — a plain string enum, open | investigating | resolved, the way you'd model any status field. Then I wrote the endpoint to update it, and I typed the obvious thing:
incident.status = dto.status;
await this.incidentsRepo.save(incident);
And I stopped, because I realized this line would happily let someone move an incident from open straight to resolved, skipping the part where anyone actually investigated it. Or move a resolved incident back to open a week later, after the postmortem was already written. A status field doesn't know what a lifecycle is. It just knows what value it currently holds.
That was the moment the project stopped being a CRUD app.
A status is a fact. A lifecycle is a set of rules.
Here's the distinction that took me embarrassingly long to actually articulate: a status enum tells you what something is right now. A state machine tells you what it's allowed to become next. Those sound similar, but the second one is a completely different kind of object, and once I saw that, I couldn't unsee it.
I pulled the transition logic out into its own thing — no database, no HTTP, no side effects, just a map:
private static readonly transitions: Record<IncidentStatus, IncidentStatus[]> = {
[IncidentStatus.OPEN]: [IncidentStatus.INVESTIGATING],
[IncidentStatus.INVESTIGATING]: [IncidentStatus.IDENTIFIED, IncidentStatus.OPEN],
[IncidentStatus.IDENTIFIED]: [IncidentStatus.MONITORING, IncidentStatus.INVESTIGATING],
[IncidentStatus.MONITORING]: [IncidentStatus.RESOLVED, IncidentStatus.IDENTIFIED],
[IncidentStatus.RESOLVED]: [IncidentStatus.POSTMORTEM, IncidentStatus.MONITORING],
[IncidentStatus.POSTMORTEM]: [],
};
That last line mattered more than it looks like it should. POSTMORTEM maps to an empty array — nothing comes after it. Not because I forgot to add more states, but because I'd decided, deliberately, that reopening a fully written-up incident should be a new incident, not a status flip. A postmortem is supposed to be a closed chapter. Letting someone silently mutate it back to open would quietly corrupt the one artifact the team is actually going to reread six months later.
I don't think I would have made that decision if the lifecycle had stayed a bare enum. A Record<Status, Status[]> forces you to write down every single allowed edge, which means it forces you to notice the edges you're choosing not to allow. The structure asks the question. A plain enum never does.
The rule doesn't just live in one place — and that's the actual lesson
Here's where it stopped being a tidy little refactor and started actually rippling outward, which I think is the real "clean architecture" lesson, more than the state machine itself.
The API layer got a single call, IncidentStateMachine.assertValidTransition(incident.status, dto.status), sitting at the top of the status-update method. If it's not a legal edge, it throws before anything else happens — no half-applied writes, no need to remember this check in five different places.
The database got its own, independent layer of the same guarantee: a Postgres enum type constraining the column to the six real values. This one surprised me — I originally thought the state machine class made the DB-level enum redundant. It doesn't. The class enforces order — you can't skip from open to resolved. The database enforces membership — the column can never hold a value that isn't even a real status, no matter what bug might someday bypass the application layer. They're not doing the same job. One's a business rule, the other's a data-integrity guarantee, and a system that only has one of them is missing half the story.
The audit trail is where this got genuinely interesting, because I hadn't planned for it, and the state machine sort of dragged it into existence. Once status transitions were an explicit, named thing rather than an arbitrary field write, it became obvious that every transition deserved a sentence, not just a new value:
await this.logEvent(
id,
IncidentEventType.STATUS_CHANGE,
actorId,
`Status changed from "${from}" to "${dto.status}"`,
);
That single line is why an incident's timeline reads like a story instead of a change history. "Status changed from investigating to identified" means something specific, because the state machine already defined what that edge means. If status were still a bare enum, I'd have logged the new value and called it done — and lost the story in the process.
It even split the concept of "history" in two, which I didn't expect going in. There's the incident timeline — what happened to this incident, read by the responder trying to understand the story. And there's a separate audit log — who did what across the whole system, read by an admin during a security review, deliberately denormalized so it survives even if the incident itself is deleted later. Those are genuinely different questions with genuinely different readers. I don't think I would have pulled them apart if the lifecycle transitions hadn't already taught me to think in terms of named, meaningful events instead of field updates.
The frontend, last, got the cheapest and most satisfying payoff. The incident detail page needs a dropdown showing "what can this incident become next" — and instead of hand-writing that logic per screen, it just mirrors the exact same transition map:
const NEXT_STATES: Record<IncidentStatus, IncidentStatus[]> = {
[IncidentStatus.OPEN]: [IncidentStatus.INVESTIGATING],
// ...identical shape to the backend
};
Worth being honest about the limits here, because I think glossing over them is where a lot of "clean architecture" writing turns into cargo-culting: that frontend copy is not enforcement. It's UX. The backend is the only thing that can actually reject an illegal transition, and if the two maps ever drift out of sync, the UI would just be wrong, not dangerous — a user might see an option that then 400s when they click it. I know that, and I chose to accept it rather than build some shared-package abstraction to keep two tiny files in perfect sync across a project this size. That felt like the right tradeoff, not a compromise I was making by accident.
Ownership was the second thing that broke my "just a field" instinct
Severity taught me the state-machine lesson. Ownership taught me a related one: not every rule fits in a decorator.
Resolving an incident isn't just "anyone with the right role can do it" — it specifically requires being the assigned owner, or an admin. That's not a role check. Role.ON_CALL_ENGINEER doesn't know anything about which incident you're looking at; it's a fact about the caller, not the resource. I couldn't express "you may resolve incidents you own" as a static @Roles() decorator no matter how I squinted at it, because the decorator has no access to the data.
So it lives as an explicit check inside the service instead:
assertCanClose(incident: Incident, actorId: string, actorRole: Role): void {
const isOwner = incident.owner?.id === actorId;
const isAdmin = actorRole === Role.ADMIN;
if (!isOwner && !isAdmin) {
throw new ForbiddenException(
'Only the assigned owner or an admin can resolve this incident',
);
}
}
It's a small function. But it's a different kind of authorization than the role guard sitting next to it, and pretending they're the same thing — jamming ownership logic into a decorator that can't see the data, or worse, checking it in the frontend and hoping nobody notices it's not enforced server-side — would have been the easy path. It's the path a CRUD app takes, because a CRUD app rarely has a concept of "this specific record has a specific person responsible for it."
What I actually learned
I don't think the lesson here is "always build a state machine" — plenty of status fields are genuinely just status fields, and building a transitions map for a two-state toggle would be its own kind of over-engineering. The lesson is narrower and, I think, more useful: the moment a value has rules about what it can become, it stops being data and starts being a domain concept, and it deserves its own name, its own file, and its own tests — separate from the entity that happens to store it.
A CRUD app rarely tells you that, because most of its fields really are just facts. An incident dashboard told me constantly, because almost nothing in it was just a fact. Severity has rules about who can change it. Status has rules about what it can become. Ownership has rules about who can act on it. Once I started listening for that signal — is this a fact, or is this a set of rules wearing a fact's clothing — I found myself asking it in every project since, CRUD or not. That's the part that actually stuck.