Projections Are Product Decisions
Why read models are where operational software decides what the truth should look like · ~15 min read ~– min read · Suggested by Q technicalbusinessoperations
Event sourcing gives you a durable record of what happened. Projections decide what that record means to the people doing the work. In operational software, the read model is not a performance cache — it is the product surface where raw facts become trustworthy decisions.
The event stream is not the product
Event sourcing is excellent at preserving facts, but users do not work in event streams. They work in lists, timelines, badges, alerts, and exception queues. The projection is where the system chooses which facts matter now, which vocabulary belongs on screen, and what should happen when the raw source is noisy or late.
A projection is not just a denormalized table. It is a promise about how a slice of operational truth should be read.
Why projections exist at all
CQRS separates writing from reading because command-side correctness and read-side usefulness are different design problems. Commands ask, “Is this change valid?” Projections ask, “What does someone need to know next?” In Strike, the same ECO events feed NOC-facing status, pager timelines, integration health, and polling state, each optimized for a different job.
Operational readers do not want raw status
The clearest example is Render task status. blueprinted, allocated, releasable, released, jeopardy, and completed are useful inside the field workflow, but they are not the NOC’s language. A projection that exposes those values directly is technically honest and operationally unhelpful; a good projection translates them into the smaller set of decisions the NOC can act on.
The same fact has several useful shapes
One ECO can be read several ways: as a domain record, a field-work grouping, a pager outcome, an integration health signal, or a billing-and-review case file. None of those views is the one true shape. They are product-specific answers to different questions, and pretending one table can satisfy all of them is how read models become either bloated or misleading.
Projections encode timing rules
Operational truth changes over time, sometimes after the system appears done. Strike’s completion grace period is a projection decision as much as a workflow decision: after all tasks are complete, the system keeps polling because field reality can reopen. The view has to represent “complete, but still watching” rather than collapsing immediately into finality.
Projections need provenance
A useful read model should be able to answer not only “what is the current value?” but “why do we believe it?” That is why event-sourced systems pair well with timelines and derived views: the projection can show the current state while preserving a route back to the supporting events, external observations, and integration attempts.
Direct writes are not automatically sins
Pure event sourcing sounds clean until an adapter has operational metadata that is not really domain truth. Strike documents this explicitly: event-sourced projections own core business state, while the Render polling adapter writes operational metadata directly. The rule is not “never write a read model directly”; the rule is “make the ownership boundary visible and enforce it.”
Good projections fail legibly
When a projection fails, users still make decisions. If the UI says nothing, they will fill the gap with Slack, screenshots, and tribal memory. A good projection separates domain status from integration health so “field work complete” does not get confused with “Render API unreachable” or “pager exhausted.”
A projection design checklist
Before adding a read model, ask who reads it, what decision it supports, which raw states it translates, what timing rule it applies, what evidence backs it, who owns each column, how duplicates and late data behave, and what failure looks like. If those answers are unclear, the projection is not ready; it is a table looking for a product decision.
What the team should take away
The database pattern is familiar, but the product lesson is easy to miss. Event sourcing preserves the history; projections make the history useful. In operational software, the quality of the read model often determines whether people trust the system during the incident, not whether the write model was theoretically pure.
Discussion prompts
Use the prompts in the full version to pressure-test real projections rather than debate the pattern abstractly. The most useful questions are about ownership, translation, and whether a screen can explain itself during an incident.
References
- Martin Fowler: CQRS — A concise framing of separating command models from query models and the cost of using the pattern casually.
- Martin Fowler: Event Sourcing — The classic reference for storing state changes as events and deriving current state from history.
- microservices.io: CQRS — Practical pattern summary for maintaining separate query models in service architectures.
- Microsoft Azure Architecture Center: Materialized View Pattern — Useful background on precomputed query views as a read-side optimization and design surface.
The event stream is not the product
Event sourcing gives us the luxury of remembering what happened. That is valuable, especially in a domain where an emergency callout can move from NOC creation to field dispatch to late follow-on work to downstream billing review. The event stream is durable, append-only, explainable, and much harder to silently lie with than a row that keeps getting overwritten.
But nobody operates a fiber outage from an event stream.
A NOC operator does not want to reconstruct an ECO by replaying ECOCreated, PagerRunStarted, RenderTaskCreated, TaskStatusObserved, PagerAccepted, and AllTasksCompleted in their head. An OSP supervisor does not want to infer which crews are moving from a raw integration payload. Support does not want a philosophical lecture about immutable facts when the question is simply, “Did Render fail, or did the contractor not respond?”
That is what projections are for. They turn a history of facts into a surface someone can use. In Strike, eco_views, pager_run_views, polling flags, timelines, integration status fields, and subscriptions are not just denormalized convenience tables. They are the product’s reading contract.
A projection is not just a denormalized table. It is a promise about how a slice of operational truth should be read.
The useful mental shift is this: the write model decides what may happen; the read model decides what the work means right now. Both are architecture. Only one of them is usually what the human actually sees.
Why projections exist at all
CQRS starts from a practical observation: the model that is good for accepting changes is often not the model that is good for answering questions. Commands care about invariants. Queries care about shape, speed, language, and context. A command handler asks, “Can this ECO be created for this NOC and this OSP?” A read model asks, “What should the operator see at the top of the incident list?”
Those are not the same question.
The command side wants narrow, validated transitions. It is happiest when every state change flows through aggregates, emits additive events, and preserves causal order. The query side wants the opposite kind of convenience: one row per thing the UI needs, already joined, translated, sorted, filtered, and ready to send over GraphQL or WebSocket.
Strike’s architecture guide describes this split in familiar terms: an event store for facts, projections for read models, and process state for coordination. The important part is not the diagram. The important part is that the split lets the read side tell the truth in the reader’s language.
A single ECO can produce several useful projections:
| Reader | Question | Projection shape |
|---|---|---|
| NOC operator | Is field work moving? | ECO status, display status, latest activity, alert state |
| Support engineer | Did an integration fail? | Render integration status, error message, last poll timestamps |
| Dispatcher | Did paging reach someone? | Pager timeline, outcome, last event, misconfiguration flag |
| Product / ops review | Where did the incident stall? | Timeline with source, actor, and status transitions |
| System worker | Should polling continue? | Completion timestamp, grace-period state, last task-change timestamp |
Trying to answer all of those with one canonical “current ECO” row sounds simpler until the first exception arrives. Then the row either grows into a junk drawer or starts hiding detail that somebody needed.
CQRS is not mainly about having two sets of classes. It is about admitting that validating a change and presenting a decision are different jobs, and giving each job a model that fits.
Operational readers do not want raw status
Status is the easiest field to get wrong because it looks harmless. A status is just a string, right? OPEN, IN_PROGRESS, COMPLETED. Or maybe blueprinted, allocated, releasable, released, jeopardy, completed. Or maybe PENDING, TASKED, DISPATCHED, FAILED.
All of those can be true. They do not belong in the same layer.
The ECO workflow document draws the boundary clearly. Render task statuses are internal to the field workflow. blueprinted means the task exists. allocated and releasable are assignment states. released means work begins. jeopardy means blocked or delayed. completed means the task’s required form and photo work is done. Those words matter to an OSP supervisor and a field technician.
The NOC’s question is different. The operator wants to know whether the callout is waiting, active, blocked, or done. They do not want to learn Render’s lifecycle taxonomy during an outage.This is not because NOC operators are less technical. It is because their decision context is different. Good software protects each role from vocabulary that belongs to someone else’s workflow.
That makes projection design a translation problem:
flowchart TD R[Render task status] --> P[Projection logic] P --> D[NOC display status] P --> I[Integration health] P --> S[Support detail] D --> N[NOC operator] I --> O[Ops escalation] S --> E[Support engineer]
The projection should preserve raw status where support and debugging need it, translate status where operators need decisions, and separate integration health from domain state. If the NOC sees releasable, the system has leaked a foreign workflow. If support cannot inspect releasable, the system has hidden evidence.
The compromise is not to pick one value. The compromise is to model each value’s audience.
The same fact has several useful shapes
An ECO is one incident, but it is not one view. To the NOC it is a case file. To Render it is a subsector grouping tasks. To the pager process it is a reason to contact an OSP supervisor. To the event store it is an aggregate stream. To billing and post-incident review it may become evidence, timing, and responsibility.
Those views overlap, but they are not interchangeable.
Consider the phrase “all work is complete.” In the field workflow, it may mean every Render task in the subsector is completed. In the NOC workflow, it may mean the emergency field response has finished and testing can begin. In the integration workflow, it may mean polling observed a stable completed state and notified the system. In the business workflow, it may mean the clock starts for invoice review or as-built documentation.
A projection that says only COMPLETED without context is doing less work than it appears.
This is why the read model is a product decision. The product must choose which completion matters on which screen. It must decide whether to show “Field complete” while still watching for late clones. It must decide whether a pager exhaustion is a domain blocker, an integration failure, or a dispatch outcome. It must decide whether an attachment is just a blob or part of the evidence language for downstream review.
All three answers can be true because each answer is a projection over the same underlying history. A good system does not force them into one sentence.
The same fact may need several read shapes. Duplication is not the enemy; unexplained disagreement is.
Projections encode timing rules
Operational truth has awkward timing. Field work does not always arrive in the order the software designer hoped. A technician may complete an investigation and then clone a follow-on repair. A completed task may reopen. A poll may observe a stable state and then the next poll may discover new work under the same subsector.
This is why Strike’s ECO workflow includes a completion grace period. When all tasks in a subsector reach completed, the ECO can become COMPLETED, the NOC can be notified, and the system can still keep polling for a configurable window, currently described as 24 hours. During that window, a new task or reopened task can move the ECO back to IN_PROGRESS. After the grace period expires, polling stops permanently and later changes require manual intervention.
That rule is not just backend plumbing. It changes the read model.
A naive projection has two states: not done and done. A useful projection can represent:
- Work appears complete.
- Completion has been communicated.
- The system is still watching because field reality can change.
- The watch window has expired and the ECO is final for automated purposes.
Those distinctions matter because they change what the human should do next. “Complete and still watching” is a different operational posture than “complete and final.” The former says, “You can move downstream, but do not be surprised if the field reopens.” The latter says, “Any new work is an exception path.”
Timing rules also affect idempotency and duplicate handling. Event handlers must expect duplicate delivery. Polling can see the same external status repeatedly. WebSocket subscribers can connect after the transition already happened. A projection that updates cleanly under duplicates is not a nice-to-have; it is what keeps the UI from flapping during a real incident.
If a projection cannot represent uncertainty over time, it will eventually fake certainty. People will act on that fake certainty because it is what the system displayed.
Projections need provenance
The moment someone challenges a status, the projection needs a memory. “Why does this say complete?” is not a debugging question. It is an operational question. The answer may involve a Render poll, a task status, a pager acceptance, a timeout, a grace-period rule, or an event replay.
Event sourcing helps because the system has facts to point at. The projection should not become a rumor that happens to be stored in Postgres. It should be a readable summary with a route back to evidence.
There are several levels of provenance a projection can carry:
| Provenance layer | Example | Why it matters |
|---|---|---|
| Event source | PagerAccepted, ECOCompleted |
Shows what domain fact changed |
| External observation | Render task status and timestamp | Shows what the integration actually saw |
| Actor / system | NOC user, OSP action, polling service | Shows who or what caused the change |
| Correlation | ECO ID, pager run ID, subsector name | Connects records across processes |
| Error trail | last error, attempts, DLQ entry | Turns failure into an actionable support path |
The architectural guide’s read model tables already point in this direction. pager_run_views stores a timeline, outcome, last event type, error, and misconfiguration flag. eco_views stores domain status plus Render integration status and polling timestamps. These are not accidental convenience columns. They are small pieces of explanation.
A projection without provenance may still be fast. It will not be trusted for long.
Direct writes are not automatically sins
Pure event-sourced systems have a seductive cleanliness. Every change becomes an event. Every view is derived. No one touches the read model except projection handlers. The story is tidy, and tidy stories are dangerous when they make real exceptions invisible.
Strike’s event-sourcing conventions document one of those exceptions plainly: Render polling writes directly to eco_views for operational metadata. The note calls it a documented architecture compromise. Event-sourced projections handle core business state. Direct operational adapters handle operational metadata. Both write to the same table, and the ownership boundary is explicit.
That is the right kind of impurity.
The alternative is worse in two directions. If every polling timestamp, retry marker, and external-system bookkeeping detail becomes a domain event, the event stream starts carrying operational exhaust as if it were business truth. If those details are written directly but not documented, the table becomes a contested surface where projection handlers and adapters quietly overwrite each other.
The design rule is not purity. The design rule is ownership.
flowchart LR ES[Event stream] --> PH[Projection handlers] PH --> EV[eco_views core columns] RP[Render polling adapter] --> OM[eco_views operational columns] EV --> UI[NOC and support views] OM --> UI
In Strike, that ownership shows up as conventions: event shapes are additive; schema evolution goes through upcasters; handlers must be idempotent; the pager_run_views upsert deliberately omits columns owned by SetOutcome() to avoid lost-update races; direct read-model writes are named as compromises instead of being hidden in code.
That last part matters. Architecture is not just the ideal path. It is also the list of places where the ideal path was deliberately bent, with enough context that the next person does not “fix” the system back into a race condition.
When two writers touch one read model, write down column ownership. Otherwise the projection becomes a shared whiteboard with no eraser discipline.
Good projections fail legibly
A read model can fail by being wrong, but it can also fail by being vague. Vague failure is especially expensive in operational software because the work does not pause while people debug the UI. Calls keep coming in. Contractors keep moving. Someone will decide what the screen means, even if the system did not.
This is why domain status and integration status should not be collapsed.
COMPLETED tells the NOC something about field work. FAILED in Render integration tells support something about the system’s attempt to create or observe external work. Pager EXHAUSTED tells dispatch that the contact workflow ended without acceptance. A blocked or delayed task tells the NOC the field workflow has a problem. Those are different failures with different owners.
If the projection shows a single red badge for all of them, it creates coordination noise. If it hides integration errors because the domain state has not changed, it creates false calm.
The best read models make failure actionable:
| Failure | Bad projection | Useful projection |
|---|---|---|
| Render create failed | ECO stuck at OPEN |
ECO open + integration failed + error + escalation cue |
| Pager exhausted | No assigned crew, no explanation | Dispatch outcome exhausted + timeline of attempts |
| Late cloned task | Completed status flickers | Reopened during grace period + new task evidence |
| Duplicate event | Timeline repeats confusingly | Idempotent handler, stable timeline |
| Unauthorized tenant access | Empty screen | Permission outcome with appropriate user-facing message |
The goal is not to expose every internal detail to every user. The goal is to ensure the right user can tell the difference between “nothing has happened,” “something failed,” and “something happened in another team’s vocabulary.”
A projection design checklist
Before adding a read model, slow down long enough to name the product decision. Otherwise the path of least resistance is a table that mirrors whatever event or API payload was easiest to query, and the UI inherits that shape by accident.
Here is the checklist I would use for Strike-like operational software:
- Who reads this view? Name the role, not just the component. NOC operator, support engineer, OSP supervisor, background worker, billing reviewer.
- What decision does it support? If the answer is “display data,” the design is not done. Display for what action?
- Which raw states are being translated? External statuses, domain events, retry states, integration health, permissions, and time windows may all need separate fields.
- Which vocabulary belongs on screen? Preserve raw vocabulary for debugging; translate it for users whose job is different.
- What evidence backs each derived value? Store enough source, timestamp, actor, correlation ID, or timeline detail to explain the view.
- What timing rule applies? Grace periods, stale windows, timeout deadlines, and finalization rules belong in the read model’s semantics, not in someone’s memory.
- Who owns each column? Projection handler, adapter, process manager, or manual override. Mixed ownership is allowed; invisible mixed ownership is not.
- How does it handle duplicates and late data? Assume at-least-once delivery and externally weird ordering.
- What does failure look like? Domain failure, integration failure, permission failure, and missing-data failure should be distinguishable.
- How will this view age? If the external system adds a status or the workflow gains a downstream step, does the projection have a clean migration path?
That checklist is longer than CREATE TABLE, which is the point. The hard part of projections is not the SQL. The hard part is deciding what the SQL means.
What the team should take away
The database pattern is familiar, but the product lesson is easy to miss. Event sourcing preserves history. CQRS lets reads and writes diverge. Materialized views make queries fast. All true. None of that automatically gives the team a useful operational surface.
The useful surface comes from projection design.
A good projection translates external vocabulary into local decisions without destroying the raw evidence. It preserves timing uncertainty instead of pretending everything is final. It carries enough provenance to be challenged. It names ownership when direct adapter writes bend the pure event-sourcing story. It fails in a way that points to the right owner.
For Osprey Strike, that is the difference between “we have an event-sourced backend” and “the NOC can trust the screen during an outage.” The first is architecture. The second is the product.
- The event stream is not the user surface — events preserve facts, but projections decide how those facts become operationally useful.
- Status is translation — raw external status, domain status, display status, and integration health are different layers with different audiences.
- Timing belongs in the read model — grace periods, late clones, duplicate events, and finalization rules shape what the user should believe.
- Impurity needs ownership — direct read-model writes are acceptable when documented, scoped, and protected from projection races.
- Legible failure builds trust — a projection should distinguish field completion, integration failure, dispatch exhaustion, and permission denial.
Discussion prompts
- Pick one Strike screen or API response: which fields are raw facts, which are translations, and which ones currently hide their provenance?
- Where do we rely on a single status field to answer multiple operational questions? Would separate domain, display, and integration statuses reduce ambiguity?
- For the next read model we add, can we require a short "projection contract" in the PR: reader, decision, source facts, column ownership, timing rule, and failure modes?
References
- Events All the Way Down — The companion Strike cairn on event sourcing, CQRS, Watermill, and the complexity budget behind the write-side architecture.
- Boundary Objects for Operational Software — The related product framing: shared objects preserve meaning across teams that do not share tools or vocabulary.
- Martin Fowler: CQRS — A concise framing of separating command models from query models and the caution that CQRS adds complexity when used casually.
- Martin Fowler: Event Sourcing — Classic reference for storing state changes as events and rebuilding current state from the event log.
- microservices.io: CQRS — Practical pattern summary for maintaining separate query models in service architectures.
- microservices.io: Event Sourcing — Pattern overview connecting event persistence, reliable publication, and reconstructing aggregate state.
- Microsoft Azure Architecture Center: Materialized View Pattern — Useful background on precomputed query views as a read-side optimization and design surface.
Generated by Cairns · Agent-powered with Claude