<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Steven Stuart</title>
    <description>The latest articles on DEV Community by Steven Stuart (@stevenstuartm).</description>
    <link>https://dev.to/stevenstuartm</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3674180%2Fb36cef0c-b6e7-41b3-a1a0-8535c7a3b691.jpg</url>
      <title>DEV Community: Steven Stuart</title>
      <link>https://dev.to/stevenstuartm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stevenstuartm"/>
    <language>en</language>
    <item>
      <title>GraphQL Solved Facebook's Problem. Does It Solve Yours?</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Mon, 12 Oct 2026 21:00:24 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/graphql-solved-facebooks-problem-does-it-solve-yours-1c9e</link>
      <guid>https://dev.to/stevenstuartm/graphql-solved-facebooks-problem-does-it-solve-yours-1c9e</guid>
      <description>&lt;p&gt;GraphQL divides teams, and I don't claim the last word on it. This post is my research into my own experience with GraphQL and into decisions I'm still making. I'm not calling anyone out or dismissing what teams have built with it. I'm asking for caution before an organization commits to a tool its product and customers may not need.&lt;/p&gt;

&lt;p&gt;Facebook's 2015 engineering post introducing GraphQL explained where the idea came from: "We don't think of data in terms of resource URLs, secondary keys, or join tables; we think about it in terms of a graph of objects." The line most people quote from that post is that "the shape of the returned data is determined entirely by the client's query." That's true, but it's a consequence of the unusually complex and deep graph, not the reason GraphQL exists. No server team can design an endpoint for every path through a graph that size, across every app version reading it, so the client has to write the query.&lt;/p&gt;

&lt;p&gt;GraphQL solved Facebook's problem, and the burden of proof sits with everyone importing it to show that it solves theirs. I don't think most teams can meet it.&lt;/p&gt;

&lt;p&gt;Most of what teams adopt GraphQL for is available without it. What's left is a client composing its own traversal of a graph the server can't predict. GraphQL's own best practices steer private APIs away from even that, by registering every query ahead of time. Client teams can still write their own queries before release, but planning across teams, or a backend for their screens, usually gives them the same control. That stops being true only when a product's screens take so many paths through its graph, and change them so often, that keeping an endpoint for each costs more than securing a query language. Facebook's did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most of GraphQL's Benefits Aren't GraphQL's
&lt;/h2&gt;

&lt;p&gt;The case for adopting GraphQL usually arrives as a bundle: typed contracts, payloads shaped to each screen, flexible filtering, declarative authorization, one endpoint over many services, and frontends that don't wait on backends. For each, the test is whether the client must compose the operation. Most don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Typed Contracts and Codegen Come From Contract-First APIs
&lt;/h3&gt;

&lt;p&gt;A GraphQL schema gives clients types and generated code, as an OpenAPI document does for an HTTP API and a Protocol Buffers definition does for gRPC. What differs is what the contract commits the service to.&lt;/p&gt;

&lt;p&gt;In contract-first development, the team designs and publishes each operation on purpose, so the contract states what the service supports. A GraphQL schema publishes types and entry points instead. The operations that matter, meaning which fields combine at what depth, get written later by whoever calls it. Unless every query is registered in advance, the schema commits the service to every operation its types and limits allow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filtering and Sorting Are the Server's Choice, Not a Query Language
&lt;/h3&gt;

&lt;p&gt;Hot Chocolate, a GraphQL server for .NET and the best-designed one I've used, adds filtering, sorting, and paging to a list with an attribute or two. But each attribute is the server choosing which lists callers may filter, on which fields, and how far they may page.&lt;/p&gt;

&lt;p&gt;An endpoint makes the same choices about its query parameters. OData, an OASIS standard for REST APIs, standardizes those parameters as &lt;code&gt;$filter&lt;/code&gt;, &lt;code&gt;$orderby&lt;/code&gt;, &lt;code&gt;$top&lt;/code&gt;, and &lt;code&gt;$skip&lt;/code&gt;, and ASP.NET Core lets each endpoint allow only the ones it chooses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Field-Level Authorization Is a Cost of Client-Written Operations
&lt;/h3&gt;

&lt;p&gt;The policy engine behind GraphQL authorization isn't GraphQL's, and the GraphQL Foundation tells production codebases to "delegate authorization logic to the business logic layer." Hot Chocolate's &lt;code&gt;[Authorize]&lt;/code&gt; attribute, for one, runs the same roles and policies ASP.NET Core applies to an endpoint. What GraphQL changes is where the check has to sit.&lt;/p&gt;

&lt;p&gt;An endpoint is a designed operation, so its code knows what the caller is doing and can apply the rule that fits. In GraphQL, a read can reach a field through any path the schema's types allow. Only root mutations are designed like endpoints.&lt;/p&gt;

&lt;p&gt;Say a support agent may see a customer's email while working that customer's ticket, but not in a sales report the agent can also open. In GraphQL, the same &lt;code&gt;Customer.email&lt;/code&gt; field sits under both. A check on the field alone can confirm the agent has an open ticket with that customer, but it can't tell whether the request came through the ticket or the report, so the email shows in both.&lt;/p&gt;

&lt;p&gt;A ticket-specific customer type would fix that, as a ticket DTO would in REST. Either fix works, but an endpoint exposes only the paths its designer wrote, while a schema must be right for every path its types allow.&lt;/p&gt;

&lt;h3&gt;
  
  
  One Endpoint Over Many Services Fights the Teams Behind It
&lt;/h3&gt;

&lt;p&gt;Apollo Federation and Hot Chocolate's Fusion put one GraphQL endpoint in front of several services, often for teams with predictable queries. Each service publishes a subgraph, and a gateway composes them into one schema and splits each query into requests to the services that own the fields.&lt;/p&gt;

&lt;p&gt;The stitching a client would have done still happens, in a query planner where the fetches are harder to see. Apollo's query-plan documentation says the router runs subgraph fetches in sequence "whenever one subgraph's response depends on data that first must be returned by another subgraph."&lt;/p&gt;

&lt;p&gt;A product built around bounded contexts gives each team authority over its own model. A federated schema asks for the opposite. Every team contributes types to one shared graph, extends other teams' entities, and passes a composition check that another team's conflicting change can fail. The bounded contexts are still there, but the schema pretends they aren't.&lt;/p&gt;

&lt;p&gt;A backend-for-frontend looks like the same facade but points the other way. Sam Newman describes it as a backend dedicated to one user experience and owned by the team building that interface. It calls each domain's published contract like any client, so each domain keeps its own schedule and authority. Its fan-out sits in code its team owns and can tune, and it shapes each response to its screens, so clients don't over-fetch.&lt;/p&gt;

&lt;p&gt;Members of my team once proposed a single query endpoint over our services, and I rejected the prototype we built. It added too much latency for our data-heavy, highly responsive web app, and it fought our caching. Each domain, and often each kind of record, had its own TTL. One response mixing those lifetimes defeated a request-level cache, and caching per source meant streaming every domain's invalidations into a gateway no domain team owned. What worked was each layer caching the data it owned.&lt;/p&gt;

&lt;p&gt;Latency isn't the lasting case against federation, since a backend-for-frontend's fan-out pays the same network hops. Ownership is. One product doesn't prove a rule, and a product without our volatile data, varied cache lifetimes, and team per bounded context might find a federated graph harmless.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fragment Colocation Needs GraphQL but Solves a Team Problem
&lt;/h3&gt;

&lt;p&gt;Relay, Meta's GraphQL client for React, is built around colocated fragments. Each component declares the data it needs, and the compiler nests those fragments into the queries a screen sends.&lt;/p&gt;

&lt;p&gt;GraphQL's defenders build on this, arguing that even when every query is registered in advance, the unpredictability hasn't gone away. It has moved from runtime to development time, because the server team can't predict what the frontend will need next month.&lt;/p&gt;

&lt;p&gt;Colocation buys frontend convenience, and it pays for it by giving up intentional contracts. The need it serves, a frontend team that can't wait on a backend team, is a planning failure before it's a technical one. Most of a screen's data needs show in its design before it's built, and leads who set priorities across teams can schedule its endpoint in the same release.&lt;/p&gt;

&lt;p&gt;So this case for the technology rests on one premise, that the teams can't plan together, and that premise is a needle holding up a foundation.&lt;/p&gt;

&lt;p&gt;Even good planning can't always keep pace, as when a screen changes mid-build or an experiment varies it. Then the frontend team needs to own its operations, and a backend-for-frontend can give it that as well as colocation can.&lt;/p&gt;

&lt;p&gt;Say a listing card on web, iOS, and Android gains its seller's rating. With colocation, each client edits a fragment and its build registers a new document. With a backend-for-frontend, each client team adds the field to its own backend's response in the same release. Neither waits on a domain team unless the rating doesn't exist yet, and then both do.&lt;/p&gt;

&lt;p&gt;Each fix has a price. A backend-for-frontend needs a backend per platform, each with its own pipeline and on-call. Colocation avoids that and saves an endpoint edit per backend. But saved time isn't a benefit until it's weighed against what it leaves behind. Colocation leaves an operation no server code was written for, even when a frontend team runs GraphQL as its own backend-for-frontend. That operation carries the authorization problem above. Only registered paths run, but no server team designed them. A designed endpoint settles that check in code its team owns.&lt;/p&gt;

&lt;p&gt;Adopting GraphQL because it removes the wait between teams, and then collecting reasons it fits, invites confirmation bias. Choosing it on purpose means proving its security, performance, stability, and testability against the problem at hand. Slowing down to plan together is how a product speeds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Left Is a Graph Nobody Can Predict
&lt;/h2&gt;

&lt;p&gt;GraphQL started in 2012, when Facebook began rebuilding its iOS and Android apps. Native apps needed "an API data version of News Feed," and where a REST service "may require multiple round-trips," "GraphQL naturally follows relationships between objects."&lt;/p&gt;

&lt;p&gt;Every item in a feed links to its author, its comments, the people who commented, and the groups and pages it came from. A reader can follow any of those links to the next view. That data also changes constantly. A designed feed endpoint would have to return every relationship any screen on any client version might open next, or come in a variant per screen and version. Facebook meets the burden of proof, and I agree with its conclusion.&lt;/p&gt;

&lt;h3&gt;
  
  
  REST Follows One Link at a Time, GraphQL Follows Many
&lt;/h3&gt;

&lt;p&gt;The round trips Facebook described are what REST does by design. Roy Fielding's dissertation defined REST around hypermedia, where each response carries links to the client's next steps and the client follows one per request. Fielding said the REST interface is "optimizing for the common case of the Web, but resulting in an interface that is not optimal for other forms of architectural interaction." A feed is one of those other forms.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Screen shape&lt;/th&gt;
&lt;th&gt;What fits&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One view at a time, with links to the next&lt;/td&gt;
&lt;td&gt;Hypermedia REST, as Fielding defined it&lt;/td&gt;
&lt;td&gt;One request per link&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many items per view, each linked to many relationships, in a large graph that changes constantly&lt;/td&gt;
&lt;td&gt;GraphQL&lt;/td&gt;
&lt;td&gt;One request states the whole traversal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Known views taking a few planned paths through data that changes slowly enough to cache&lt;/td&gt;
&lt;td&gt;Designed endpoints&lt;/td&gt;
&lt;td&gt;The server ships each view's shape and caches what it can&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most APIs called REST today sit in the third row. They're RPC, designed operations over HTTP.&lt;/p&gt;

&lt;p&gt;Each relationship a list item links into needs its own designed operation. For a first-party product, two questions test, screen by screen, whether its operations outrun hand-written endpoints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does each item in a list summarize itself and open one detail view, rather than link into many of its relationships?&lt;/li&gt;
&lt;li&gt;Does the data behind those views change slowly enough for a designed endpoint to serve and cache it?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If both answers are yes for most screens, hand-written endpoints can keep pace. A marketplace's listing and detail screens pass both, since each card opens one listing whose text and photos are read far more often than they change.&lt;/p&gt;

&lt;p&gt;Volatile data hurts a designed endpoint only when items also link into many relationships, because everything returned just in case must then be fetched fresh.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub, Agents, and Graph Databases Face Unpredictable Queries
&lt;/h3&gt;

&lt;p&gt;GitHub's repositories, issues, and pull requests form a graph that third-party callers traverse in ways GitHub can't predict, so no screen test applies. Over a graph database, GraphQL is a thin layer, and Neo4j's GraphQL Library compiles each operation into "a single Cypher query which is executed against the database."&lt;/p&gt;

&lt;p&gt;AI agents compose queries as they go, which should make them GraphQL's best fit, yet Anthropic and Apollo both point them toward designed tools. Anthropic's agent-tool guidance recommends "a few thoughtful tools targeting specific high-impact workflows." Apollo's Model Context Protocol server, which exposes GraphQL as agent tools, offers an open &lt;code&gt;execute&lt;/code&gt; tool but steers developers toward governing what agents reach "by adopting predefined persisted queries." Predefining an agent's queries gives up the reason to expose a graph to it, and those operations could just as well be RPC endpoints.&lt;/p&gt;

&lt;h3&gt;
  
  
  Open Queries Can Be Secured Without a Registry
&lt;/h3&gt;

&lt;p&gt;These cases need open queries, and a server that accepts them accepts them from anyone, because the endpoint shows in the browser's network tab and the schema is often one introspection query away. A query like &lt;code&gt;users(first: 100) { friends(first: 100) { friends(first: 100) { name } } }&lt;/code&gt; asks for a million records. Aliases let one request call a login mutation a hundred times, and a per-request rate limiter counts that as one attempt, as a PortSwigger lab shows.&lt;/p&gt;

&lt;p&gt;The GraphQL Foundation's security guidance recommends limiting query depth, applying "a separate smaller limit to how deeply lists can be nested," capping operations per batch, and restricting aliases. Cost analysis prices the rest. It weights each field and charges for what a query could return rather than for the request that carried it, so a hundred aliased logins cost a hundred mutations.&lt;/p&gt;

&lt;p&gt;GitHub caps a call at 500,000 nodes and meters each user at 5,000 points an hour, scored by the connections a query could traverse. The price is a cost model the server tunes field by field as the schema grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  GraphQL's Own Best Practices Steer Private APIs Away From the Point
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Trusted Documents Are for First-Party APIs Only
&lt;/h3&gt;

&lt;p&gt;For private APIs, GraphQL's own guidance avoids tuning a cost model. It refuses any query the server hasn't seen. With persisted queries, the client's operations are hashed and registered at build time, and in production the client sends only the hash. Relay, Hot Chocolate's &lt;code&gt;OnlyAllowPersistedDocuments&lt;/code&gt; option, Apollo's safelisting, and the GraphQL Foundation's "trusted documents" describe the same arrangement.&lt;/p&gt;

&lt;p&gt;Benjie Gillam, a GraphQL Working Group contributor, calls an allowlist "very much a best practice" for the "vast majority of GraphQL users." The GraphQL Foundation adds that "trusted documents can't be used for public APIs." So for a private API, the recommended practice gives up open queries and leaves client teams composing operations at build time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Serving Partners Brings Back the Cost Model
&lt;/h3&gt;

&lt;p&gt;In my experience, what starts private often has to interoperate later, with a partner integration, another department, or a customer who wants their data. Some of those consumers expect HTTP conventions, such as per-resource URLs and HTTP caching. For every GraphQL endpoint I built, I ended up needing a more RESTful one beside it.&lt;/p&gt;

&lt;p&gt;A designed contract can often open to outsiders behind auth and rate limits, because the server chose each operation and parameter, bounding its cost. The locked-down GraphQL API can't serve them as built. Registering operations for partners means the server team writes each one anyway. A gateway that translates for partners only moves those endpoints into infrastructure no domain team owns. Opening the API, or adding an open surface beside it, brings back the cost model the registry avoided.&lt;/p&gt;

&lt;h3&gt;
  
  
  Persisted Queries Carry the Costs of Both Models
&lt;/h3&gt;

&lt;p&gt;Under persisted queries, the server stores and can refuse each document. It doesn't choose which fields combine in a request. The client gives up runtime freedom, and the server gives up design.&lt;/p&gt;

&lt;p&gt;Both teams run the registry. Its clearest advantage is that it ties each field to the builds that read it, which makes deprecation safe, especially with long-lived mobile apps. A backend-for-frontend matches that only by keeping each release's response version until its last app build retires.&lt;/p&gt;

&lt;p&gt;Registration in CI keeps attackers' queries out, but not a teammate's costly one. No server author designed that document's field combination, so catching its cost means reviewing or scoring each document at registration.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Monorepo Makes the Registry Cheaper
&lt;/h3&gt;

&lt;p&gt;Gillam's write-up says the allowlist practice "has been used within Facebook since before GraphQL was open sourced." So Facebook did know its queries before they ran, but only because its client build generated the list rather than a team writing it.&lt;/p&gt;

&lt;p&gt;Facebook's code also lives largely in one repository, which Meta's post on its Sapling source control system puts at "tens of millions of files." Meta hasn't said its persisted queries depend on that, but I read them as benefiting from it. When client, compiler, and registry change in one commit, the hash is a detail of one build rather than a contract between two teams.&lt;/p&gt;

&lt;p&gt;Where client and server ship from separate repositories, the registry becomes that contract. Without a graph like Facebook's, keeping a query language only to lock it down is forcing a square block into a round hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most Teams Don't Need GraphQL
&lt;/h2&gt;

&lt;p&gt;Most teams adopting GraphQL don't have a graph nobody can predict. Their screens follow known paths, and designed endpoints give them what they wanted from GraphQL. For those teams, the server should own each operation, and the teams should plan together instead of building around each other.&lt;/p&gt;

&lt;p&gt;GraphQL earns its place where nobody can predict the queries. That covers public graph APIs like GitHub's, agents whose tasks can't be listed in advance, graph databases, and graphs like Facebook's, whose screens take too many changing paths to keep an endpoint for each. Persisted queries fit only that last case. Anywhere else, a team that locks GraphQL down has already listed its queries and keeps a query language only to compose them at build time.&lt;/p&gt;

&lt;p&gt;None of this says you can't use GraphQL, or that trying it was a mistake. It says to prove you have Facebook's problem before you take on Facebook's solution.&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>graphql</category>
    </item>
    <item>
      <title>Why LeetCode Interviews Measure the Wrong Thing</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Thu, 08 Oct 2026 20:55:40 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/why-leetcode-interviews-measure-the-wrong-thing-597i</link>
      <guid>https://dev.to/stevenstuartm/why-leetcode-interviews-measure-the-wrong-thing-597i</guid>
      <description>&lt;p&gt;Coding tests can effectively filter out candidates who can't write working code. That's a legitimate purpose. The problem emerges when organizations test for timed algorithm-puzzle skills in roles that require entirely different capabilities: system design, debugging distributed systems, architectural decision-making, or cross-functional collaboration.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A senior engineer who can architect scalable systems and debug production failures shouldn't be rejected because they can't solve a dynamic programming puzzle in thirty minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The interview tests for skills the job doesn't require while ignoring skills that define success in the role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timed Puzzles Test a Skill Most Roles Rarely Use
&lt;/h2&gt;

&lt;p&gt;Some roles genuinely require strong algorithm skills. If you're building database engines, compilers, or performance-critical systems, testing for algorithmic thinking makes sense. The problem is applying this filter by default regardless of what the job actually requires.&lt;/p&gt;

&lt;p&gt;A problem a competent engineer can solve cold with basic data structures, like reversing a linked list or finding duplicates with a hash map, checks the coding fluency every role needs. The mismatch starts with problems that are hard to finish in thirty minutes unless you recognize a named technique the role doesn't use, such as dynamic programming or a specific graph algorithm. The argument here is about those technique-dependent problems. Difficulty labels on practice sites don't mark that line, since many medium problems are plain hash-map work.&lt;/p&gt;

&lt;p&gt;Most software engineering roles demand system design thinking to architect scalable solutions, production debugging to troubleshoot complex failures, cross-functional collaboration to align technical decisions with business needs, and trade-off evaluation to balance competing constraints. Algorithm optimization rarely appears in the day-to-day work, yet coding rounds built on technique-dependent problems test almost exclusively for it. Larger interview loops add design and behavioral rounds, but the coding round often comes first as a screen. When that screen uses technique-dependent problems, a candidate who fails it never reaches the rounds that sample the job.&lt;/p&gt;

&lt;p&gt;Even when performance optimization matters in typical production systems, it's usually about database query optimization, caching strategies, network request patterns, and system architecture rather than the algorithmic complexity of isolated functions. For roles building web applications or distributed services, the engineer who can identify an N+1 query problem delivers more immediate value than the engineer who can implement graph traversal algorithms from memory. Different roles, different requirements.&lt;/p&gt;

&lt;p&gt;A candidate might struggle to find the optimal solution to a graph problem in thirty minutes while being excellent at diagnosing why a distributed system exhibits inconsistent behavior under load. They might fumble dynamic programming problems while being the engineer who catches architectural flaws in design reviews. When the role requires the latter skills, the interview measures their weakest capability and ignores their strongest. Public data doesn't show how often that happens across a hiring pool, so the case here rests on what the test contains rather than on counted rejections.&lt;/p&gt;

&lt;h3&gt;
  
  
  Drilled Patterns Pass for Reasoning
&lt;/h3&gt;

&lt;p&gt;Pattern matching also masquerades as algorithmic thinking. Practice sites group their problems by technique, such as two pointers, sliding window, and dynamic programming. A candidate who has drilled enough of them can solve a hard problem by recognizing which template it fits. Recognizing patterns is a useful skill, and engineers diagnose production issues partly that way. But recognizing a problem from a drilled catalog measures hours of preparation.&lt;/p&gt;

&lt;p&gt;Under time pressure, recall produces the same answer on the whiteboard as reasoning from first principles. An interviewer can try to tell them apart by changing a constraint and asking why. That probe works only as well as the interviewer runs it, so the signal comes from the interviewer, not the format.&lt;/p&gt;

&lt;h3&gt;
  
  
  One Live Round Adds Noise
&lt;/h3&gt;

&lt;p&gt;The live format adds noise of its own. In a randomized controlled trial published at ESEC/FSE 2020, Mahnaz Behroozi, Chris Parnin, and colleagues gave 48 computer science students a whiteboard problem, half with an interviewer watching and half alone in a private room. Being watched cut performance by more than half. That penalty comes from observation, not from the puzzle, so it applies to any live exercise.&lt;/p&gt;

&lt;p&gt;Aline Lerner found a second source of noise in 2016 data from interviewing.io, a practice-interview platform. Only about a quarter of candidates who interviewed more than once performed consistently, and many who earned a top score in one interview earned a failing score in another. That instability belongs to any single interview. Neither finding favors one format's content over another's. Both argue against letting one live round decide, and a lone timed puzzle used as a gate is exactly that. It measures preparation and composure on that day alongside a skill the role rarely uses.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Screen's Misses Fall Unevenly
&lt;/h3&gt;

&lt;p&gt;The usual defense is that this filter accepts false negatives to avoid false positives, because a bad hire is expensive and a large company can afford to lose good candidates. The trade can hold for the company, but it rests on an assumption. For a role that doesn't use the puzzle skill, the screen catches bad hires only as far as puzzle performance happens to track the skills that predict success in that role.&lt;/p&gt;

&lt;p&gt;Its false negatives also aren't spread evenly. A filter that rewards drilled patterns favors whoever had time to drill, and the talent pool narrows in that direction. Defenders read those drilling hours as a sign of conscientiousness, but hours available to drill also track free time, so the signal mixes diligence with circumstance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most Roles Run on Debugging, Design, and Review
&lt;/h2&gt;

&lt;p&gt;Most software engineering involves systems thinking applied to building products. That means understanding how components interact, how failures propagate, how changes impact users, and how technical decisions affect business outcomes. It means reading production metrics to diagnose issues, reviewing code to catch bugs before deployment, mentoring junior engineers to improve team capability, and collaborating with product teams to ensure what gets built actually solves user problems.&lt;/p&gt;

&lt;p&gt;Consider what a senior application engineer actually does. They investigate why a service's 99th percentile latency spiked from 200ms to 2 seconds overnight and discover a deployment introduced inefficient database queries. They review a pull request, identify a race condition in concurrent access to shared state, and suggest refactoring with proper locking. They pair with a junior developer to debug why a feature works locally but fails in staging due to differences in environment configuration. They join a design meeting and explain why the proposed real-time notification feature requires WebSocket infrastructure and database schema changes that will take three weeks, not three days.&lt;/p&gt;

&lt;p&gt;None of these activities resemble solving algorithm puzzles under time pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Work-Like Formats Keep What Puzzles Get Right
&lt;/h2&gt;

&lt;p&gt;Puzzles persist for reasons that hold up. They're cheap to run at scale, every candidate gets a comparable problem, and the scoring is easy to defend. That consistency comes from giving every candidate the same problem and scoring it against written criteria. Neither step depends on puzzle content, and a work-like exercise can do both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prediction Is Tied, So Content Decides
&lt;/h3&gt;

&lt;p&gt;Puzzles' strongest defenders also treat them as a proxy for general reasoning ability. Public data tying algorithm-round scores to later job performance is scarce, so the case for or against puzzles has to lean on general selection research. Paul Sackett and colleagues re-analyzed personnel-selection studies in the Journal of Applied Psychology in 2022. They estimated validity at .42 for structured interviews, .40 for job knowledge tests, .33 for work samples, and .31 for cognitive ability. Those are averages across occupations, and work samples don't clearly beat cognitive ability on their own.&lt;/p&gt;

&lt;p&gt;So the numbers don't crown a content winner. When prediction is roughly tied and no outcome data exists for a particular test, selection practice falls back on content validity. The US Uniform Guidelines on Employee Selection Procedures (29 CFR 1607.5) describe it as showing that a test's content is "representative of important aspects of performance on the job."&lt;/p&gt;

&lt;p&gt;Puzzles fall short of that standard whichever way you classify them. Treated as a cognitive-ability test, a puzzle is a proxy that ranks below job knowledge. Treated as a job-knowledge test, it samples algorithm technique the role rarely uses. Either way, a structured exercise built from the role's own work meets the content standard more directly. That standard is about defensibility, not a proven gain in hires. But when prediction is tied, matching the job's content is the reason left to prefer one format, so it breaks the tie.&lt;/p&gt;

&lt;p&gt;Two problems from the first section, drilled recall and uneven misses, also favor the work-like format. Preparation bends the puzzle proxy, because puzzles draw from a public catalog of thousands of problems with published solutions, so a candidate who has seen the problem is measured partly on recall. And the screen's false negatives skew toward people without time to drill.&lt;/p&gt;

&lt;p&gt;A work-like format costs more. Scenarios have to be written per role, calibrated, and rotated, and interviewers have to be trained and recalibrated on the rubric. Most of that cost recurs per role. The longer formats also take more interviewer time per candidate, so a short debugging or review exercise suits the screen and the longer ones suit later rounds. A puzzle screen is cheaper to run, but its cost lands elsewhere, in each candidate it turns away for content the role doesn't use. No public outcome data shows which cost is larger.&lt;/p&gt;

&lt;h3&gt;
  
  
  Match the Format to the Work
&lt;/h3&gt;

&lt;p&gt;Some companies have made that switch, and the community-maintained Hiring Without Whiteboards list on GitHub catalogs hundreds of them. The list shows the switch is workable at scale, not that those companies hire better, so the case for it rests on the research above. The format should follow the work the role does most:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When the role mostly...&lt;/th&gt;
&lt;th&gt;Test it with&lt;/th&gt;
&lt;th&gt;What it reveals&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Builds performance-critical internals like database engines, compilers, or low-latency libraries&lt;/td&gt;
&lt;td&gt;Algorithm and data-structure problems, ideally drawn from the domain&lt;/td&gt;
&lt;td&gt;Whether the candidate can reason about complexity where it decides the outcome&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extends and maintains a product codebase&lt;/td&gt;
&lt;td&gt;Pair programming on an existing codebase&lt;/td&gt;
&lt;td&gt;Collaboration, code quality standards, and how they work through ambiguous requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivers features end to end&lt;/td&gt;
&lt;td&gt;A small take-home project with tests and documentation&lt;/td&gt;
&lt;td&gt;Whether they can build a working feature without artificial time pressure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs services in production&lt;/td&gt;
&lt;td&gt;Diagnosing a seeded bug from logs and metrics&lt;/td&gt;
&lt;td&gt;How they form and test hypotheses about a failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shapes how services fit together&lt;/td&gt;
&lt;td&gt;A system design discussion on a realistic problem&lt;/td&gt;
&lt;td&gt;Their grasp of distributed systems, scalability patterns, and trade-off evaluation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reviews others' work and mentors&lt;/td&gt;
&lt;td&gt;Critiquing a real pull request&lt;/td&gt;
&lt;td&gt;Whether they can find bugs, suggest improvements, and give feedback constructively&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Some employers hire into a general pool and place engineers on teams later, so there is no single role to sample, and puzzles appeal because they are role-neutral. But a generalist pool still shares a core of work: reading unfamiliar code, debugging a failure, and reviewing a pull request. The debugging and review rows above sample that core for any team, and they meet the content standard where a puzzle doesn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structure Makes Any Format Fairer
&lt;/h3&gt;

&lt;p&gt;These approaches aren't perfect. Take-home projects favor candidates with more free time, the same skew a drilled screen has, which is a reason to keep them small. Pair programming can feel stressful, and system design discussions are harder to standardize.&lt;/p&gt;

&lt;p&gt;The observation penalty from the Behroozi study also hits every live row in the table, so give candidates private time to work before they discuss their approach wherever the format allows.&lt;/p&gt;

&lt;p&gt;Private work brings its own problem, because AI assistants make unobserved work hard to attribute. Any private or take-home portion needs a live follow-up where the candidate walks through their work and extends it. That follow-up is still observed, but the candidate explains a solution they already reached instead of searching for one under a watching eye. The Behroozi study's private condition used the same shape, with participants solving alone and then explaining their work in a retrospective think-aloud.&lt;/p&gt;

&lt;p&gt;Each format earns its place only when it's structured. That means the same scenario for every candidate, and a rubric written beforehand that lists what a strong answer finds, such as the seeded bug's root cause or the race condition in the pull request. Scenarios drawn from the company's own systems leak too, through interview-review sites. But they have no published solution set and can be rotated, so they resist drilling better than a public catalog does. Built that way, structure supplies the consistency and the job's own work supplies the content, rather than memorized algorithm patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Learn Algorithms for the Work, Not the Interview
&lt;/h2&gt;

&lt;p&gt;Build your algorithmic foundation because it matters beyond the interview. Understanding data structures helps you choose the right tool for the job, and knowing complexity analysis helps you write efficient code. Study algorithms to become a better engineer, not just to pass interviews.&lt;/p&gt;

&lt;p&gt;But recognize that grinding LeetCode is interview preparation, not professional development. The hours spent memorizing dynamic programming patterns could be spent building systems, contributing to open source, or learning distributed systems concepts. Those investments develop professional capability while LeetCode grinding mostly develops interview performance.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Strong engineers architect scalable systems, debug production failures, and collaborate across teams to ship features. Whether they can solve algorithm puzzles in thirty minutes is an indirect, noisy signal of any of that.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The problem isn't the candidates' capability. It's an interview process that measures the wrong thing for the role.&lt;/p&gt;

&lt;p&gt;Until hiring changes, engineers are stuck preparing for interviews that don't reflect the job, while companies reject candidates on content that says little about the role.&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>interview</category>
    </item>
    <item>
      <title>Planning for Plan Continuation Bias: How to Keep Building the Right Thing</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Wed, 07 Oct 2026 17:08:52 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/planning-for-plan-continuation-bias-how-to-keep-building-the-right-thing-15p3</link>
      <guid>https://dev.to/stevenstuartm/planning-for-plan-continuation-bias-how-to-keep-building-the-right-thing-15p3</guid>
      <description>&lt;p&gt;Many of the software projects that go wrong go wrong through misalignment. They solve a need nobody had, accept tradeoffs nobody weighed, or carry risks nobody examined. PMI's 2014 survey of more than 2,000 practitioners put poor requirements management behind almost half of projects that missed their goals. Alignment isn't a phase that ends when the work starts. Every plan, discovery, commit, and test is a chance for the work to drift away from what it was meant to accomplish, and a chance to pull it back.&lt;/p&gt;

&lt;p&gt;Pulling it back requires something most advice skips over. You have to stop. For years, I couldn't. When a better idea surfaced mid-implementation, my first instinct wasn't to evaluate it but to finish what I was doing. By the time I finished, the switching costs had mounted and the moment for cheap redirection had passed. It was rarely pride, just a pre-rational impulse to proceed, as though stopping mid-stride carried some cost that continuing didn't.&lt;/p&gt;

&lt;p&gt;What changed it for me was learning to think about work in terms of business value and to treat my own direction as an assumption to test rather than a plan to complete. I stopped being a code cowboy. The impulse didn't disappear, though, and it isn't mine alone. It has a formal name, and it explains why realignment so often fails to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continuing Wins Because It Takes Less Thought
&lt;/h2&gt;

&lt;p&gt;Pilots call it get-there-itis. Researchers call it plan continuation bias. Judith Orasanu and colleagues at NASA's Ames Research Center went through the tactical decision errors in the 37 accidents covered by the NTSB's 1994 review of flightcrew-involved major accidents. Of the 51 tactical decision errors in that corpus, 38 were decisions to continue the original plan despite cues suggesting a different course of action.&lt;/p&gt;

&lt;p&gt;The ratio isn't a pure measure of bias. The sample counts only flights that ended in accidents, so nobody knows how often crews continued in similar conditions and landed safely. Crews also choose to continue far more often than to divert, so continuing has more chances to go wrong. What the cases do show is crews who had the cues calling for a change and carried on anyway.&lt;/p&gt;

&lt;p&gt;Orasanu's team locates much of the cause in effort rather than stubbornness. Revising your understanding of a situation and working out a new course of action costs more cognitive work than continuing with a plan whose details are already settled. People tend to take the cheap operation when it's available. Workload, time pressure, and ambiguous signals all shrink the budget for the expensive operation at exactly the moment it's needed. The original plan doesn't win the argument. It wins by default, because it never has to have one.&lt;/p&gt;

&lt;p&gt;The stakes and the minute-by-minute time pressure don't transfer to software, but the default does. Revising a plan still takes more thought than continuing it, and the budget for that thought still shrinks under deadlines and under commitments other people have built on. There is evidence for this at the scale of whole projects. Mark Keil's 1995 case study of a runaway software project found a failing course of action kept alive by project, psychological, social, and organizational factors together. Nobody has measured the single-task version. It rests on the same mechanism.&lt;/p&gt;

&lt;p&gt;What software lacks is the warning. A pilot pressing into weather has minutes and no undo. A developer pressing on with the wrong approach has weeks, git can undo any of it, and the work ends the way good work does, reviewed and merged. The pilot who got away with it at least knows it was close. The developer rarely learns there was anything to get away with. When the cost does arrive, it arrives months later as slow delivery or a rewrite, blamed on tech debt rather than on the choice. So the software version of the bias seldom corrects itself.&lt;/p&gt;

&lt;p&gt;Our own advice makes it worse. "Finish what you started" is plan continuation bias repackaged as a character virtue. It assumes that what you started should be finished, which is exactly the question the bias keeps you from asking. That isn't the Kanban rule of limiting work in progress, which stops new work from starting and says nothing about whether the current work is right. "Think before you act" assumes the thinking was sound, and once you've thought and decided, the decision has inertia. The problem was never acting without thinking. It's acting without &lt;em&gt;reconsidering&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Impulse Shows Up at Every Stage
&lt;/h2&gt;

&lt;p&gt;Each stage of software work has its own version of the impulse, and each one lets the work drift a little further from the need before anyone looks up. Planning and discovery are the worst offenders, because the decisions made there shape everything built afterward, and their misalignment stays invisible the longest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Planning: The First Workable Design Becomes the Plan
&lt;/h3&gt;

&lt;p&gt;A plan is supposed to come from weighing options against the need, the tradeoffs, and the risk. Under the impulse, the plan is the first design that seemed workable, and the weighing never happens. Once that design is written into tickets, estimates, and a roadmap, it has momentum before a line of code exists. A team that sketches a synchronous integration in a kickoff meeting, estimates it, and schedules it rarely circles back to ask whether its behavior under failure fits the business's tolerance for delay. Questioning it now means reopening commitments other people rely on, so the plan proceeds unchallenged and its misalignment ships inside every task built from it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Discovery: New Information Gets Filed as "Later"
&lt;/h3&gt;

&lt;p&gt;Discovery is when the work learns something the plan didn't know: a requirement nobody mentioned, a constraint in a system you integrate with, users who behave differently than assumed. That is the moment realignment exists for, and it's the moment the impulse is strongest, because acting on the discovery means stopping work already in motion. So the discovery becomes a follow-up ticket, a tech-debt item, or a note for the retrospective. It's acknowledged without being acted on, and the work continues against a picture of the need that is now known to be wrong. Deferring is right when a discovery doesn't change the direction, and it's the bias when the discovery would have.&lt;/p&gt;

&lt;h3&gt;
  
  
  Coding: "Let Me Just Get This Working First"
&lt;/h3&gt;

&lt;p&gt;This is the version I see most in newer developers, and they make it constantly. Finishing first and then reevaluating sounds like sequencing rather than avoidance. The plan is to weigh the other approach once this one works. Then you spend another hour building out the current approach. You write tests around it. Other code starts depending on it. A colleague reviews it and builds understanding of how it works. At that point, "considering the other approach" means throwing away not just your work but the organizational investment in reviewing, understanding, and integrating what you've built.&lt;/p&gt;

&lt;p&gt;The impulse to finish manufactures the very sunk costs that now appear to justify not switching. That is what makes the bias self-sealing. Ordinary sunk-cost reasoning is a mistake about money you already spent. This one does the spending itself, then cites the receipt.&lt;/p&gt;

&lt;p&gt;Barry Staw's 1976 study Knee-Deep in the Big Muddy found that people who feel personally responsible for an initial decision commit more resources to it when it starts failing, not fewer. Among participants who received the same news of failure, the ones who had made the original decision themselves invested more, so the escalation tracked authorship of the decision rather than evidence about it. In software, this looks like the developer who hits a warning sign, such as a test that only passes with a workaround. They spend two more days making the approach work rather than an hour checking whether a different one would be simpler.&lt;/p&gt;

&lt;p&gt;Sometimes getting it working first is the right test, because some approaches can't be judged until something runs. A spike has a time box and an agreement to throw it away, so it tests the approach. "Let me just get this working" has neither, so it builds on the approach.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing: Proving Instead of Testing
&lt;/h3&gt;

&lt;p&gt;Proving asks "can I make this work?" and the answer is almost always yes given enough effort. Testing asks "should I be making this work?" and that's the question alignment depends on. The impulse turns testing into proving.&lt;/p&gt;

&lt;p&gt;Tests written after the approach is settled check that the code does what its author intended, and rarely whether what the author intended is what the need required. A green suite then reads as confirmation of the direction, when all it confirmed was the implementation. Tests that come from the need before the approach is chosen, such as a check against the business's tolerance for delay, are the ones built to fail a direction. Without them, the current assumption gets the full weight of implementation and verification, while the alternative gets a hypothetical conversation, maybe, later, if there's time.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Team's Momentum Feels Like Validation
&lt;/h2&gt;

&lt;p&gt;The group version is worse, and it doesn't work the way most people assume.&lt;/p&gt;

&lt;p&gt;The familiar picture of groupthink is dissent being actively suppressed. That happens, but I'd argue a subtler and more passive version also exists, and it's harder to see. Nobody explicitly validated the direction. Nobody consciously suppressed alternatives. Everyone just assumed that someone else must have validated it, and the group's momentum itself became evidence that the direction is correct.&lt;/p&gt;

&lt;p&gt;This shares something with the bystander effect, where responsibility diffuses until nobody acts, but it runs a step further. Diffused responsibility explains why nobody checked. Momentum read as confirmation explains why nobody felt the need to.&lt;/p&gt;

&lt;p&gt;Questioning the direction doesn't just feel unproductive. It feels like you're slowing the team down, which in most team cultures marks you as the obstacle rather than the one asking the right question. From the inside, speed is indistinguishable from conviction.&lt;/p&gt;

&lt;p&gt;Orasanu's team didn't isolate momentum, but their data does show social pressure feeding the bias. Alongside ambiguous conditions, they named organizational and socially-induced goal conflicts as the second context that contributes to plan-continuation errors. So the group is more than a second instance of the bias. It's one of the conditions that feed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Process Decides Which Direction Is Free
&lt;/h2&gt;

&lt;p&gt;Most methods for keeping work aligned assume that when discovery arrives, someone will notice and act. None of them do the registering for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixed Intervals Put Correction at the Far End
&lt;/h3&gt;

&lt;p&gt;Any cadence that commits a group to a fixed goal or set of work for a fixed window places the routine moment for changing direction at the end of that window. In a two-week sprint, the review is the official place to say the plan was wrong, and by the time it arrives two weeks of sunk cost have accumulated. The correction point sits exactly where the bias is strongest.&lt;/p&gt;

&lt;p&gt;Scrum is not blind to this. The Scrum Guide fixes the Sprint Goal rather than the scope, and the Daily Scrum adapts the plan toward that goal every day. A Product Owner can cancel a sprint, and the sprint boundary itself is a replanning point. But daily adaptation serves the goal rather than questioning it, and cancellation is a lever someone has to decide to pull, which is the very decision the bias suppresses. The standup, as many teams run it, has each person report progress against work already committed, which quietly rewards continuation every morning. It is part of what the framework trades for predictability.&lt;/p&gt;

&lt;h3&gt;
  
  
  Some Processes Make Stopping the Default
&lt;/h3&gt;

&lt;p&gt;Basecamp's Shape Up is organized around three moves that make a discovery cheaper to act on. A circuit breaker means unfinished work expires by default instead of receiving an extension, so stopping requires no argument from anyone. An appetite fixes the time and varies the scope, which makes dropping part of a plan an ordinary decision rather than an admission of failure. Shaping before committing means less has been invested at the moment the "this isn't right" signal arrives, giving the signal a chance to compete with momentum.&lt;/p&gt;

&lt;p&gt;What matters about a process is which direction it makes free, and a sprint and a circuit breaker pull in opposite directions. The Scrum Guide returns unfinished work to the backlog, but nothing stops the next planning session from pulling it straight back in, and the work already done argues for exactly that. Under a sprint, stopping has to be argued for. Under a circuit breaker, expiry is the default, so continuing has to be argued for. Shape Up's six-week window is longer than a sprint, so its advantage comes from the default, not from stopping sooner.&lt;/p&gt;

&lt;p&gt;Circuit breakers trip only at defined boundaries, so they miss the smaller discoveries between them, like the "this approach isn't quite right" signal on a Tuesday afternoon that the impulse to finish overrides.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Helps When Awareness Isn't Enough
&lt;/h2&gt;

&lt;p&gt;Knowing about the bias doesn't prevent it, because the impulse operates faster than reflection. What worked for me wasn't trying harder to notice. It was changing what I thought the work was. Once each task was a bet on delivering some business value, my approach to it became an assumption about how to deliver that value, and assumptions get tested. A plan you treat as a commitment pulls you toward finishing it. A plan you treat as an assumption pulls you toward checking it. Reframing changes what stopping feels like, but the impulse stays, so the moments to stop still have to come from structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking a Doubt Costs Less Than Building on It
&lt;/h3&gt;

&lt;p&gt;For most people, stopping feels like failure or waste. You were making progress and now you're not. But once a doubt has surfaced, evaluating it is usually cheaper than building more on top of it, and seeing it that way changes the emotional calculus even when the impulse is still there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Low Switching Costs Keep the Signal Audible
&lt;/h3&gt;

&lt;p&gt;The impulse draws power from switching costs that accumulate with every hour of continued execution. Cheap experiments before commitment, small commits, well-defined interfaces, and feature flags all keep the cost of being wrong low for as long as possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Fixed Rhythm Beats Waiting for Permission
&lt;/h3&gt;

&lt;p&gt;The Pomodoro technique was designed for productivity, but it accidentally created the kind of permission structure this bias requires. Every 25 minutes, you stop. That forced pause is a moment where "am I still working on the right thing?" can be asked without the social or psychological cost that usually prevents reassessment. The technique puts nothing about direction into the break, though, so the pause needs a switch criterion, written in advance, to check against. Events can trigger the same check, like a test that needs a workaround or an estimate that overruns.&lt;/p&gt;

&lt;p&gt;The natural objection is that these pauses cost working hours that would otherwise ship something. A check against a criterion written in advance is usually quick, though, and a doubt too big to check quickly deserves the time. Four hours of uninterrupted coding on a misunderstood problem doesn't produce the right solution, and the context-switching argument can't tell that case apart from the one where the four hours were well spent. Breaking focus on work you understood throws away context you had built. Breaking focus on work you hadn't examined throws away nothing you would want to keep.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Stop Test
&lt;/h3&gt;

&lt;p&gt;The pause only helps if something happens inside it. The person asking whether to switch is usually the one who made the original decision, and Staw's finding predicts they'll lean toward their own choice. Itamar Simonson and Barry Staw later tested ways to counter that, and one that worked was setting, in advance, a minimum target that triggers a change if it isn't met. So before starting, write down what result, cost, or discovery would make you switch, and which assumptions the plan depends on, so an unexpected discovery has something to contradict.&lt;/p&gt;

&lt;p&gt;That puts the criterion before the sunk cost exists and gives every pause something to check against. It also guards against the opposite mistake, switching whenever a new idea merely feels better. The stop test is then three questions for the pause, answered against what you wrote down, ideally by someone who didn't make the original call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Am I proving or testing?&lt;/strong&gt; Can this be made to work, or should it be?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is switching getting more expensive while I keep going?&lt;/strong&gt; Compare what you would throw away by switching now with what you would have thrown away an hour ago. If that pile keeps growing while the doubt sits unresolved, you are building the sunk cost that will later argue against switching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If I hadn't already started, would I choose this approach today?&lt;/strong&gt; This is Peter Drucker's question from The Effective Executive, "If we did not already do this, would we go into it now?", scaled down from a program to a single task. A no here is the entire signal. Everything after it is negotiation with sunk cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Alignment Depends on the Ability to Stop
&lt;/h2&gt;

&lt;p&gt;You can't realign after discovery if you can't stop long enough to receive the discovery. You can't weigh a tradeoff you never paused to see, or catch a risk while you're busy proving the approach works. Alignment isn't something you achieve at kickoff. You recover it at every plan, discovery, commit, and test, and each recovery starts with a stop.&lt;/p&gt;

&lt;p&gt;So the work isn't only to try harder at noticing. The work is to arrange things so that stopping doesn't wait on noticing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rhythms that pause whether or not anything looks wrong, and check against criteria set before the work began&lt;/li&gt;
&lt;li&gt;Boundaries that expire on their own&lt;/li&gt;
&lt;li&gt;Commitments small enough that changing your mind costs less than defending it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Build those, and the moment you would otherwise have missed arrives on a schedule that isn't yours to override.&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Entitlements Are a Domain, Not a Feature Flag</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Mon, 05 Oct 2026 18:58:14 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/entitlements-are-a-domain-not-a-feature-flag-3c8o</link>
      <guid>https://dev.to/stevenstuartm/entitlements-are-a-domain-not-a-feature-flag-3c8o</guid>
      <description>&lt;p&gt;It usually starts with one feature. The product team wants the new reporting dashboard available only on the enterprise plan, and there's no plan model in the code yet. The flag service is already wired in and supports targeting, and a segment called &lt;code&gt;enterprise-customers&lt;/code&gt; takes ten minutes to set up. A year later there are a dozen such segments. Every grant now goes through engineering, the code understands plans only as segment names, billing and access drift apart, and change logs are not sufficient to audit the proper state of customer product access. Most people involved feel the strain, but there's no clear way back.&lt;/p&gt;

&lt;p&gt;I think the way back starts with seeing that this was never a flag decision. Release toggles, experiments, and kill switches are decisions about how code behaves, made by the people who build and run it. Deciding which customers are owed a feature is a commercial decision, and it belongs to a business domain with its own owner, policies, and audits. Beyond the server-side and operational issues, a solution also has to account for client-side apps, which can quickly accelerate the pain and complicate a solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature Flags Decide How Code Behaves
&lt;/h2&gt;

&lt;p&gt;Martin Fowler's 2010 entry on feature toggles presents them as a way to keep unfinished work deployed but switched off on the main branch when a feature takes longer than a release cycle. Unleash, an open-source feature flag platform, still describes a release flag as a way to "manage the release of new or incomplete features," with an expected lifetime of 40 days.&lt;/p&gt;

&lt;p&gt;Experiments and kill switches use the same switch to decide which version should ship or whether code should run right now.&lt;/p&gt;

&lt;p&gt;When the team that builds and runs the product decides how the code should behave, it's a feature flag. That holds even when a product manager runs the experiment, because no customer is owed the variant they landed in. By the same test, a beta promised to named design partners is owed for as long as the promise runs, so it's an entitlement. When sales, finance, or a contract decides who gets a feature, it's an entitlement that happens to use a flag. A flag service can still carry that answer, but then it runs one department's decisions through a tool built for another's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Standard References Treat Entitlement as a Flag Type
&lt;/h2&gt;

&lt;p&gt;Pete Hodgson's catalog of toggle types, published on Fowler's site, sets permissioning toggles beside release, experiment, and ops toggles. Permissioning toggles change "the features or product experience that certain users receive." Hodgson notes these "may be very-long lived compared to other categories of Feature Toggles - at the scale of multiple years."&lt;/p&gt;

&lt;p&gt;The vendors built the category in. Unleash ships a permanent permission flag type to "control feature access based on user roles or entitlements." LaunchDarkly's entitlements guide treats entitlement flags as permanent ones, which "are part of the everyday operation of the application." A 2020 post on LaunchDarkly's blog by Dawn Parzych goes further: "Instead of creating a custom build for a customer requesting a specific feature, wrap a flag around the feature and release it to that particular customer."&lt;/p&gt;

&lt;p&gt;A 2020 interview study by Meinicke, Wong, Vasilescu, and Kästner shows teams drifting there without deciding to. Practitioners "usually do not distinguish between different kinds of flags," and "a feature flag that initially guards a new feature may become a configuration option that should be only available for 'premium' users."&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Flag Service Can't Do When It Holds the Entitlement
&lt;/h2&gt;

&lt;p&gt;Each of a flag service's design choices is likely sensible for a release decision and wrong for a commercial one.&lt;/p&gt;

&lt;h3&gt;
  
  
  No Single Fallback Is Right for Every Customer
&lt;/h3&gt;

&lt;p&gt;Flag clients are built to never break the application. OpenFeature, a vendor-neutral standard for flag evaluation APIs, requires in its specification that client methods "MUST NOT throw exceptions" and that evaluation calls "must always return the default value in the event of abnormal execution." For a release flag, that's right, because falling back to off means the old behavior everyone has been running.&lt;/p&gt;

&lt;p&gt;For an entitlement there's no safe default. LaunchDarkly's entitlements guide warns that if the application can't connect, "all of your end users will receive a single fallback variation." Default to off, and every enterprise customer loses what they bought. Default to on, and every free customer gets it.&lt;/p&gt;

&lt;p&gt;An entitlement service can fail too. A client that starts with nothing cached falls back to one default, no matter which system holds the grant. The difference is who decides how long a cached grant still counts. LaunchDarkly's offline-mode documentation says a client SDK that can't reach the vendor uses "the latest stored flag variation values." Unless every app build adds its own expiry check, a cancelled customer's device keeps any paid feature that works offline for as long as it stays offline.&lt;/p&gt;

&lt;p&gt;An entitlement system makes that an explicit window. RevenueCat, which manages in-app subscriptions, fixes one for every app, so an entitlement active when a device went offline "will remain active for up to three days." A team that owns its entitlements lets the plan owner set it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Change Log Isn't an Audit Trail
&lt;/h3&gt;

&lt;p&gt;When finance, support, or legal asks why a tenant has a feature, they want the agreement that granted it, when it started, and when it ends. A flag service records that someone added a tenant ID to a segment on a given day. Giving sales a role in the flag tool changes who edits, not what's recorded.&lt;/p&gt;

&lt;p&gt;Approvals, a comment, and a removal date can sit on that change, but the contract, trial, or sales exception behind it lives in a ticket or a Slack thread, if anywhere. Even a required contract ID is free text the flag service can't validate or query. Asking which tenants had a feature last quarter, and under which agreements, means replaying segment edits. A segment's history is enough for a rollout but not for a decision with money attached.&lt;/p&gt;

&lt;h3&gt;
  
  
  Billing and Access Drift Apart
&lt;/h3&gt;

&lt;p&gt;Once the flag service decides access, billing knows what customers bought and the flag service knows what they can use, and usually nothing reconciles the two. A downgrade in billing doesn't remove the tenant from the segment, and a sales exception granted in the segment never reaches billing. Schematic is an entitlement platform vendor with an interest in the point. It describes the common path: "many teams use their feature flag tool for entitlements early on, and then realize the two concerns need to be separated as their pricing gets more complex."&lt;/p&gt;

&lt;p&gt;LaunchDarkly's guide offers segments that stay in sync with an external system, so billing could feed one, but only if every grant, sales exceptions included, goes into billing first. A separate domain still needs that discipline, but it can enforce it. Billing sends plan changes as events, and the entitlement store refuses any grant with no agreement behind it. Exceptions and trials start in the entitlement store and flow out to billing. A hand-edited segment can't refuse anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entitlement Is a Domain With Its Own Owner
&lt;/h2&gt;

&lt;p&gt;Product access encodes the company's pricing, contracts, and obligations to each customer. It has an owner outside engineering, usually product and sales, policies about who may grant exceptions, and audit requirements because what a customer can use is part of what they're billed for.&lt;/p&gt;

&lt;p&gt;It also has its own model of plans that bundle features, contracts that override them with their own dates, expiring trials, and seat limits, none of which a segment expresses. That's the profile of a bounded context, not a flag type. Modeling it as its own domain, with grants that are dated and never overwritten, closes each gap above. A service answers "may this tenant use this feature?" from data it owns. Billing can host that domain if its catalog maps plans to features and also records exceptions and trials with their dates.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Entitlement held in a flag service&lt;/th&gt;
&lt;th&gt;Entitlement held in its own domain&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who grants access&lt;/td&gt;
&lt;td&gt;Whoever edits segments, checked against nothing&lt;/td&gt;
&lt;td&gt;Product and sales grant it, under their own exception policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What a grant records&lt;/td&gt;
&lt;td&gt;Who changed a segment, and when&lt;/td&gt;
&lt;td&gt;The agreement behind it, with start and end dates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What a renewal touches&lt;/td&gt;
&lt;td&gt;Removal dates edited per segment, linked to no contract&lt;/td&gt;
&lt;td&gt;The contract's dates, once, and every plan feature follows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How billing stays in step&lt;/td&gt;
&lt;td&gt;Two systems hold one fact, and neither wins&lt;/td&gt;
&lt;td&gt;Billing sends plan changes in, and exceptions flow out to it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What happens in an outage&lt;/td&gt;
&lt;td&gt;On clients, the last stored value with no expiry, or one coded fallback&lt;/td&gt;
&lt;td&gt;A fallback and offline window the plan owner sets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  One Contract Can Justify a Temporary Flag
&lt;/h2&gt;

&lt;p&gt;The hardest case is the one Parzych's advice describes. A single customer's contract requires a feature nobody else has asked for. For now, everything about it is in doubt, including its specification, its stability, whether it meets the customer's needs, and whether it will ever sell. The contract item itself isn't negotiable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two Decisions Share One Flag
&lt;/h3&gt;

&lt;p&gt;Who is owed the feature is settled. Whether the feature survives is the open question, and it's partly about the code and partly about the market.&lt;/p&gt;

&lt;p&gt;Engineering's usual reason to keep unproven code behind a flag is the ability to turn it off. For this tenant, turning it off breaches a contract, so the off switch is a commercial decision and not an operational one. The fallback problem returns in its starkest form, since no single default can be on for the one tenant who holds the contract and off for everyone else. Where an entitlement domain already exists, the grant goes there and the flag only isolates the code. Where none exists, the flag carries both decisions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Every Exit Removes the Flag
&lt;/h3&gt;

&lt;p&gt;Neither the off switch nor the fallback problem goes away, but both can be accepted on purpose. Making an unproven feature that one contract requires into a plan feature would be premature, and the flag keeps the code isolated until the open question is answered. I think that makes this case a legitimate use of a flag, declared as intentional technical debt and owned by product and sales rather than engineering. The off switch and the outage fallback are now their risks to carry.&lt;/p&gt;

&lt;p&gt;That ownership means only product or sales may change the grant, and its exit trigger is recorded with the contract. If engineering needs an incident kill switch, it's a separate flag whose use product signed off on in advance.&lt;/p&gt;

&lt;p&gt;What separates it from Hodgson's multi-year permissioning toggles is that it has a decision point, due no later than the first renewal, and every way out of that decision removes the flag:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Another customer wants it.&lt;/strong&gt; The grant moves into the entitlement model as a plan feature, and the flag goes away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It becomes standard.&lt;/strong&gt; The code path becomes permanent and the flag is deleted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The contract ends or the feature fails.&lt;/strong&gt; The code is removed with the flag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Parzych's advice is right on the first day. It becomes a problem only when nobody records the exits and the flag turns into a permanent grant with no owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  On Client Apps, Vendor Coupling Compounds the Entitlement Problem
&lt;/h2&gt;

&lt;p&gt;Mobile, desktop, and browser apps need the same answer as servers, sometimes offline, and flags usually reach them through the vendor's SDK.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clients Still Need Flags, but Never Decide Entitlements
&lt;/h3&gt;

&lt;p&gt;Client apps arguably need flags more than servers do, since a client fix waits on app store review and on users who don't update. But the client is never the authority on an entitlement. It can hold a grant the server issued for as long as the plan's rules trust it, but the server enforces, and the client only decides what to show.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Vendor's SDK Ties Every Build to the Vendor
&lt;/h3&gt;

&lt;p&gt;The vendor's SDK, and its representation of the answer, become part of every installed build. Swapping vendors waits on app releases, and until old builds disappear, you can't put your own layer between the client and the vendor. The layer serving the answer isn't yours either, so you can't merge sources, set expiry and invalidation for each kind of data, or push your own change events. I think direct vendor SDK access from client apps is a trap, though many teams accept it for the streaming updates and built-in experiment analysis that come with it.&lt;/p&gt;

&lt;p&gt;Entitlements in the flag service make that coupling worse. Every build then depends on the vendor for what customers are owed. Exposure gets worse too. LaunchDarkly's secure mode exists because a malicious end user "could use a context or user key to identify what flag values another end user receives," and it covers only its JavaScript-based SDKs. Its other SDKs have no such protection. If tenant keys are readable slugs like &lt;code&gt;acme&lt;/code&gt;, a modified client can probe flags and learn which paid features a named customer holds, even when the server enforces every grant.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Projection Fixes the Source but Not the Layer
&lt;/h3&gt;

&lt;p&gt;For a team already deep in the hack, a first step back is fixing where entitlements come from while the flag service keeps serving them. I've used this shape before, with a read store that only receives changes from the audited business store and is never written to directly. The flag service becomes a projection of an entitlement store that sales and product own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ┌──────────────────────────────────┐
 │ Entitlement store                │ &amp;lt;── sales and product write
 │ audited source of truth          │
 └────────────────┬─────────────────┘
                  │ events (only writer)
                  v
 ┌──────────────────────────────────┐
 │ Flag service                     │
 │ release flags + entitlement      │
 │ segments, rebuilt from source    │
 └────────────────┬─────────────────┘
                  │ vendor SDK
                  v
               Client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes LaunchDarkly's synced segment, described earlier, a rule instead of a hope, and two conditions keep the projection honest. Only the sync integration may write to entitlement segments, enforced in the vendor's permissions rather than by agreement, and the whole projection can be rebuilt by replaying the source. It works, but the sync and the rebuild can each break. It also leaves the device getting grants through the vendor's SDK, with every problem above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Entitlements Can Ride on an Endpoint You Already Own
&lt;/h3&gt;

&lt;p&gt;The cheaper way out takes grants off the vendor's path entirely, and it may not need a new endpoint. The app already asks the server who the user is and which tenant they belong to, and that session or account lookup can return the tenant's grants with the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"u_1842"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tenant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entitlements"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"generatedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-05T14:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"refreshAfterSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"staleSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;259200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"grants"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"advanced-reporting"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One number says how often to refresh, and the other says how long a cached answer still counts when a refresh fails. Contract grants change rarely, so hourly is plenty, and an in-app purchase forces a refresh. The plan owner decides grants stay trusted for three days offline. That window is a promise, so data a user creates under a cached grant has to sync even if the grant ended while the device was offline. What happens to that data next is the plan owner's call too. &lt;code&gt;generatedAt&lt;/code&gt; lets the client tell how old its copy is, and nothing in the exchange touches the flag vendor.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Facade Decouples Flags From the SDK, as a Separate Choice
&lt;/h3&gt;

&lt;p&gt;Release flags still come through the vendor's SDK, and moving them can wait. The usual shape is a thin endpoint you own in front of the flag service. It's the kind of server component Sam Newman describes in his backends-for-frontends pattern. The vendor's contract ends at the server. If one endpoint serves both grants and flags, the response should keep them apart, because one flat map of booleans can't tell a release toggle from a contract grant.&lt;/p&gt;

&lt;p&gt;The SDK also records who saw each variation, for experiments. Behind a facade, the app takes over that job. Statsig's experimentation guidance explains that counting users who never reached a feature dilutes an experiment's result. For a change that lives entirely in the interface, only the client knows whether the user reached it.&lt;/p&gt;

&lt;p&gt;A facade also gives up the vendor's real-time streaming of flag changes, so a client kill switch waits for the next refresh. A change too urgent for that usually belongs on the server. A client-only one, like disabling a crashing native screen, is a reason to keep release flags on the vendor's SDK.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Flag Can Stand In When the Gap Is Written Down
&lt;/h2&gt;

&lt;p&gt;None of this means a startup with one paid tier needs an entitlement service on day one. But I think holding product access in a flag is still a hack, and the only good reason for it is a timeline or budget that would otherwise block delivery. Even then it's a cost, because paying it off means refactoring the code and migrating the grants. Someone should decide it on purpose and write down three things: that the flag is standing in for an entitlement domain, who owns that gap, and what will trigger paying it off. A good trigger is the first grant no plan explains, such as a sales exception, after which segments hold facts billing doesn't.&lt;/p&gt;

&lt;p&gt;Unstated, the gap compounds. Each segment and sales exception adds to a later migration. Each client build that reads entitlements from the vendor's SDK adds a contract you can't recall. And the flag service keeps gaining authority because nobody recorded that it was never supposed to have any.&lt;/p&gt;

&lt;p&gt;Hacking feature flags can work because each wrong answer is usually recoverable on its own. A tenant who gets a feature a day late complains and gets a credit. No credit undoes exposing what other customers bought, though. A different system layer that decides who can sign in, move money, or read another tenant's data has no such margin, and the same shortcut is never an option. A hack can hold together for a while, but the leaks will show and the costs will add up.&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The API Gateway Shouldn't Know Your Business</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Fri, 02 Oct 2026 13:58:19 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/the-api-gateway-shouldnt-know-your-business-aec</link>
      <guid>https://dev.to/stevenstuartm/the-api-gateway-shouldnt-know-your-business-aec</guid>
      <description>&lt;p&gt;I appreciate what an API gateway brings to most systems, at the right time and in the right place. It gives every service a shared first layer. TLS, rate limiting, and access logging happen once at the edge, and bad tokens get turned away before any service spends work on them. The services behind it still check what reaches them, but they start from cleaner traffic. The gateway also lets those services move and split behind a stable address without breaking their clients.&lt;/p&gt;

&lt;p&gt;But a gateway's configuration tends to collect things nobody designed it to hold. It might be a check on a customer's plan, a response field renamed back for an older client version, or a call to a second service to decide whether the first one should be reached.&lt;/p&gt;

&lt;p&gt;That drift, away from the gateway's purpose and from the code that owns the rule, isn't always carelessness. The gateway is the cheapest place to apply a business domain rule to every external request, so rules flow toward it. The decade-old advice that business logic doesn't belong there hasn't removed that pull. It says where business logic shouldn't go, and little about the forces that send it there or how to keep a rule out when the need is urgent.&lt;/p&gt;

&lt;p&gt;A business domain rule belongs in the gateway only if the team that owns the gateway could change it correctly without asking a domain team. Keeping the rest out takes an answer to each force, and an emergency rule that does go in goes in as debt, with a domain owner and a scheduled removal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Advice Has Been Clear Since 2014
&lt;/h2&gt;

&lt;p&gt;In March 2014, James Lewis and Martin Fowler described microservices as favoring "smart endpoints and dumb pipes," against Enterprise Service Bus products that "often include sophisticated facilities for ... applying business rules." Thoughtworks put "overambitious API gateways" on Hold in its Technology Radar from November 2015 through May 2018. The April 2016 entry calls the pattern "a worrying re-emergence of this disease," the disease being business smarts pushed into middleware. It says "any domain smarts such as data transformation or rule processing should live in applications or services where they can be controlled by product teams."&lt;/p&gt;

&lt;p&gt;Microsoft's Gateway Offloading pattern agrees, saying "Never offload business logic to the gateway."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Rules Drift Into the Gateway
&lt;/h2&gt;

&lt;p&gt;Four forces pull a rule toward the gateway, and each makes sense to whoever makes the change that day.&lt;/p&gt;

&lt;h3&gt;
  
  
  Every External Request Passes Through It
&lt;/h3&gt;

&lt;p&gt;Premium customers can request to export more than 10,000 rows, and everyone else can't. In the services, that rule is a change to the export service, and possibly to each service that exposes a large download. In the gateway, it's one policy that rejects the request before any service sees it. The person making the change may not see it as a pricing rule, because it can look like an urgent fix to keep large exports from degrading the system.&lt;/p&gt;

&lt;h3&gt;
  
  
  It Ships on a Different Schedule
&lt;/h3&gt;

&lt;p&gt;A gateway policy usually ships as configuration, and a platform team often owns it, so adding the export limit there doesn't wait on the export team's backlog. What the gateway offers is a different queue, not necessarily a faster one. When the export team is busy and the deadline is close, the rule goes wherever it can ship this week.&lt;/p&gt;

&lt;p&gt;After that, every change to the export limit waits in the platform team's queue, even when the export team could ship it sooner. The Gateway Offloading pattern says offloading doesn't fit when centralizing concerns "creates a change-management bottleneck" behind a slower gateway release cycle. The schedule that let the rule in once is the schedule it's stuck with.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vendors Compete on Programmability
&lt;/h3&gt;

&lt;p&gt;A rule can only move into the gateway if the gateway can express it, and gateway products have made sure it can. Azure API Management runs policy expressions written in C#, and its &lt;code&gt;send-request&lt;/code&gt; policy calls another service mid-request. Kong's Pre-Function plugin "lets you dynamically run Lua code." Thoughtworks' later radar entries said vendors kept adding such features to differentiate their products.&lt;/p&gt;

&lt;p&gt;Suppose the export team is booked for the quarter, so leadership overrides the queue and hands the export limit to the platform team, which runs Azure API Management. The platform team writes a policy that takes four steps on each export request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the customer ID from the caller's validated token.&lt;/li&gt;
&lt;li&gt;Ask the entitlements service for that customer's plan with &lt;code&gt;send-request&lt;/code&gt;, caching the answer for a few minutes.&lt;/li&gt;
&lt;li&gt;Read the requested row count from the &lt;code&gt;rows&lt;/code&gt; query parameter, treating a missing value as zero so clients that omit it keep working.&lt;/li&gt;
&lt;li&gt;Return a 403 if the plan isn't &lt;code&gt;premium&lt;/code&gt; and the count is over 10,000.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every step uses a standard gateway feature, and the result is business logic. Once the plan lookup exists, the next rule that needs a customer's plan costs one more condition.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security Wants One Place to Check
&lt;/h3&gt;

&lt;p&gt;Authorization can be the first business rule to land in the gateway, pushed by a security group that wants one place to audit rather than trusting each dev team to get it right. The gateway already checks tokens, so it looks like that place. The drift starts when the gateway moves from checking that a token is valid to deciding what the caller may do. It usually begins with a role check on a route, which grows into decisions about which records the caller may touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Gateway Rule Loses Its Owner, Its Coverage, and Its Tests
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Its Meaning and Its Enforcement Get Different Owners
&lt;/h3&gt;

&lt;p&gt;Step 4 of the platform team's export policy encodes two business facts, that a plan called premium exists and that it allows larger exports than every other plan. The entitlements team decides what plans exist and the export team decides what they allow, but the platform team owns the policy. The team that can change the rule's meaning can't see where it's enforced, and the team that enforces it can't judge whether it's still right.&lt;/p&gt;

&lt;p&gt;Suppose sales introduces an enterprise plan. The entitlements service starts returning &lt;code&gt;enterprise&lt;/code&gt;, and the gateway rule still compares against &lt;code&gt;premium&lt;/code&gt;, so enterprise customers, who pay the most, get rejected on large exports. No test fails, because no test in either service ever ran the rule. Hard-coding &lt;code&gt;premium&lt;/code&gt; would be a bug in the service too. Here, though, someone must first find the rule, and then the platform team must change a rule whose meaning they didn't write.&lt;/p&gt;

&lt;p&gt;The same split makes the rule hard to remove. The platform team can see the rule but can't tell whether any client depends on it, and the domain team could answer that but may not know the rule exists. So rules can accumulate, each one cheaper to leave than to trace, and the gateway drifts toward the middleware Lewis and Fowler described.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Rule Covers Only What the Gateway Can See
&lt;/h3&gt;

&lt;p&gt;The first gap is the routes that never cross the gateway. Follow one free-plan customer who wants 50,000 rows. Calling the public API, they get the 403. Then they click Export in the product's web app, whose backend calls the export service over the internal network, and the export runs. They set up a nightly scheduled export. The scheduler, acting with its own service identity, publishes an &lt;code&gt;ExportRequested&lt;/code&gt; message that the export service consumes, and that export runs too. The same customer asks the same question three ways and gets two different answers.&lt;/p&gt;

&lt;p&gt;The Gateway Offloading pattern recommends that backends accept requests only through the gateway, which stops outside clients from bypassing it but doesn't help here. The web app's backend and the scheduler are part of the product. The queued message never becomes an HTTP request. Even a scheduler call routed through the gateway would carry the scheduler's identity, with no customer for the gateway to look up. So the export service needs its own check, and the enterprise-plan change now has to reach two copies of the rule.&lt;/p&gt;

&lt;p&gt;The second gap is on the gateway's own route, where it can check only what a request declares, while an export's size is known only after the query runs. The service treats &lt;code&gt;rows&lt;/code&gt; as an optional cap, so a request for &lt;code&gt;?from=2020-01-01&amp;amp;to=2026-01-01&lt;/code&gt; can leave it out. Step 3 treats a missing count as 0, so the request passes and gets every row the date range matches. Requiring &lt;code&gt;rows&lt;/code&gt; would close the hole, but that changes the export API's contract, which is the export team's decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Rule Leaves the Domain's Tests
&lt;/h3&gt;

&lt;p&gt;The export team's test suite exercises the export service, not the policy. Unless gateway config runs in the export team's pipeline, the rule has no test that runs when the export service or the plan catalog changes. API Management's documentation adds that policy expressions have "only limited verification" when defined and run at request time, where any exception becomes a runtime error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the Gateway to Rules Its Own Team Can Change
&lt;/h2&gt;

&lt;p&gt;One question tests whether a rule is about the request or about the business: &lt;strong&gt;could the team that owns the gateway change this rule correctly without asking a domain team?&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind of rule&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;th&gt;Who decides its meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;About the request&lt;/td&gt;
&lt;td&gt;Token expiry, request size, per-caller rate limit&lt;/td&gt;
&lt;td&gt;Gateway&lt;/td&gt;
&lt;td&gt;Gateway team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An adapter for a backend or client that can't change&lt;/td&gt;
&lt;td&gt;Renaming fields for a vendor system or an old client version&lt;/td&gt;
&lt;td&gt;Gateway, until retired&lt;/td&gt;
&lt;td&gt;A named team, with a test against the backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Composition for one client&lt;/td&gt;
&lt;td&gt;One response for a mobile screen&lt;/td&gt;
&lt;td&gt;Backend for frontend&lt;/td&gt;
&lt;td&gt;That client's team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A stopgap during an incident&lt;/td&gt;
&lt;td&gt;Blocking large free-plan exports until a fix ships&lt;/td&gt;
&lt;td&gt;Gateway, until the fix ships&lt;/td&gt;
&lt;td&gt;Domain team, with a removal date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A business rule&lt;/td&gt;
&lt;td&gt;Export limits by plan&lt;/td&gt;
&lt;td&gt;Owning service&lt;/td&gt;
&lt;td&gt;Domain team&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rules about the request pass because their correct values don't change when the business changes its mind. A rule that reads a business concept, such as a plan, account status, or price, fails, however little code it takes.&lt;/p&gt;

&lt;p&gt;Two better-built versions of the export rule still fail. In the first, the export team keeps its own gateway config. The split owners and the platform team's queue go away, but the coverage gaps remain.&lt;/p&gt;

&lt;p&gt;In the second, the policy asks entitlements for the customer's limit as a number, so plan names leave the gateway. But which routes count as exports is still the export team's decision. A new bulk download endpoint would slip past the gateway rule, while a check in the export code covers it on release. Tagging export routes in the export team's spec closes that gap, but the gateway copy still misses the internal paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentication Passes, Authorization Doesn't
&lt;/h3&gt;

&lt;p&gt;Tokens sit on both sides of the ownership test. Rejecting a token that is malformed, badly signed, expired, or tied to a revoked session passes, because what makes a token valid doesn't change when the business does.&lt;/p&gt;

&lt;p&gt;Any decision about what the caller may do fails the test, even at the route level. A scope per route looks like a property of the token, but which scope &lt;code&gt;POST /exports&lt;/code&gt; requires is the export team's call. If they split &lt;code&gt;exports:write&lt;/code&gt; into a standard and a bulk scope, the gateway enforces the old contract until the platform team catches up. The exception is a scope check generated from the service's API spec. It removes the lag and can stay as an early reject, because the export team still decides its meaning and the service runs its own check.&lt;/p&gt;

&lt;p&gt;The Gateway Offloading pattern lists authorization among the concerns a gateway can centralize, so this line is narrower than Microsoft's. The gateway sees &lt;code&gt;/accounts/4417/exports&lt;/code&gt; and a user ID, but only the service that loads account 4417 knows who belongs to it. Broken object level authorization tops the 2023 OWASP API Security Top 10, which describes the check as one "usually implemented at the code level."&lt;/p&gt;

&lt;h3&gt;
  
  
  Capacity Limits Pass, Plan Quotas Don't
&lt;/h3&gt;

&lt;p&gt;Rate limits sit on both sides too. A per-caller limit that protects capacity, such as 100 requests a second per key, passes. Its value tracks capacity, not what a customer bought, and it only has to protect the edge. A quota sold as part of a plan, such as 10,000 API calls a month on the basic tier, fails. The export limit splits the same way. A size cap that protects the system passes, and the extra rows premium customers may request belong in the export service.&lt;/p&gt;

&lt;p&gt;Amazon API Gateway's usage plans attach a quota to each API key, which makes the gateway a second copy of each customer's plan to update on every upgrade and cancellation. The exception is an API that is itself the product, with its plans sold in the gateway. There the gateway's owner owns the plan, and the quota passes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adapters and Composition Are Narrow Exceptions
&lt;/h3&gt;

&lt;p&gt;A transformation for a backend or client that can't change, such as a vendor system, an old client, or a service partway through a migration, can live in the gateway. It needs an owner, a test against the backend, and a recorded change that will retire it. Its meaning belongs to the backend's owner, so it fails the ownership test. Like a stopgap, it may still run in the gateway, but only on those conditions. Composition for one client belongs in a backend for frontend, Sam Newman's pattern of a service owned by the team that builds the client. That service owns presentation, not the domain rules behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Answer Each Force
&lt;/h2&gt;

&lt;p&gt;Under a deadline, the easiest place for a rule wins, so each force needs an answer that makes the owning service as easy a choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Every Request Reaches the Service
&lt;/h3&gt;

&lt;p&gt;Put the check in the export service from the start, and keep plan details out of it. The service asks the entitlements service for this customer's export limit, as a number, and refuses any export over it. That adds a call to each service that serves large downloads, but a shared client library with caching keeps it to a few lines. When sales adds an enterprise plan, only the entitlements service's limits change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Borrow the Gateway's Schedule Only as Debt
&lt;/h3&gt;

&lt;p&gt;For a one-time change it can make alone, the platform team's queue is short, and sometimes that's what a missed deadline or an incident needs. Suppose a Friday release breaks the export service's limit check, and free customers start pulling millions of rows through the public API. The platform team can ship the four-step policy within the hour, changed to refuse any export without a declared row count, long before the export team can safely deploy a fix. Ship it, but ship it as debt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The export team owns the fix and the required &lt;code&gt;rows&lt;/code&gt; parameter the policy imposed,&lt;/strong&gt; booked into a release the day the policy ships. Once the incident ends, nothing pushes anyone to replace a policy that seems to work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The policy carries its owner, ticket, and removal date,&lt;/strong&gt; so anyone reading the gateway can tell a stopgap from a rule that belongs there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix isn't done until the policy is deleted&lt;/strong&gt; and the service's own test covers the routes the stopgap never saw.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Make Code in the Gateway Require an Owner
&lt;/h3&gt;

&lt;p&gt;A business rule needs only the platform team's approval to get into the gateway, and that review tends to ask whether the policy works, not whose rule it is. A policy needs cross-team review if it calls another service, runs custom code, or compares a claim to a business value. A pipeline can detect the first two. The review comes from the domain team that owns the concept, or from architecture review when no team clearly owns it.&lt;/p&gt;

&lt;p&gt;Some policies can't wait for review or can't pass it. If the owning team can't review before an emergency ships, the review joins the stopgap's debt. If the policy fails the ownership test, it merges only as a dated stopgap or an owned adapter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Split Authorization by Who Owns the Facts
&lt;/h3&gt;

&lt;p&gt;Security can get consistent, auditable authorization without the gateway. Role-based decisions, such as what the support-agent role may do or which tenant a user belongs to, rest on facts that rarely change mid-request. They can live in one central policy, distributed to every service and evaluated locally on every route, with each domain team owning the entries for its own capabilities. Contextual decisions, such as whether a user may export account 4417 given its status or a legal hold, rest on domain facts that can change from one request to the next. Centralizing those repeats the gateway's split one layer over, so they stay in the domain service, tested by the team that owns the facts.&lt;/p&gt;

&lt;p&gt;Where security doesn't own the rules, it can still own how every service enforces them. Shared middleware can reject any request a service doesn't explicitly allow, so a forgotten check refuses access instead of granting it. Every service can also log its authorization decisions to one place security audits.&lt;/p&gt;

&lt;p&gt;Either way, the check runs in the service. A gateway-only check trusts the web app and scheduler routes because they run inside the network. NIST's zero trust architecture, SP 800-207, rejects that, holding that there's no "implicit trust granted to assets or user accounts based solely on their physical or network location."&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the Business Rules Already in Your Gateway
&lt;/h2&gt;

&lt;p&gt;Open your gateway's policies, plugins, mapping templates, and route rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Search for business concepts.&lt;/strong&gt; Claim names and domain terms find most of them. Put each through the ownership test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move each rule that fails into the service that owns it,&lt;/strong&gt; with a check and a test that cover every path to the capability, including jobs, consumers, internal tools, and other gateways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give any stopgap that has to stay an owner and a removal date,&lt;/strong&gt; and add a pipeline check that rejects a policy missing either or past its date. A stopgap with neither is business logic that stayed.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwaredevelopment</category>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>apigateway</category>
    </item>
    <item>
      <title>Your Reads Should Not Design Your Writes</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Wed, 16 Sep 2026 16:57:45 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/your-reads-should-not-design-your-writes-4icb</link>
      <guid>https://dev.to/stevenstuartm/your-reads-should-not-design-your-writes-4icb</guid>
      <description>&lt;p&gt;Any API endpoint that updates part of a record has to decide what a missing field means, whether the client left it alone or wants it cleared. Most business APIs face that decision constantly, because records like customers, orders, and accounts are edited through forms and clients that rarely send everything. Few problems have left me as dizzy as this one, partly because it's hard, but mostly because of how many competing solutions keep getting promoted for it, from patch formats to field masks to change-tracking client libraries.&lt;/p&gt;

&lt;p&gt;Most of those solutions ask every client to get something subtle right, when most teams need a write surface any client can call correctly on the first try. I think the simplest way there is PUT, where every body must include every field and the server has nothing left to guess.&lt;/p&gt;

&lt;p&gt;That only works once a resource is small enough for any client to send whole, so the change that matters is to stop letting the shape of your reads design your writes. When a group of fields has a single owner, when a change starts a workflow, or when a collection's items come and go individually, that group, change, or collection gets its own path. That can look like a workaround, but I'd argue it's healthy normalization of the write surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write Intentions Exceed Request Semantics
&lt;/h2&gt;

&lt;p&gt;Each field in a write body carries one of four intents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leave alone.&lt;/strong&gt; The field isn't part of this change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set a value.&lt;/strong&gt; Including zero, empty, or false.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear.&lt;/strong&gt; Remove the current value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change one element of a collection.&lt;/strong&gt; Add or remove an item without resending the rest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The examples use one customer, which the API reads whole:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /customers/42
{
  "displayName": "Ada",
  "phoneNumber": "555-1234",
  "email": "ada@example.com",
  "verified": true,
  "isActive": true,
  "tags": ["vip", "beta"]
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By convention, updates to &lt;code&gt;/customers/42&lt;/code&gt; accept that same shape, whether they arrive as PATCH, PUT, or POST. JSON can express the first three intents for &lt;code&gt;phoneNumber&lt;/code&gt; by sending a value, sending &lt;code&gt;null&lt;/code&gt;, or leaving it out. But most servers read the body into a fixed model where missing and &lt;code&gt;null&lt;/code&gt; become the same empty value, so the endpoint has to pick a rule, and the rule decides which two intents survive:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Body&lt;/th&gt;
&lt;th&gt;Replace the resource&lt;/th&gt;
&lt;th&gt;Skip nulls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;"phoneNumber": "555-1234"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Set&lt;/td&gt;
&lt;td&gt;Set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;"phoneNumber": null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clear&lt;/td&gt;
&lt;td&gt;Leave alone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;phoneNumber&lt;/code&gt; omitted&lt;/td&gt;
&lt;td&gt;Clear&lt;/td&gt;
&lt;td&gt;Leave alone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Skipping nulls gives up clear, so a phone number, once set, can never be removed. Teams often work around that one field at a time, with an empty string that means clear or a separate &lt;code&gt;clearPhoneNumber&lt;/code&gt; flag, and the next nullable field breaks the same way. Replacing gives up leave alone, which is safe only while every client sends every field. A view model that loads only the fields its screen shows breaks that silently, clearing everything else on save. The fourth intent doesn't fit a single field at all, because &lt;code&gt;tags&lt;/code&gt; can only be sent whole.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clients Hold State, Not Changes
&lt;/h2&gt;

&lt;p&gt;A handler can fix the server side by reading the raw document or accepting a patch format that encodes intent. But a partial update is only as correct as the client that built it, and the client has to know which fields the user touched.&lt;/p&gt;

&lt;p&gt;The form often knows, since libraries like Angular's reactive forms and React Hook Form track dirty fields. But the form doesn't send the request. A view model maps its values onto a DTO, a service layer passes that along, and a generated SDK type turns an unset property into a default or a null. By the time the request leaves, "the user didn't touch this" has been flattened into a value, and preserving it means threading change tracking through every layer of every client, including partners'.&lt;/p&gt;

&lt;p&gt;Tools that try to recover it each leave a gap. Microsoft's Kiota generator tracks set properties in a backing store, but only for code that writes to the SDK object directly, which a hand-written mapper doesn't. Diffing the loaded document against the outgoing one shows a mapper's defaults as changes the user never made. Only a client that snapshots its own mapped copy at load avoids that.&lt;/p&gt;

&lt;p&gt;One workaround skips change tracking. Each client sends only the fields its form owns, so the contact form sends &lt;code&gt;{ displayName, phoneNumber }&lt;/code&gt; and the handler checks which keys are present in the raw JSON. That works while every client scopes its bodies the same way.&lt;/p&gt;

&lt;p&gt;But nothing on the server enforces the grouping. The endpoint accepts &lt;code&gt;isActive&lt;/code&gt; from any caller that includes it. The handler decides field by field who may set what and what each change triggers. And a typed SDK that models the whole customer can lose presence again on the way out. The form scope is the right boundary, held in the wrong place, by convention in each client instead of by the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patch Formats and Design Guides Leave the Client Problem in Place
&lt;/h2&gt;

&lt;p&gt;The standards never settled partial updates. RFC 5789, which defined PATCH in 2010, left the body format open on the expectation that "no single format will be appropriate for all types of resources." Two kinds of answer grew into that gap, standardized patch formats and the API design guides large companies publish, and teams tend to adopt either one as the professional answer.&lt;/p&gt;

&lt;p&gt;JSON Patch, defined in RFC 6902, encodes all four intents explicitly, as add, remove, and replace operations addressed by path. A path into an array is an index, though, so removing &lt;code&gt;beta&lt;/code&gt; means removing &lt;code&gt;/tags/1&lt;/code&gt;, which deletes the wrong tag if someone reordered the array first, unless a &lt;code&gt;test&lt;/code&gt; operation guards it. JSON Merge Patch, from RFC 7396, is simpler and covers three, but replaces arrays whole. Neither can tell a client which fields changed, so adopting one hands every caller the change-tracking problem.&lt;/p&gt;

&lt;p&gt;Design guides carry a different risk, because they aren't neutral standards but one company's internal practice, published. Google's API Improvement Proposals do move state changes into custom methods. But their field masks are built around protobuf messages and Google's generated clients. The client still has to fill each mask with the fields that changed. A server can pin each mask to one form's fields, but that groups fields by form without giving the group a URL for gateways, logs, and ETags to key on.&lt;/p&gt;

&lt;p&gt;Even Microsoft doesn't offer its REST API Guidelines as universal. It publishes them hoping other organizations will "create guidelines that are appropriate for them."&lt;/p&gt;

&lt;p&gt;Those choices fit organizations that own their clients, generate their SDKs, and employ governance teams to enforce conformance. Copied into a team without that, they can become ritual, kept because a large company published them rather than because they solve anything the team has.&lt;/p&gt;

&lt;p&gt;Partial updates still have a home in documents, data with no fixed model, rather than records. A preferences bag that gains keys every release suits JSON Merge Patch, and because nothing maps it onto a fixed model, a present, null, or absent key still carries all three intents. A large configuration document suits JSON Patch. The rest of this post is about records.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wide Write Is a Normalization Failure
&lt;/h2&gt;

&lt;p&gt;The shared update body mirrors the read, so the write has to have an opinion about every field, including &lt;code&gt;verified&lt;/code&gt;, which no client should set, and &lt;code&gt;isActive&lt;/code&gt;, which most callers shouldn't touch.&lt;/p&gt;

&lt;p&gt;A read is allowed to be a composition, and serving the whole customer in one GET is good design, but it's a view over things with different rules. The wide write happens when that view becomes the write target, usually because one model serves both directions. It fails the way an unnormalized table does, by giving unrelated facts a single place to be written. That's a governance gap more than a technical one. Nobody decided what the write surface should be, so the read model decided for them.&lt;/p&gt;

&lt;p&gt;PUT on the wide customer doesn't rescue it. RFC 9110 defines PUT as replacing "the state of the target resource" with "the state defined by the representation enclosed." That only works when every client can produce every field and is entitled to replace each one, which only a small resource can promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalize the Write Surface
&lt;/h2&gt;

&lt;p&gt;A normalized write surface writes each field where its owner, permission, and workflow live, while reads compose those resources into views for each screen. It's the read and write split of CQRS, with both sides left as ordinary HTTP resources.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three Tests Decide What Shares a Resource
&lt;/h3&gt;

&lt;p&gt;Two fields belong in the same writable resource only when they pass all three tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same owner.&lt;/strong&gt; The same party is entitled to decide both values, whoever submits the change for them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same authorization scope.&lt;/strong&gt; A caller needs the same permission to change either one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same workflow.&lt;/strong&gt; Changing either one triggers the same consequences, or none.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;th&gt;Authorization scope&lt;/th&gt;
&lt;th&gt;Workflow&lt;/th&gt;
&lt;th&gt;Write&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;displayName&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Customer&lt;/td&gt;
&lt;td&gt;Edit own profile&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUT /customers/42/profile&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phoneNumber&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Customer&lt;/td&gt;
&lt;td&gt;Edit own profile&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUT /customers/42/profile&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Customer&lt;/td&gt;
&lt;td&gt;Edit own profile&lt;/td&gt;
&lt;td&gt;Verification email&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUT /customers/42/email&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;verified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;Not client-writable&lt;/td&gt;
&lt;td&gt;Set by verification&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;isActive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Operations&lt;/td&gt;
&lt;td&gt;Manage account status&lt;/td&gt;
&lt;td&gt;Deactivation checks&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /customers/42/deactivate&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Support&lt;/td&gt;
&lt;td&gt;Manage tags&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST&lt;/code&gt; and &lt;code&gt;DELETE /customers/42/tags&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;displayName&lt;/code&gt; and &lt;code&gt;phoneNumber&lt;/code&gt; pass all three and share a resource.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;email&lt;/code&gt; fails only the workflow test, and that's enough. The verification rules then live on the one endpoint that triggers them, and a PUT that resends the current address triggers nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;isActive&lt;/code&gt; fails all three. Changing it is a state transition with its own checks, so it becomes a named operation. A deactivation can no longer ride along with a phone-number change, because there's no body to carry it.&lt;/p&gt;

&lt;p&gt;The one wide body becomes these requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;PUT /customers/42/profile
{ "displayName": "Ada", "phoneNumber": null }

PUT /customers/42/email
{ "email": "ada@example.com" }

POST /customers/42/tags
{ "name": "vip" }

DELETE /customers/42/tags/beta

POST /customers/42/deactivate
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server requires every field in each body, so a body that leaves one out fails with a 400, and no intent depends on the server guessing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Intent&lt;/th&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Set &lt;code&gt;phoneNumber&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PUT /customers/42/profile&lt;/code&gt; with &lt;code&gt;"phoneNumber": "555-1234"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clear &lt;code&gt;phoneNumber&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PUT /customers/42/profile&lt;/code&gt; with &lt;code&gt;"phoneNumber": null&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leave &lt;code&gt;phoneNumber&lt;/code&gt; alone&lt;/td&gt;
&lt;td&gt;Skip the profile PUT, or resend its current value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove one tag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DELETE /customers/42/tags/beta&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A mapper can still turn a value into a null the user never chose. That error doesn't vanish, but when a screen loads its section whole, the mapper can only null fields the client fetched. Skipping a PUT means tracking which sections were touched, one flag per section rather than per field. Merge Patch on the same small resource would keep leave alone, but only by handing back the field-level tracking the mapper layers lose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Collections With Identity Get Their Own Addresses
&lt;/h3&gt;

&lt;p&gt;Collections whose elements carry identity get addressed individually, the way GitHub's REST API handles issue labels and Stripe's API exposes subscription items.&lt;/p&gt;

&lt;p&gt;A collection with no natural key, like an ordered list of steps, stays in its parent's body and is replaced whole with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Normalizing Costs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  More Calls, Usually Along Lines the Work Already Follows
&lt;/h3&gt;

&lt;p&gt;The objection is usually raised for admin and operations apps, on the assumption that they edit everything at once. Many don't. Their work moves from correcting contact details to reviewing access to deactivating an account, and each step is a write this design already names.&lt;/p&gt;

&lt;p&gt;The customer screen is the harder case, because it shows every concern on one page. A single Save button now makes one call per concern it touches plus one per tag, and has to report which ones failed.&lt;/p&gt;

&lt;p&gt;Shaping the screen like the write surface is a design choice, but common controls already lean that way. Nielsen Norman Group's toggle-switch guidelines say a switch "should take immediate effect and should not require the user to click Save or Submit," and tag chips can save on each click the same way. A single Save that carries a deactivation along with a phone-number edit is the wide write again, moved into the interface.&lt;/p&gt;

&lt;p&gt;Call count does hurt clients that write in volume, like an offline app syncing a day of edits, a bulk-edit grid, or an importer loading thousands of customers. They need a batch endpoint, not the wide body back. In the style of Microsoft Graph's JSON batching, each entry carries its own method, URL, and body and gets its own status. Each one passes through the rules of the endpoint it names, without quietly turning the batch into a transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Atomicity Across Concerns Has to Be Named
&lt;/h3&gt;

&lt;p&gt;A single wide update usually applies all or nothing, and separate calls don't, so a save across contact details and tags can fail halfway. As long as the server validates each write against current state, every successful call leaves a valid customer, so a failure leaves an incomplete edit rather than a corrupt record.&lt;/p&gt;

&lt;p&gt;Separate calls stop being enough when writes are only valid together. Closing an account deactivates the customer and cancels their subscriptions, and a deactivated customer who's still billed has to be impossible. That's a use case, and it gets a named operation, &lt;code&gt;POST /customers/42/close&lt;/code&gt;. Rules linking two fields follow the owners. A shipping address needs a postal code, and one owner holds both, so they share a resource. An enterprise tier requires a phone number, but the owners differ. A downgrade that also drops the phone number changes both fields together, so it gets a named operation. Listing every rule that reads more than one section finds these cases.&lt;/p&gt;

&lt;p&gt;The wide update never removed those actions. It hid them in field values, so &lt;code&gt;"isActive": false&lt;/code&gt; is a deactivation command the handler detects by comparing against the stored value. Naming them isn't a return to RPC, the endpoint-per-action style that resource-oriented APIs replaced. A normalized surface is still mostly GET and PUT on resources, with named operations kept for transitions, the way Stripe's API finalizes invoices and GitHub's API merges pull requests. An operation earns a name when the business needs its writes to succeed together, not when a screen happens to save several things at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Concurrent Edits Surface as Conflicts Instead of Merging
&lt;/h3&gt;

&lt;p&gt;If one agent changes &lt;code&gt;displayName&lt;/code&gt; while another changes &lt;code&gt;phoneNumber&lt;/code&gt;, two PUTs to the profile each send both fields, and the later one overwrites the earlier change. A partial update would keep both, but only with the change tracking clients rarely manage, and even then a merge can pair a new contact's name with the old contact's phone.&lt;/p&gt;

&lt;p&gt;The usual guard is a version check. A GET on the profile returns a version tag in its &lt;code&gt;ETag&lt;/code&gt; header, and the client sends that tag back in an &lt;code&gt;If-Match&lt;/code&gt; header on the PUT. If the resource changed in between, the server answers &lt;code&gt;412 Precondition Failed&lt;/code&gt; instead of overwriting. An edit screen loads each section from its own GET, so it resends that resource's own representation and version. A server that requires the header answers a missing one with &lt;code&gt;428 Precondition Required&lt;/code&gt;, defined in RFC 6585.&lt;/p&gt;

&lt;p&gt;Small resources make that check practical. On the wide customer, a tag another agent adds fails every save that loaded the customer before it. A batch replaying a day of offline edits gets 412s on its stale entries to refetch and reapply, which is where offline-first apps may need field merging. Outside offline sync, a 412 on the profile means someone changed the same details at the same time, which is rare and should reach the user.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding a Writable Field Changes the Contract
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;preferredName&lt;/code&gt; joins the profile, an older client's PUT leaves it out, and the server either clears it or rejects the request. So the field comes with a new version of the resource, which each request declares in its path or media type. Older clients keep sending the old version's request, and the server leaves &lt;code&gt;preferredName&lt;/code&gt; alone for those.&lt;/p&gt;

&lt;p&gt;A partial update skips the version by treating missing as leave alone, the rule that stopped the phone number from being cleared. Normalizing limits versions instead. Reads gain fields freely, and a field with its own owner, scope, or workflow gets its own resource. A resource that still gains writable fields every release may be better served by partial updates. For the rest, the versions that remain are the price of a body the server never guesses about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migration Runs Alongside the Wide Write
&lt;/h3&gt;

&lt;p&gt;Partners already calling the wide update can't simply start getting 405. The narrow endpoints go in alongside it, and clients move over screen by screen. Meanwhile the wide update has to route each field through its narrow endpoint's rules, or it becomes a back door, and it keeps its old missing-versus-null ambiguity until its last client moves. Once its traffic stops, &lt;code&gt;/customers/42&lt;/code&gt; keeps serving the composed view and answers writes with 405 and &lt;code&gt;Allow: GET&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Failures Behind One Wide Write
&lt;/h2&gt;

&lt;p&gt;I've come to think most software problems trace back to a failure to align with the need, a failure of architecture governance, or a failure to change direction. The wide write and the usual fixes for it show all three. Teams adopt a design guide written for someone else's clients. They let the read model decide a write surface nobody owned. And they special-case each ambiguous field instead of changing the design.&lt;/p&gt;

&lt;p&gt;None of the corrections takes new technology:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Group fields into a writable resource only when they share an owner, an authorization scope, and a workflow&lt;/li&gt;
&lt;li&gt;Replace those resources whole with PUT, requiring every field in the body&lt;/li&gt;
&lt;li&gt;Give collections with identity-bearing elements their own POST and DELETE endpoints&lt;/li&gt;
&lt;li&gt;Give the operation its own endpoint when a change is a state transition or has to be atomic across concerns&lt;/li&gt;
&lt;li&gt;Require &lt;code&gt;If-Match&lt;/code&gt; on PUTs, because resending a value read earlier is how a client leaves a field alone&lt;/li&gt;
&lt;li&gt;Version a writable resource when it gains a field, and let reads grow freely&lt;/li&gt;
&lt;li&gt;Move existing clients onto the narrow endpoints, then let aggregate URLs answer GET and refuse writes&lt;/li&gt;
&lt;li&gt;Save partial updates and patch formats for documents, like preference bags and large configurations&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Topology Is Not a Trust Model: Position vs Identity</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Thu, 02 Jul 2026 19:50:03 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/topology-is-not-a-trust-model-position-vs-identity-2l8j</link>
      <guid>https://dev.to/stevenstuartm/topology-is-not-a-trust-model-position-vs-identity-2l8j</guid>
      <description>&lt;p&gt;Every service request arrives with the same question: what makes this request legitimate? It is a question I return to often, because the answer tends to reveal a foundational belief about a system's architecture. That belief might not have been made explicit, and it could hint at a misalignment with the business domain.&lt;/p&gt;

&lt;p&gt;The school of &lt;strong&gt;Positional architecture&lt;/strong&gt; claims that legitimacy is &lt;strong&gt;granted&lt;/strong&gt; by placement. A request is legitimate because it arrived from the right place, to the right place. More specifically, from behind the perimeter, through the right intermediaries, from a subnet the architecture trusts, or toward a service that grants access on that same basis.&lt;/p&gt;

&lt;p&gt;The school of &lt;strong&gt;Identity-oriented architecture&lt;/strong&gt; claims that legitimacy is &lt;strong&gt;earned&lt;/strong&gt; by ownership. A request is legitimate because the caller has proven who it is and what it is authorized to do. Credentials state its bounded scope, not just its network address. Every service validates every caller the same way, and all services relate to each other as peers with clear, bounded authority over their own domains.&lt;/p&gt;

&lt;p&gt;The choice typically reflects a single belief about where legitimacy comes from. That belief shapes where authority lives in the system, how teams own their work, and whether the system can adapt as the domain evolves. It shapes them through one question that position-based trust never requires: what is this caller allowed to do?&lt;/p&gt;

&lt;h2&gt;
  
  
  Position vs. Identity in Practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Positional architecture&lt;/strong&gt; would commonly arrange a checkout feature like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Checkout Client
        │ ← identity verified here (edge)
        ▼
CheckoutFacade          ← owned by the API team; shapes the response for this consumer
        │ ← internal; trusted by position, not verified identity
        ▼
CheckoutOrchestrator    ← owned by the platform team; coordinates the checkout flow
        │               │
        ▼               ▼
  OrderService     PaymentService    ← no credential check; trusted because internal
        │
    orders DB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Identity-oriented architecture&lt;/strong&gt; removes the intermediaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Checkout Client
        │ [checkout token]         │ [checkout token]
        ▼                          ▼
  OrderService ──[order-svc token]──► PaymentService
[validates caller]                  [validates caller]
        │
    orders DB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two diagrams differ in topology as well as trust, but topology and trust can vary independently. A layered system can check bounded identity at every hop. What the trust model decides is whether a tier that owns no data or rules of its own ever has to be justified.&lt;/p&gt;

&lt;p&gt;Under identity, a facade can still exist, but it appears in OrderService's policy and logs as a named caller, such as "CheckoutFacade may create orders." The orders team has to justify that grant, and anyone can list who depends on whom. Under positional trust, there is no grant and no list, so a tier that owns nothing borrows another domain's authority without its owners deciding to lend it.&lt;/p&gt;

&lt;p&gt;When OrderService calls PaymentService, it presents its own service identity rather than forwarding the client's token. The client can call both services itself only while no rule spans the two calls. A checkout whose order must not stand when payment fails needs a service that owns the checkout's lifecycle, as CheckoutService does in the lifecycle-ownership example below.&lt;/p&gt;

&lt;p&gt;When PaymentService needs to know which user a payment is for, OrderService exchanges the user's token for a narrowed one carrying both identities, as OAuth 2.0 Token Exchange (RFC 8693) defines. That way a compromised OrderService can pay only for users whose requests reach it. The common alternative, forwarding the user's token, narrows that risk without closing it. A compromised intermediary can replay a forwarded token against any service that accepts it, while an exchanged token is bound to one audience and one actor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bounded Authority
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Authority Is Canonical Ownership
&lt;/h3&gt;

&lt;p&gt;Authority is ownership: a domain service holds the canonical representation of its data, the validation rules governing it, and the contract it exposes. When multiple components claim authority over the same facts, each enforces different rules. No component is the definitive answer, and the drift between them is slow, then sudden.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tangled Calls Come From Weak Boundaries, Not Direct Calls
&lt;/h3&gt;

&lt;p&gt;The most compelling argument for adding an orchestration layer is avoiding the death star: an uncontrolled web of lateral calls between peer services where no component owns the full decision. Keeping calls flowing downward along real dependencies is a sound instinct. Positional architecture uses layer placement to enforce the cascade, but placement without authority creates pass-through components that fragment the authority they were supposed to preserve.&lt;/p&gt;

&lt;p&gt;Bounded authority inverts this: a service with tight, well-named scope has no need to reach sideways for decisions it already owns. When one bounded domain needs to coordinate with another, the call is direct and well-understood. Uber's fix for roughly 2,200 tangled microservices was its Domain-Oriented Microservice Architecture, which grouped services into domains and layers with a gateway per domain. Those layers can look positional, but each gateway belongs to its domain, and the layers are dependency rules, not tiers trusted for position. &lt;strong&gt;The tangled dependencies of a death star emerge from many poorly bounded components, not from well-bounded ones communicating directly.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A Tier Must Earn Its Place
&lt;/h3&gt;

&lt;p&gt;A layer is a conceptual separation of concerns (domain logic from presentation, for instance) that identity can enforce without a dedicated service sitting between the callers. A tier exists because behaviors need to scale or fail independently. A worker tier has different throughput, concurrency, and instance allocation from the service that enqueues into it. A facade tier that routes and shapes for a single consumer has no such requirement.&lt;/p&gt;

&lt;p&gt;Two consumer-facing tiers do meet an operational requirement: an aggregation tier that saves mobile clients round trips, and a facade that keeps the public contract stable while domains split or merge. Either earns its place as long as it owns no business rules and the services behind it still check the caller.&lt;/p&gt;

&lt;h3&gt;
  
  
  Legitimate Coordination Is Choreography or Lifecycle Ownership
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Choreography&lt;/strong&gt;: each service reacts to domain events it subscribes to. No central coordinator sits in the call path, so no single place shows the whole flow, and tracing a stuck checkout means correlating events across services. Events are records of decisions domain services have already made, not instructions to other services. An event mechanism that begins routing on business rules has claimed authority over those decisions. It is a positional layer disguised as infrastructure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client
  │ [checkout token]
  ▼
CheckoutService ← owns the cart
  │ publishes checkout.initiated
  ▼
OrderService [own authority]
  │ publishes order.created
  ▼
PaymentService [own authority]
    publishes payment.authorized
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lifecycle ownership&lt;/strong&gt;: a domain service owns the process itself. CheckoutService is not a positional orchestrator if it holds the canonical state of the checkout: when it started, what steps have completed, what its terminal states are. Each sub-service validates CheckoutService's identity directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client
  │ [checkout token]
  ▼
CheckoutService ← holds checkout state
  │ [checkout-svc token]          │ [checkout-svc token]
  ▼                               ▼
OrderService                PaymentService
[validates caller]          [validates caller]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove the coordinator and ask whether canonical state is lost. If yes, the service is a domain, not a layer. Canonical means business facts that users and other services ask this service for, such as a checkout's status or outcome, not a log of which calls succeeded. An orchestrator whose saga state users query passes the test too, so it should have an owning team like any domain.&lt;/p&gt;

&lt;p&gt;If no, all state lives in the sub-services and the coordinator exists only to sequence calls. The checkout's own rules, such as whether an order stands when payment fails, then belong to no domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Positional Architecture Accumulates
&lt;/h2&gt;

&lt;p&gt;Positional architecture can be a deliberate choice, with its trade-offs known and accepted. More often it arrives through one of a few recurring paths, none of which examined the trust model they built together.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Organic accumulation&lt;/strong&gt;: a facade added for consumer shaping, an orchestrator grown to coordinate a flow no single service owned, an adapter added for a protocol mismatch. Each decision was reasonable when made; the architecture they collectively implied was not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern cargo-culting&lt;/strong&gt;: Companies like Netflix and Uber evolved their layers in response to specific scaling pressures they described publicly, such as Netflix's range of client devices and Uber's thousands of services. Most teams haven't faced those pressures and won't. Teams that copy the pattern have a new domain, a smaller team, and a system that hasn't revealed where the real scaling pressure will sit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance overreach&lt;/strong&gt;: PCI DSS requires network security controls between the Cardholder Data Environment and untrusted networks, but the PCI Security Standards Council's scoping and segmentation guidance says segmentation is not a requirement, only a way to shrink assessment scope. HIPAA and GDPR prescribe access control and data protection outcomes, and SOX requires internal controls over financial reporting. None of them dictates how internal services trust each other. The pressure toward perimeter models comes from how organizations and assessors interpret the frameworks, not from their text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cases where positional architecture earns its cost are narrow. The one scenario that legitimately forces a specific boundary component is integration with systems outside your change control: acquisitions, partner APIs, and legacy systems that can't support identity-oriented calls. A facade at that boundary is a quarantine, not a commitment to positional architecture everywhere. Elsewhere, positional trust holds up only with per-hop controls such as mutual TLS and explicit authorization, and delivery pressure tends to delay those.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Positional Architecture Costs
&lt;/h2&gt;

&lt;p&gt;Positional architecture that has accumulated pass-through tiers adds hops that answer no operational question and that no owner approved. Each one adds cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Debugging requires tracing the entire layer topology&lt;/li&gt;
&lt;li&gt;Testing multiplies because you need unit tests at each layer, contract tests between layers, and integration tests across the full stack&lt;/li&gt;
&lt;li&gt;Capacity planning gets harder because one unit of external load fans out to multiple internal calls with different resource profiles at each layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These costs grow with hop count, so a long choreographed flow pays some of them too, but each of its hops is a domain acting on its own authority.&lt;/p&gt;

&lt;p&gt;The real coupling is not shared code but shared call chains: every consumer request travels through the same intermediary services in the same order. The layers are separate deployments with separate teams. Yet a change in a domain service propagates upward through every adapter and facade that passes it along rather than translating it. That is how change spreads through a tightly coupled monolith.&lt;/p&gt;

&lt;p&gt;At sufficient coupling depth, a true monolith is more defensible. It skips the network hops and multi-service deploys that are the price of independence, a price the coupled layers pay without getting the independence.&lt;/p&gt;

&lt;p&gt;In identity-oriented architecture, a consumer calls a domain service directly. That service may call one or more supporting services, and having to state what each caller may do tends to keep that chain short. Each service can be reasoned about, scaled, and deployed on its own terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Identity-Oriented Architecture Costs
&lt;/h2&gt;

&lt;p&gt;Every service credential requires a lifecycle: issuance, rotation, and revocation. At scale, this becomes a distributed secrets management problem that positional architecture sidesteps by treating network membership as sufficient proof. Delegation also puts a token service on the path of delegated calls, which caching short-lived tokens bounds but doesn't remove.&lt;/p&gt;

&lt;p&gt;The infrastructure for workload identity has matured, but most of the cost is front-loaded: teams pay it before the system is large enough for positional architecture's costs to become visible. That timing asymmetry is part of what makes the positional default durable. Paying early can look like cargo-culting, but it needn't start big. Any system with two services already has to decide what each may ask of the other, and a per-service allow-list answers that long before a token service is needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Asymmetric Security Posture of Positional Systems
&lt;/h2&gt;

&lt;p&gt;The security problem with positional systems is not the model itself but the belief that sustains it and where that belief concentrates security effort. Built to its full specification, a positional system can be tightly controlled, with mutual TLS on every hop, audit logging at every tier, and explicit authorization at each layer.&lt;/p&gt;

&lt;p&gt;The positional belief can outlive a security overhaul. The security half of this post's argument is zero trust, which NIST SP 800-207 defines as granting no implicit trust based solely on network location. But a zero-trust rollout can leave the pass-through tiers and layer-shaped teams in place, and they keep pulling security effort back to the edge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Network Membership Is Not Identity
&lt;/h3&gt;

&lt;p&gt;In a positional system, a service's authority comes from its layer placement. When that service authenticates to call another, the most natural credential is a cert from the internal CA, accepted by any internal callee that trusts that CA. It answers "are you one of us?" rather than "are you specifically OrderService with authority over orders?"&lt;/p&gt;

&lt;p&gt;So security effort tends to concentrate at the edge, where external callers prove they belong, and everything behind it is trusted implicitly.&lt;/p&gt;

&lt;p&gt;A single vulnerability such as server-side request forgery, request smuggling, or a compromised internal service can give access to the entire soft interior, not just the narrow scope of whatever was breached.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentication Is Not Authorization
&lt;/h3&gt;

&lt;p&gt;The counter-argument is that service meshes like Istio and Linkerd can retrofit mutual TLS and per-hop authentication onto a positional system without changing service code. A mesh does issue each workload its own identity, and Istio's authorization policies can restrict which identities may call which paths and methods. On a positional system, though, the path of least resistance is the policy that matches its belief: "any authenticated mesh workload may call." A compromised internal service then holds an equally valid ticket.&lt;/p&gt;

&lt;p&gt;Per-identity allow-lists are possible, but writing one means answering what each caller is for. Even a strict policy stops at routes and methods. Whether this caller may act on this particular order is a domain decision that only OrderService can make. The mechanism changed; what the system trusts didn't.&lt;/p&gt;

&lt;p&gt;The distinction is clearance versus need-to-know. A top secret clearance doesn't entitle its holder to every top secret document. A mesh cert accepted under an allow-all-authenticated policy is a clearance that proves tier membership and nothing else.&lt;/p&gt;

&lt;p&gt;Identity-oriented architecture demands need-to-know instead. Every service presents credentials that prove a specific bounded identity, and every callee checks that identity against what it owns. A compromised component can act only within its authorization scope and on the requests that reach it while compromised. The security model and the architectural model are aligned because they share the same belief: legitimacy comes from what you are, not from where you sit.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Attack Surface Objection Counts Only the Edge
&lt;/h3&gt;

&lt;p&gt;A common objection is that exposing domain services directly increases the attack surface.&lt;/p&gt;

&lt;p&gt;That objection counts only publicly reachable endpoints, and intermediary tiers don't reduce even those. Every new consumer-facing feature adds public endpoints whether or not intermediary tiers exist. The count also leaves out the interior. Once anything behind the edge is breached, every implicitly trusted internal call is attack surface too.&lt;/p&gt;

&lt;p&gt;An identity-oriented system can still put a gateway at the edge for rate limiting, IP flagging, and geographic constraints, and keep network segmentation as defense in depth. Passing the gateway just grants nothing. Every service still checks every caller, because there is no interior to fall back on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer Boundaries Become Team Boundaries
&lt;/h2&gt;

&lt;p&gt;Positional architecture tends to reinforce horizontal teams organized around the layers. Specialization produces horizontal teams too, but positional architecture gives them a structure to settle into, since every tier needs an owner. The backend team owns domain services, the platform team owns orchestration, the API team owns the external facade. The backend team tends to be rewarded for internal quality, not consumer outcomes, and the orchestration team optimizes for the calls it coordinates rather than the features consumers need.&lt;/p&gt;

&lt;p&gt;Every capability that crosses a layer boundary requires coordination, negotiation, and synchronized releases. Identity-oriented systems have platform teams as well, but they own a capability domains consume, not a hop features pass through.&lt;/p&gt;

&lt;p&gt;Melvin Conway observed in "How Do Committees Invent?" that organizations design systems that copy their communication structures. Layers tend to push back the other way, because once they exist, communication structures solidify around them. The result is teams in conflict about the architecture rather than the product. Who owns the latency that appeared between the orchestrator and the domain service? Whose responsibility is it when the contract between the facade and the adapter breaks? These arguments look like culture problems when they're architectural ones. The clearest sign that an architecture is serving itself rather than its system is when teams spend more time reasoning about which layer a change belongs to than building the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discipline Costs More When It Multiplies Across Layers
&lt;/h2&gt;

&lt;p&gt;The most common response is that teams with strong governance, comprehensive testing, and mature observability can operate positional systems effectively. A well-governed positional system beats an undisciplined identity-oriented one.&lt;/p&gt;

&lt;p&gt;The objection treats discipline as an architectural substitute, and it isn't. Both styles require the same disciplines. They differ in what those disciplines cost when you add layers. In a positional system, a change at any hop can ripple through every connected hop. Even mature observability can't map that ripple in advance. Tracing shows only the dependencies that happened, since no callee's policy lists the ones its owners approved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Position by Default Is the Failure
&lt;/h2&gt;

&lt;p&gt;Does legitimacy come from who you are, or from where you sit?&lt;/p&gt;

&lt;p&gt;Positional architecture becomes the wrong answer when it arrives by default rather than by deliberate commitment. Three questions expose the default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Does this tier meet an operational requirement its neighbors can't, such as scaling or failing on its own?&lt;/strong&gt; If not, identity could enforce its layer without a deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If this coordinator disappeared, would canonical state be lost?&lt;/strong&gt; If not, it only sequences calls, and the rules it enforces belong to no domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the callee check what this caller may do, or only that the call came from inside?&lt;/strong&gt; If only the latter, the interior is soft.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A system that earns its layers by living with the problems they solve is a different thing from one that inherits them from a diagram.&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Architecture Is a Belief About Where Authority Belongs</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Fri, 26 Jun 2026 21:00:14 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/architecture-is-a-belief-about-where-authority-belongs-3iak</link>
      <guid>https://dev.to/stevenstuartm/architecture-is-a-belief-about-where-authority-belongs-3iak</guid>
      <description>&lt;p&gt;When I encounter a system or data design decision I'm unsure about, I endeavor to ask the same thing: &lt;strong&gt;where does authority live, and how bounded is it&lt;/strong&gt;?&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Authority Belongs
&lt;/h2&gt;

&lt;p&gt;Authority in a software system is the assignment of decision-making power. Something is authoritative over data when it is the canonical source of truth, and authoritative over a behavior when it is the only thing that can legitimately enforce it. Every design encodes a belief about where authority belongs, and that belief shapes what the system can absorb when it changes.&lt;/p&gt;

&lt;p&gt;Four of software engineering's best-known practices each began as a response to a specific observed failure. Robert C. Martin's Single Responsibility Principle observed that a class holding authority over two concerns forces reasoning about both when either changes, producing behavioral drift at the class level. Database normalization, from E. F. Codd's relational model, observed that a fact stored in two places produces an inconsistency when one is updated, causing data drift.&lt;/p&gt;

&lt;p&gt;Least privilege confines power too, but for containment rather than drift. As Jerome Saltzer and Michael Schroeder framed it, it limits the damage an accident or error can do by confining each process to the power its job needs, so it can't act on state it doesn't own. Bounded contexts come from Eric Evans's domain-driven design, summarized in Martin Fowler's BoundedContext entry. They observed that two teams sharing a term without shared authority over its meaning will diverge on that meaning, creating semantic drift at the domain level. So a bounded context lets a term diverge on purpose across a boundary, with one meaning and one authority inside each context.&lt;/p&gt;

&lt;p&gt;These traditions emerged from different problems, in different decades, for different audiences, and converged on the same structural answer: confine decision-making power to the concern that needs it. Spread beyond that concern, it produces drift or widens the damage an error can do. When a component or even an actor holds decision-making power beyond what its concern requires, it makes decisions that other components or actors are also making, and those decisions diverge. Copying data isn't the problem. Copies fed from a single owner's contract don't diverge, because only the owner decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assessing Authority Strength
&lt;/h2&gt;

&lt;p&gt;Two properties assess how well an authority claim holds: how well the authority is contoured, and how strongly its boundary is enforced.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contour
&lt;/h3&gt;

&lt;p&gt;Contour is the precision of the authority claim, calibrated by two conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Behavioral coherence&lt;/strong&gt;: the authority's decisions, facts, and behaviors change together for the same reasons&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational coherence&lt;/strong&gt;: no behavior inside the boundary needs to scale or fail independently of the others, close to Mark Richards and Neal Ford's architecture quantum&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CQRS, for example, can split read and write models for the same domain even when they are behaviorally coherent, because their operational envelopes are incompatible. Reads often run at far higher volume than writes. Behavioral coherence alone would keep them together.&lt;/p&gt;

&lt;p&gt;A well-contoured authority can be named precisely: "OrderCheckoutService" tells you what it owns, while "OrderService" does not until its boundary states which order decisions belong to it. The test behind the name is the list of decisions only this component may make, and a list that needs exceptions or an "and also" signals that contour might be misaligned.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bond
&lt;/h3&gt;

&lt;p&gt;Bond is the enforcement strength of the boundary. The strength a boundary needs is set by the consequence of bypass. That consequence has two parts: the damage a bypass can do to state when it is used, and the coupling it creates that someone pays for when the owner next changes.&lt;/p&gt;

&lt;p&gt;A strongly bonded authority has no known bypass, so all interactions go through its contract. A weakly bonded authority has routes around it such as direct database access, internal calls that skip validation, or shared state that circumvents the service layer. A payment processing boundary that is bypassed can produce corrupted financial state, while a read model that serves slightly stale data can tolerate a weaker bond.&lt;/p&gt;

&lt;p&gt;A read-only bypass does little damage to state but can still create heavy coupling, because every consumer of a table's shape is a party to its next migration. Weighing both parts catches the familiar shared-database anti-pattern when access is granted, not later when a migration stalls.&lt;/p&gt;

&lt;p&gt;Contour and bond sit close to cohesion and coupling, but contour adds operational coherence, and bond counts the coupling a bypass creates at the owner's next change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Characteristics Should Drive Style and Team Topology
&lt;/h2&gt;

&lt;p&gt;We can often be so focused on code and "architecture" that we forget that there is a much broader puzzle to solve, with each aspect affecting the others. Contour and bond apply to the teams that own the code, not only to the code.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Architecture Characteristics&lt;/strong&gt;, the term Richards and Ford use in &lt;em&gt;Fundamentals of Software Architecture&lt;/em&gt;, should be the primary driver of any system design decision. They are the business priority values the system must honor: cost, security, availability, scalability, deployability, and the rest. Style and team topology should be derived from them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architectural Style&lt;/strong&gt; is the structural arrangement of the system, chosen to honor the characteristics. Contour and bond assess whether authority in the code is correctly placed under the pressures the characteristics describe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team Topology&lt;/strong&gt; is how the organization structures ownership and decision-making. Melvin Conway's law shows the derivation often runs in reverse, as systems mirror their builders' communication structure. The inverse Conway maneuver, Jonny LeRoy and Matt Simons's term for shaping teams to reach a target architecture, is that derivation done on purpose. We should assess authority here as well to see whether the teams have enough proximity to a domain to adapt: to draw and redraw the domain boundaries to sustain integrity and growth. A team owning two behaviorally incoherent domains has poor contour just as a class does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Poor Contour Schedules Drift
&lt;/h2&gt;

&lt;p&gt;Poor contour doesn't create a risk of drift. Under sustained change to the concerns it conflates, it schedules it.&lt;/p&gt;

&lt;p&gt;When two components hold partial authority over the same concern, they evolve independently. Different teams touch them under different pressures, and neither has complete visibility into what the other owns. The same drift happens when one component holds authority over two concerns. Once two stakeholder groups request its changes, each change is made and checked under one group's pressure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Early Optimization Locks In Miscontoured Authority
&lt;/h3&gt;

&lt;p&gt;A persistent source of poor contour is early optimization. Before a domain's behavioral coherence is understood, structural decisions get made: services decomposed, schemas separated, and ownership assigned. These optimize for what is visible now, like team size and deployment topology, rather than for behavioral coherence, which might only become clear under change pressure. Once deployed, the cost of realignment is high enough to defer indefinitely. The structure that was supposed to be provisional becomes load-bearing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Correlation Between Decision and Consequence Is Hidden
&lt;/h3&gt;

&lt;p&gt;Architectural arguments often fail because the failure they predict arrives years later, and its cost is rarely expressed in terms legible to the people who make the final call.&lt;/p&gt;

&lt;p&gt;Because of that lag, when a facade's validation rules and a domain service's rules diverge, people tend not to trace it back to the decision to put business logic in a routing layer. They trace it to human error. When a decomposed architecture becomes expensive to change, teams rarely trace it back to service boundaries drawn before behavioral coherence was understood. They trace it to team coordination.&lt;/p&gt;

&lt;p&gt;By the time the drift is painful, the people who drew the boundary have often moved on, and the decision that caused it is no longer traceable to the people dealing with its consequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authority in Practice: An Order Workflow
&lt;/h2&gt;

&lt;p&gt;An order workflow, built here as an illustration rather than a case history, touches most of the patterns where authority gets misplaced.&lt;/p&gt;

&lt;h3&gt;
  
  
  No Authority Declared
&lt;/h3&gt;

&lt;p&gt;The system starts as a single application. Order management, payment processing, inventory tracking, and user accounts share a codebase and a database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────────────────────────┐
│                   OrderApplication                   │
│                                                      │
│   CheckoutController       AdminController           │
│           │                       │                  │
│           └───────────┬───────────┘                  │
│                       │                              │
│           ┌───────────┴───────────────────┐          │
│           ▼                               ▼          │
│    OrderPaymentMgr ◄──────────► InventoryUserMgr     │
│           │                               │          │
│           └───────────┬───────────────────┘          │
│                       │                              │
│                   Shared DB                          │
└──────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both controllers reach into the entire manager layer. &lt;code&gt;CheckoutController&lt;/code&gt; calls &lt;code&gt;OrderPaymentMgr&lt;/code&gt; to initiate a purchase and &lt;code&gt;InventoryUserMgr&lt;/code&gt; to check stock. &lt;code&gt;AdminController&lt;/code&gt; calls the same managers to modify orders, adjust inventory, and update accounts. The managers cross-call each other when they need data the other holds.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;InventoryUserMgr&lt;/code&gt; mixes stock management with user account concerns. Neither manager is contoured to a single domain; neither controller is contoured to a single workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contour&lt;/strong&gt;: undefined. &lt;code&gt;OrderPaymentMgr&lt;/code&gt; conflates order lifecycle with payment processing, behaviors that change for different reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bond&lt;/strong&gt;: none. There is nothing to bypass and nothing to break until the system is large enough that the cost becomes unavoidable.&lt;/p&gt;

&lt;p&gt;This is not inherently wrong for an early-stage system. The problem is not the monolith but that authority was never considered. When the system grows, there is nothing to grow from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decomposition Without Authority
&lt;/h3&gt;

&lt;p&gt;The team recognizes that &lt;code&gt;OrderPaymentMgr&lt;/code&gt; and &lt;code&gt;InventoryUserMgr&lt;/code&gt; are too broad and splits them into per-domain services. Each service deploys independently and owns its own code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────────────────────────┐
│                   OrderApplication                   │
│                                                      │
│   CheckoutController       AdminController           │
│                                                      │
│    OrderService    PaymentService    InventorySvc    │
│         │               │                │           │
│    [orders DB]    [payments DB]    [inventory DB]    │
│         ▲               │                            │
│         └───────────────┘                            │
│          PaymentService reads orders data            │
│          directly across domain boundary             │
└──────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each service has its own schema, which declares a boundary. But PaymentService queries the orders schema directly, and that bypass exists for any service that knows the connection string. Each such query embeds the schema's shape into the consumer's code, so a data model change requires simultaneous updates across every service that queries it. Separate schemas or database instances don't determine authority. The data access patterns do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contour&lt;/strong&gt;: named but not coherent. PaymentService queries order data because order state and payment decisions are tightly coupled in practice, and the boundary didn't account for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bond&lt;/strong&gt;: declared but bypassed. The consequence of cross-schema access was underestimated.&lt;/p&gt;

&lt;p&gt;This is a common intermediate state: the full complexity of distributed services without the independence those services were supposed to deliver.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared Authority Through a Facade
&lt;/h3&gt;

&lt;p&gt;With services now decomposed but &lt;code&gt;CheckoutController&lt;/code&gt; and &lt;code&gt;AdminController&lt;/code&gt; still reaching across all of them, the team consolidates the entry point into a facade: a consumer-facing API that shapes responses and hides internal service structure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────────────────────────┐
│                   OrderApplication                   │
│                                                      │
│                      OrderFacade                     │
│               [validates order here]                 │
│                  │                │                  │
│                  ▼                ▼                  │
│            OrderService      PaymentService          │
│         [also validates]          │                  │
│                  │                │                  │
│            [orders DB]       [payments DB]           │
└──────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The facade validates order requests before passing them to OrderService. But OrderService also validates orders at the domain level, as it must. The same business rules now live in two places. When a rule changes (say, orders above a certain value require a manual approval step), both the facade and the domain service need to update. One gets updated; the other doesn't. Now clients going through the facade see one behavior and any direct caller of OrderService sees another.&lt;/p&gt;

&lt;p&gt;Neither layer is clearly the authority. Both claim to be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contour&lt;/strong&gt;: split across two behavioral concerns. Validation rules change when business requirements change; response shaping changes when clients change. Behavioral coherence says these belong to different authorities, but the facade holds both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bond&lt;/strong&gt;: split across two enforcement points. The consequence is inconsistent behavior.&lt;/p&gt;

&lt;p&gt;A facade holds clear authority over presentation concerns: routing, shaping, and aggregating results. Checks on shape and syntax fit there too, because they don't change when business rules do. The moment it acquires business logic, it becomes a second authority over the domain, and divergence is no longer a risk to manage but the mechanical consequence of the split.&lt;/p&gt;

&lt;p&gt;Contract tests or a shared rule module narrow the gap, but layers that deploy separately can run different versions. Only delegating the check to the domain's own endpoint closes it. The fix is not to remove the facade but to clarify what it owns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Domain-Driven Decomposition
&lt;/h3&gt;

&lt;p&gt;When the migration completes, each domain exclusively owns its data and has modeled its own aggregate root: the object that controls all access to entities within its boundary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────────────────────────┐
│                   OrderApplication                   │
│                                                      │
│  ┌──────────────────┐    ┌──────────────────┐        │
│  │  OrderService    │    │  PaymentService  │        │
│  │                  │    │                  │        │
│  │  OrderAggRoot    │───►│  PaymentAggRoot  │        │
│  │                  │    │                  │        │
│  │  [orders DB]     │    │  [payments DB]   │        │
│  └──────────────────┘    └──────────────────┘        │
└──────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An order's state can only change through the Order aggregate root: &lt;code&gt;Order.Accept()&lt;/code&gt;, &lt;code&gt;Order.Fulfill()&lt;/code&gt;, &lt;code&gt;Order.Cancel()&lt;/code&gt;. The aggregate root enforces the invariants that govern those transitions. PaymentService cannot read the orders table. If it needs order data, it calls the Order context's service boundary. OrderService requests payment through PaymentService's contract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contour&lt;/strong&gt;: named and coherent. OrderService's decisions are the transitions above, with no "and also". Deciding when an order needs payment is one of them, so PaymentService no longer reads order state and decides only how to take payment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bond&lt;/strong&gt;: strong, proportional to the consequence of bypass. State transitions through aggregate roots carry high consequence if violated, so the aggregate root enforces accordingly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reporting Access Breaks the Bond
&lt;/h3&gt;

&lt;p&gt;The order system is performing well, but dashboard queries against order data are putting load on OrderService. The team grants the reporting service direct read access to the orders database: read-only, for dashboards only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────────────────────────┐
│                   OrderApplication                   │
│                                                      │
│  ┌──────────────────┐    ┌──────────────────┐        │
│  │  OrderService    │    │ ReportingService │        │
│  │                  │    │                  │        │
│  │  OrderAggRoot    │    │                  │        │
│  │                  │    │                  │        │
│  │  [orders DB]     │◄───│                  │        │
│  └──────────────────┘    └──────────────────┘        │
│   ReportingService reads orders DB directly;         │
│   schema now serves two unrelated access patterns    │
└──────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contour is unchanged. OrderService still owns order lifecycle. The bond is weakened, and the team judges the consequence low because a read cannot modify order state. That judgment weighs only the first part of the consequence.&lt;/p&gt;

&lt;p&gt;Later, the reporting team adds an &lt;code&gt;order_summary&lt;/code&gt; materialized view over the &lt;code&gt;orders&lt;/code&gt; table, then a nightly export job that selects from that view.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;orders&lt;/code&gt; table has grown large enough that query performance on the checkout flow degrades under load. The team designs a migration: split &lt;code&gt;orders&lt;/code&gt; into &lt;code&gt;orders&lt;/code&gt; (header: customer, status, timestamps) and &lt;code&gt;order_line_items&lt;/code&gt; (per-item: SKU, quantity, price). The migration cannot proceed. The &lt;code&gt;order_summary&lt;/code&gt; view joins across columns that would be split into two tables, and the export job runs in a pipeline the reporting team controls on a separate release schedule. Coordinating all three changes stalls the migration for two quarters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bond&lt;/strong&gt;: violated. The aggregate root can no longer change the schema it's supposed to own without coordinating with a consumer that was never declared an authority over it.&lt;/p&gt;

&lt;p&gt;The bond wasn't broken in one decision; it eroded through a sequence of locally reasonable choices: a performance bypass, then a convenience view, then queries that took dependencies on both. The cost surfaced not at the point of access but at the point of change. An expand-and-contract migration with a compatibility view could unblock the split, but someone must own and retire that view, and owning it declares reporting as a consumer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reporting Access Through Contract
&lt;/h3&gt;

&lt;p&gt;The corrected version declares that consumer from the start and keeps the production schema exclusively in OrderService's authority.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────────────────────────┐
│                   OrderApplication                   │
│                                                      │
│  ┌──────────────────┐    ┌──────────────────┐        │
│  │  OrderService    │    │ ReportingService │        │
│  │                  │    │                  │        │
│  │  OrderAggRoot    │───►│  [reporting DB]  │        │
│  │                  │    │                  │        │
│  │  [orders DB]     │    │                  │        │
│  └──────────────────┘    └──────────────────┘        │
│   production schema belongs to OrderService alone;   │
│   reporting store shaped for reporting access only   │
└──────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OrderService publishes order data through a contract it controls, whether events, a scheduled export, or a dedicated read model, and ReportingService builds its own store from that contract. The &lt;code&gt;orders&lt;/code&gt; split from the previous stage would change only OrderService's mapping into that contract, with no coordination across release schedules.&lt;/p&gt;

&lt;p&gt;The price is a reporting feed that OrderService's team must build and version. Changes to what an order means still need coordination, but through a contract OrderService owns. Bond need only match the consequence. Where &lt;code&gt;orders&lt;/code&gt; rarely changes, a read replica with a declared schema contract and a named owner for downstream breakage can be bond enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contour&lt;/strong&gt;: coherent. OrderService owns order behavior and the production schema. ReportingService owns its read model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bond&lt;/strong&gt;: maintained. The &lt;code&gt;orders&lt;/code&gt; schema has no bypass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contour and Bond as a Standing Check
&lt;/h2&gt;

&lt;p&gt;Contour and bond are critical not just at design time but as a check at every point the architecture evolves, through a few questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Can the decisions only this component makes be listed without exceptions or an "and also"?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does everything inside the boundary change for the same reasons, and can it scale and fail as one unit?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What routes around the contract exist?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What breaks if each route is used, now or at the owner's next change, and does enforcement match that consequence?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a change makes a bypass convenient, who would have to coordinate the next schema change?&lt;/strong&gt; If the answer includes a team that was never declared an authority, the bond is already eroding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not every architectural disagreement is about authority. A choice between synchronous and asynchronous calls inside settled ownership is a tradeoff among latency, cost, and availability. But many disagreements about service boundaries, data ownership, and pattern choice, traced far enough, are arguments about authority that the participants haven't recognized as such. Making the authority framing explicit doesn't resolve the argument automatically, but it changes what the argument is about. Aesthetic preference or pattern-matching becomes a structural position that can be examined, challenged, and shown to be wrong. Contour and bond give that examination tangible articulation.&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Shaped Kanban: Complete Features, Not Sprints</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Thu, 04 Jun 2026 15:05:26 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/shaped-kanban-complete-features-not-sprints-3f3c</link>
      <guid>https://dev.to/stevenstuartm/shaped-kanban-complete-features-not-sprints-3f3c</guid>
      <description>&lt;p&gt;As an architect, a core part of my job is assessing viability and risk before committing a team to building something. That means understanding the problem deeply, testing critical assumptions early, and knowing when to change course. Sprint-based development fights me on every one of these. Planning ceremonies reward estimation speed over depth, sprint commitments pressure teams forward regardless of what they discover, and customer needs get filtered through velocity charts that measure team activity rather than delivered value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Timebox Problem
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Interval-based development organizes work around fixed time periods, not around completing features.&lt;/strong&gt; When timeboxes become the primary organizing principle, they corrupt even well-aligned teams.&lt;/p&gt;

&lt;p&gt;The intention is sound: prevent endless work, create rhythm, establish accountability. Yet timeboxes attempt to enforce through calendar boundaries what discipline should provide naturally.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Calendar Fills Itself
&lt;/h3&gt;

&lt;p&gt;When discovery changes understanding mid-interval, teams are forced to ship incomplete work or carry it over. When planned work gets blocked, teams feel pressure to fill the remaining days with whatever fits: not the most valuable work, not what should naturally come next, just work that squeezes into the artificial deadline. In continuous flow, a free WIP slot takes the top-ranked item whatever its size, because no boundary is four days away. In a sprint, the days left tend to decide. A high-value item that won't fit gets passed over for a smaller one, or it gets started and carried over. &lt;strong&gt;The timebox itself becomes the constraint that dictates what work happens, not the actual priorities or readiness.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The timebox created the problem it was meant to solve.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Different Teams, Forced Cadence
&lt;/h3&gt;

&lt;p&gt;Different team types operate on different natural cadences. Feature teams might deliver every few days while platform teams deliver every few months, yet many organizations force synchronization through universal sprint cadences because cross-team planning needs shared boundaries. The Scaled Agile Framework makes this explicit, aligning every team's iteration start and end dates within a release train.&lt;/p&gt;

&lt;p&gt;Timeboxes also conflate three concerns that should be independent: development cycles, deployment cycles, and feedback cycles. Each operates at its own natural frequency, yet sprints tend to pull all three into alignment, stretching fast work to fill the interval and fragmenting slow work across multiple cycles.&lt;/p&gt;

&lt;p&gt;The Scrum Guide doesn't require this coupling. It lets a team release an increment before the sprint ends, and it says the Sprint Review is never a release gate. But planning still happens at the boundary, and in practice feedback tends to batch at the review, where stakeholders inspect the work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ceremony Fuels Continuation Bias
&lt;/h3&gt;

&lt;p&gt;Scrum's ceremony structure fails this most visibly. Sprint Planning commits you to work. Daily Standups report progress. Sprint Review demonstrates what was built. The Retrospective redirects for next time. No ceremony routinely asks the question that matters mid-sprint: should we stop this work two days in because the assumptions were wrong?&lt;/p&gt;

&lt;p&gt;The Scrum Guide comes close. The Daily Scrum can adapt the sprint backlog, and the Product Owner can cancel a sprint whose goal has become obsolete. But adapting the backlog serves the sprint goal, and cancellation belongs to one role as an exception. Neither is a routine check on whether a piece of work should stop.&lt;/p&gt;

&lt;p&gt;Without a ceremony that asks the question, many teams don't. They obfuscate and proceed because abandoning the sprint goal feels like failure, and the real failure gets deferred. A team that won't fail a design two days into a sprint is unlikely to throw away three sprints of committed work either. The sunk cost grows with every sprint, and each Sprint Planning recommits the team to it in front of stakeholders.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The redirect you needed was to fail early, not plan differently next time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"We can't remove sprints; how would we know when things are done?" That question reveals the dysfunction. Scrum's own Definition of Done is meant to answer it without reference to the calendar. If a team still can't tell when work is done without one, it has a definition on paper but never developed genuine agreement on what "done" means.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When alignment exists, delivery cycles follow naturally. When alignment doesn't exist, timeboxes just create the illusion of progress.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Shaped Kanban: Discipline Without Shared Timeboxes
&lt;/h2&gt;

&lt;p&gt;What if you could have discipline without a shared timebox organizing everyone's work?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shaped Kanban combines rigorous upfront work definition with continuous flow.&lt;/strong&gt; It takes the best ideas from Shape Up, shaping work before betting on it and using circuit breakers to bound risk, and applies them to Kanban's continuous flow model. Unlike Shape Up's fixed 6-week cycles, each piece of work has its own natural timeline within appropriate bounds.&lt;/p&gt;

&lt;p&gt;Shaped Kanban replaces team-wide timebox mechanics with genuine discipline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shape work before committing&lt;/strong&gt; - Define clear boundaries, identify risks, clarify what "done" looks like&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bet flexibly&lt;/strong&gt; - Commit resources on a business cadence (quarterly, monthly) or on-demand as priorities shift&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use feature-specific circuit breakers&lt;/strong&gt; - Each feature gets appropriate time bounds (3 days, 2 weeks, 6 weeks) based on complexity, not universal sprint durations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flow continuously&lt;/strong&gt; - Work moves through the system when ready, not when the calendar says so&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This allows different team types (feature teams, platform teams, shared services) to operate at their natural cadences without artificial synchronization pressure. Coordination happens explicitly through dependencies, not through forced sprint alignment.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Shaped Kanban Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Shaping
&lt;/h3&gt;

&lt;p&gt;Before work begins, senior people shape the problem and solution space. Not detailed specifications, but boundaries.&lt;/p&gt;

&lt;p&gt;Appetite, Shape Up's term, defines how much time this problem deserves, not how long it will take. Instead of estimating bottom-up ("this will take 6 weeks"), you set a top-down constraint: "this is worth 2 weeks, not more." The appetite becomes a creative constraint that forces the question: what can we solve within this time bound? If you cannot shape a viable solution within the appetite, the problem either needs a bigger appetite or should not be worked on yet.&lt;/p&gt;

&lt;p&gt;Shaping answers these questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What problem are we solving, and what is the appetite?&lt;/li&gt;
&lt;li&gt;What are we explicitly not doing?&lt;/li&gt;
&lt;li&gt;What does good enough look like within the appetite?&lt;/li&gt;
&lt;li&gt;What assumptions are we making that, if wrong, would make this unviable?&lt;/li&gt;
&lt;li&gt;Which other teams' work depends on this, and what does this depend on?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Shaping happens when needed, not on a fixed schedule. Work entering the system has clear boundaries, identified assumptions, and defined appetites instead of vague user stories.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Betting
&lt;/h3&gt;

&lt;p&gt;Leadership commits resources to shaped work on a business cycle (quarterly, monthly) or on-demand as priorities shift.&lt;/p&gt;

&lt;p&gt;The system maintains a hard separation between two artifacts. The Idea Archive is a PM-managed library of potential pitches sitting outside the development workflow. The Dev Backlog contains only accepted bets, each with a defined appetite and documented assumptions. Work moves from the archive to the backlog only when leadership places a formal bet, keeping the development queue clean.&lt;/p&gt;

&lt;p&gt;Shaping and betting can happen as often as business needs emerge. Priorities can change before developers pull the work, capturing the benefit of short planning cadences while preserving context about which assumptions need testing.&lt;/p&gt;

&lt;p&gt;Bets also give stakeholders the forecast a sprint used to give them. The appetite states what a feature is worth, and it doubles as a time budget. Each appetite is a ceiling. Add up the appetites queued ahead of a feature and divide by how many the WIP limit lets run at once, and the result is the latest that feature should start. That start date holds unless an earlier bet is extended or reshaped, or a new bet is placed ahead of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Circuit Breakers
&lt;/h3&gt;

&lt;p&gt;Each feature has built-in boundaries, both temporal and assumption-based.&lt;/p&gt;

&lt;p&gt;Temporal boundaries are feature-specific time limits: a simple CRUD screen might have a 3-day limit, a complex workflow with integrations might have a 6-week limit, and a research spike might have a 2-week limit.&lt;/p&gt;

&lt;p&gt;A temporal boundary is still a time limit, but it differs from a sprint in what it controls. It belongs to one feature and is sized by that feature's appetite, and it exists to force a reassessment, not to decide what work happens. When it trips, there are no leftover sprint days to fill, because the next shaped item is pulled when capacity opens.&lt;/p&gt;

&lt;p&gt;Assumption boundaries trigger when testing reveals the work is unviable. Critical assumptions defined during shaping get tested during implementation. If testing proves an assumption wrong and requires massive realignment, the circuit breaker trips and the work moves to Failed status for potential reshaping or Dropped status if unworkable.&lt;/p&gt;

&lt;p&gt;When either boundary is hit, anyone building the feature can declare the trip, and work stops at once. The people who placed the bet then decide what happens next: adjust scope, extend, reshape based on what you learned, or drop the work. Stopping never waits for a decision. Only restarting does. An extension is a new bet with a new appetite that competes with the rest of the queue, and it shows on the board, so a quiet slip becomes a visible decision.&lt;/p&gt;

&lt;p&gt;Per-feature boundaries also make failure localized. One feature can trip its circuit breaker while others continue flowing. In a uniform sprint, stopping mid-cycle puts the whole team's commitment in question, creating social pressure to keep going regardless of what you've learned.&lt;/p&gt;

&lt;p&gt;With a per-feature breaker, the stop condition was written down before work began, so stopping carries out the plan rather than abandoning it. The appetite said what the problem was worth. It never promised the team would finish within it, so tripping the breaker is a smaller admission than abandoning a sprint goal. It still takes someone willing to declare it, which is why the failure section below treats tripping as a discipline.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Kanban Flow
&lt;/h3&gt;

&lt;p&gt;Work flows continuously. When capacity opens, pull the next shaped and bet-on feature. The first order of business is testing critical assumptions identified during shaping, not building features, because the earliest moment to catch a wrong assumption is when a ticket is pulled. Then build until done or the circuit breaker trips.&lt;/p&gt;

&lt;p&gt;Work items progress through clear states: Unshaped → Shaped → Accepted → Active → Completed, Failed, or Dropped. Progress tracking uses hill charts from Shape Up. Work is either uphill (still figuring it out) or downhill (executing on known work). This avoids the useless "80% done" claims that plague sprint burndowns.&lt;/p&gt;

&lt;p&gt;A sprint boundary forces an inspection every few weeks. A six-week appetite has no such calendar checkpoint, by design. The assumption tests at the start and a hill chart stuck uphill do the job a sprint boundary was meant to do, and they raise the warning sooner.&lt;/p&gt;

&lt;p&gt;WIP limits and circuit breakers work together. WIP limits constrain how many features run simultaneously, while circuit breakers bound how long any individual feature can run.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Done" means the feature delivers the agreed value, period. Not "the sprint ended so we call it done."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  5. Technical Debt Without a Cooldown
&lt;/h3&gt;

&lt;p&gt;Shape Up's two-week cool-down between cycles handles work like technical debt and exploratory spikes that don't fit a formal pitch. Shaped Kanban removes fixed cycles entirely, which creates a real gap if left unaddressed.&lt;/p&gt;

&lt;p&gt;Small items too minor to pitch, like a dependency bump or a half-day refactor, travel with the active feature that touches that code, inside its appetite, so shaping leaves room for them in scope. For anything larger, the answer is to reframe technical work so it can compete at the betting table. Every meaningful technical work item falls into one of three categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Corrections&lt;/strong&gt;: mistakes actively hurting the business now, including security vulnerabilities, bugs driving churn, and reliability failures violating SLAs. These are risk mitigation bets, not engineering housekeeping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimizations&lt;/strong&gt;: improvements that translate directly into margin or efficiency, like reducing cloud spend, speeding page loads, or automating deployments. With numbers attached, they compete well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-Alignments&lt;/strong&gt;: work that unlocks future capabilities the roadmap depends on. Making the dependency explicit changes the conversation, since leadership is already implicitly betting on this work when they approve downstream features.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Technical debt framed as engineering work gets deprioritized. Framed as business risk, margin opportunity, or roadmap prerequisite, it can win bets. This demands more rigor than Shape Up's cool-down, but architectural health becomes visible and competes on the same terms as every other bet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Team Coordination Through Explicit Dependencies
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Multi-team coordination is hard. Shaped Kanban doesn't pretend otherwise.&lt;/strong&gt; Scale deserves an honest answer.&lt;/p&gt;

&lt;p&gt;Shape Up emerged from Basecamp's own product development, refined over years as the company grew from four people to over fifty. My own experience with Shaped Kanban at scale sits in that same range: three medium-sized teams working on the same broad system. I have also worked in organizations running Scaled Scrum. That is enough to project how the two approaches compare as teams multiply, though I won't claim confidence about enterprise-level deployments I haven't run.&lt;/p&gt;

&lt;p&gt;The most significant dynamic at scale is continuation bias. I expect Scrum's greatest weakness to get worse as teams multiply. Scrum as commonly run operates on collective abstractions like sprint commitments, velocity charts, and planning poker (the last two aren't in the Scrum Guide, but they usually come with it). None of these give the people building a feature a pre-agreed trigger to stop it, and you cannot fix the collective without first empowering the individual.&lt;/p&gt;

&lt;p&gt;Shaped Kanban's core mechanisms work at the level of the individual feature. Its appetite and documented assumptions give the people building it an explicit, pre-agreed trigger to stop, without waiting for a team-wide ceremony or a collective decision. Leadership decides what happens after the stop, not whether it happens. That is the lever I would reach for to prevent bad assumptions from propagating across a multi-team program.&lt;/p&gt;

&lt;p&gt;Cross-team coordination in Scaled Scrum typically flows through the Scrum of Scrums meeting. But Scrum of Scrums is just another timeboxed event. It begins, it ends, and everyone returns to their sprint. It doesn't build an ongoing, async coordination discipline that is informed and purpose-driven. Scrum teams can write dependencies down too, but nothing in the sprint process requires it.&lt;/p&gt;

&lt;p&gt;In Shaped Kanban, dependencies are explicit from the shaping stage. When one team's circuit breaker trips, the shaped work already lists the downstream bets that depend on it, and those teams hear at once rather than at the next sync meeting. Each team understands how their work connects to the broader program and why, which makes async coordination practical because the context is already documented rather than locked inside a recurring meeting.&lt;/p&gt;

&lt;p&gt;Scrum's rigidity also tends to produce a common breakdown at scale: a small number of individuals, usually dev leads or architects, silently absorb all the cross-team coordination the process doesn't account for. They become the informal connective tissue holding the program together while everyone else follows the sprint. Shaped Kanban moves that load into shaping. Senior people still carry it, but the dependencies they find are written into the shaped work and visible to every team. They stop living in a few people's heads and inside ceremonies that can't actually handle them.&lt;/p&gt;

&lt;p&gt;The genuine challenge is visibility. Shaped Kanban at scale requires roadmaps that are explicit about dependencies between epics and features. You cannot hide behind sprint abstractions and hope the pieces fit together.&lt;/p&gt;

&lt;h2&gt;
  
  
  How This Approach Can Fail
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shaped Kanban can be abused just like Kanban can be abused.&lt;/strong&gt; The flexibility that makes it powerful also creates opportunities for dysfunction if discipline erodes.&lt;/p&gt;

&lt;p&gt;How This Approach Can Fail&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WIP limits must actually limit work.&lt;/strong&gt; If WIP limits become suggestions rather than constraints and teams allow constant disruptions and context switching, the framework collapses into chaos. Kanban without strict WIP limits is just a glorified to-do list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Priorities must be honored.&lt;/strong&gt; Bet on work you know has high value, deliver the original priorities, and let the circuit breakers do their job. If every urgent request bypasses the queue or priorities shift weekly, you are practicing reactive chaos with a Kanban board.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Circuit breakers must trip.&lt;/strong&gt; When work hits its time boundary or invalidates critical assumptions, stop and reassess. Do not extend deadlines reflexively. If you never let circuit breakers trip, they are theater.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flexibility requires discipline.&lt;/strong&gt; Without discipline, you have just removed the one forcing function that timeboxes provided while keeping all the dysfunction: poorly defined work, shifting priorities, and endless scope creep.&lt;/p&gt;

&lt;p&gt;Timeboxes enforce rhythm mechanically while Shaped Kanban requires you to enforce rhythm through actual alignment and genuine agreement. When that agreement slips, it fails more visibly, because continuing past an appetite takes a new bet rather than a quiet carryover. Enforcing rhythm through agreement is harder than letting a calendar do it, so the willingness to change comes first.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Consider This Approach
&lt;/h2&gt;

&lt;p&gt;If your organization struggles with timeboxes fragmenting work, teams forced into artificial synchronization, or discovery invalidating sprint commitments, Shaped Kanban might help.&lt;/p&gt;

&lt;p&gt;It is the wrong move for a team without senior people who can shape work, or under leadership that won't honor its bets. A shared sprint cadence supplies a forcing function those teams can't yet supply for themselves.&lt;/p&gt;

&lt;p&gt;Shaped Kanban requires these disciplines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Define work with clear boundaries before committing&lt;/li&gt;
&lt;li&gt;Start work with uncertainty, knowing you can stop and realign when assumptions break&lt;/li&gt;
&lt;li&gt;Commit to specific outcomes with understood scope&lt;/li&gt;
&lt;li&gt;Build until done or until constraints force reassessment&lt;/li&gt;
&lt;li&gt;Measure whether you delivered value, not just whether you shipped&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not a perfect solution. Continuous flow across teams demands more intentional coordination than synchronized sprints. But I will take that tradeoff every time. Explicit coordination makes dependencies intentional, while synchronized sprints leave them to hope until the next boundary.&lt;/p&gt;

&lt;p&gt;Rhythm and tempo come from alignment and natural feature boundaries, not predetermined calendars. You cannot iterate toward value without agreement on what constitutes value. Shaped Kanban makes that agreement explicit, visible, and continuous, without requiring everyone to march to the same drumbeat.&lt;/p&gt;

</description>
      <category>sdlc</category>
      <category>agile</category>
      <category>kanban</category>
      <category>scrum</category>
    </item>
    <item>
      <title>Reporting and Production Make Terrible Roommates</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Wed, 11 Mar 2026 14:03:38 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/reporting-and-production-make-terrible-roommates-30m7</link>
      <guid>https://dev.to/stevenstuartm/reporting-and-production-make-terrible-roommates-30m7</guid>
      <description>&lt;p&gt;I tend to think of reporting and production as incompatible roommates. They need the same space, they optimize for completely different things, and every accommodation one makes for the other is a debt that gets called in later. The production schema is usually what accumulates the most debt and gets hit the hardest.&lt;/p&gt;

&lt;p&gt;Consider what happens when the analytics team asks for a denormalized &lt;code&gt;order_summary&lt;/code&gt; view on the production database so their dashboards load faster. The DBA obliges, adds a materialized view, and now every schema migration has to account for it. Six months later the application team wants to split the &lt;code&gt;orders&lt;/code&gt; table into &lt;code&gt;orders&lt;/code&gt; and &lt;code&gt;order_line_items&lt;/code&gt;. But the view is embedded in 10 dashboard queries and a nightly export job, and most of those queries also join it back to columns on &lt;code&gt;orders&lt;/code&gt; directly. Redefining the view over the new tables is the easy part. Rewriting and revalidating a dozen queries the application team doesn't own is not, so the refactor stalls, and the production schema fossilizes around a reporting concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reporting Tends to Win Every Schema Negotiation
&lt;/h2&gt;

&lt;p&gt;The cause is structural. A transactional schema optimizes for write consistency, referential integrity, and the access patterns of the application that owns it. A reporting schema optimizes for read throughput, aggregation, and the access patterns of analysts and dashboards. When both share a schema, every design decision becomes a negotiation.&lt;/p&gt;

&lt;p&gt;Once dashboards reach executives or exports reach outside consumers, reporting tends to win that negotiation, because saying no gets more expensive with every dashboard that already depends on a yes. Its requests arrive one at a time as small additions (a column, an index, a view), each cheap to accept and expensive to remove. A refactor's payoff is deferred, while a broken dashboard is visible to leadership the same morning.&lt;/p&gt;

&lt;p&gt;Not every system needs a separation on day one. A small team with a single database, low reporting complexity, and a schema that's still fluid can query production directly without meaningful friction. But this fossilization is predictable. It emerges when reporting consumers multiply, when dashboards become load-bearing, and when schema changes require cross-team coordination.&lt;/p&gt;

&lt;p&gt;Architects who recognize this trajectory can keep the door open for separation without building the full pipeline prematurely. That means resisting the urge to denormalize production schemas for reporting convenience, and keeping reporting access patterns from becoming implicit contracts on the production schema. When the separation does happen, it can be reactive, tapping into what the database already captures, or intentional, making the application responsible for producing reporting-quality records in the write path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reactive Separation Reuses What the Database Already Captures
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A Dedicated Reporting Replica Isolates Load, Not Schema
&lt;/h3&gt;

&lt;p&gt;The simplest place to start is to point reporting tools at a read replica of the production DB. Many teams already have replicas for distributing query load, and so dedicating one to reporting keeps analytical queries from competing with production traffic. No new infrastructure, no async pipeline, no application code changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌─────────────┐         ┌─────────────────┐
  │ Application │────────&amp;gt;│  Production DB   │
  └─────────────┘  writes │  (Primary)       │
                          └────────┬─────────┘
                                   │ replication
                                   v
                          ┌─────────────────┐
                          │  Read Replica    │
                          │  (Same Schema)   │
                          └────────┬─────────┘
                                   │ direct queries
                          ┌────────┴─────────┐
                          │  BI / Dashboards  │
                          └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a feasible fit when reporting needs are straightforward and the production schema is close enough to what reporting consumers need. It also assumes data that's a few seconds stale is acceptable. "A few seconds stale" is the optimistic case, though. Heavy analytical queries on the replica can cause replication lag to spike well beyond that, especially during peak reporting windows.&lt;/p&gt;

&lt;p&gt;The isolation is partial, too. On a PostgreSQL standby, a long report can block the replica from applying changes. The replica then either cancels the report or, with &lt;code&gt;hot_standby_feedback&lt;/code&gt; on, stops the primary from cleaning up dead rows. Reporting indexes land on production too, because a replica can't hold indexes of its own.&lt;/p&gt;

&lt;p&gt;The replica breaks down when reporting needs diverge far enough from the production schema's shape. Reporting consumers write increasingly complex queries with multiple joins, or they start requesting schema changes to production to make their queries simpler, which is exactly the distortion this post is about.&lt;/p&gt;

&lt;p&gt;The replica can't capture intermediate transitions either. Reporting queries see whatever state the database holds when they run, so if a record changes twice between one query and the next, the intermediate state is invisible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change Data Capture: State History Without Intent
&lt;/h3&gt;

&lt;p&gt;CDC tools like Debezium tap the database's transaction log and emit changes as events. The application writes normally to whatever schema makes sense, and CDC streams those changes to a separate store. Unlike the replica approach, CDC captures every intermediate state change because it reads from the transaction log rather than querying current state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌─────────────┐         ┌─────────────────┐
  │ Application │────────&amp;gt;│  Production DB   │
  └─────────────┘  writes └────────┬─────────┘
                                   │ transaction log
                                   v
                          ┌─────────────────┐
                          │  CDC Connector   │
                          │  (e.g. Debezium) │
                          └────────┬─────────┘
                                   │ change events
                                   v
                          ┌─────────────────┐
                          │  Stream / Queue  │
                          │  (Kafka, Kinesis)│
                          └────────┬─────────┘
                                   │
                     ┌─────────────┴─────────────┐
                     v                           v
            ┌────────────────┐          ┌────────────────┐
            │  Transform (T) │          │  Schema        │
            │  Reshape/Join  │          │  Registry      │
            └───────┬────────┘          └────────────────┘
                    v
            ┌────────────────┐
            │ Reporting Store│
            │ (Warehouse/DL) │
            └────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CDC's greatest strength is that it requires no application code changes and no new abstractions in the write path. That makes it often the only viable option for legacy systems where changing the write path is too risky, or for teams that need separation now and can't modify every service that writes data. It also tends to deliver complete payloads, because the log usually carries the full new row even when the application updated a single field.&lt;/p&gt;

&lt;p&gt;The first limitation is semantic. CDC events originate from the database layer, so they capture &lt;em&gt;what&lt;/em&gt; changed but not &lt;em&gt;why&lt;/em&gt; it changed. A row update that represents a customer canceling an order looks identical to a row update that represents a system correcting a data entry error.&lt;/p&gt;

&lt;p&gt;The obvious workaround is to add columns like &lt;code&gt;cancelled_by&lt;/code&gt; and &lt;code&gt;cancel_reason&lt;/code&gt;. They record the why only when the application itself needs them, because then they're domain state and CDC carries them. Adding them only for reporting is the distortion this post started with. And some intent has no natural row to live on at all, such as a correction or a change spanning several entities. For financial ledgers or audit-critical workflows, where intent is as important as state, the write path has to record the intent itself, which is what the outbox and event sourcing below do.&lt;/p&gt;

&lt;p&gt;The second limitation is the absence of a contract boundary. The table structure &lt;em&gt;is&lt;/em&gt; the contract, implicitly. When that schema changes, nothing fails at build time. The CDC pipeline either silently emits differently shaped events or breaks at runtime, and reporting consumers discover the problem in production rather than in development.&lt;/p&gt;

&lt;p&gt;Two mitigations narrow that gap without closing it. A transformation layer that maps raw tables onto a stable reporting model, such as warehouse staging models, shrinks the repair after a schema change to one place. But the reporting side owns that layer, so it learns of production changes only after they ship. A schema registry can partially close this gap by rejecting incompatible schema versions when the pipeline tries to register them, but that's added infrastructure catching incompatibility at runtime rather than at build time.&lt;/p&gt;

&lt;p&gt;The third limitation is database dependency. CDC is only as good as the log the database exposes. PostgreSQL's logical decoding, MySQL's binlog, and DynamoDB Streams are mature options. But a managed service that restricts log access, or a stream that retains changes only briefly (DynamoDB Streams keeps 24 hours), can push teams toward application-layer alternatives earlier than expected.&lt;/p&gt;

&lt;p&gt;Reading the log also loads the primary. Logical decoding adds write-ahead log volume to every write. A PostgreSQL replication slot also holds that log on the primary until the connector consumes it, so a stalled CDC pipeline can fill the production database's disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Intentional Separation Captures Intent in the Write Path
&lt;/h2&gt;

&lt;p&gt;Reactive approaches separate the workload but not the context. They capture &lt;em&gt;what&lt;/em&gt; changed, not &lt;em&gt;who&lt;/em&gt; changed it or &lt;em&gt;why&lt;/em&gt;. That context exists at the application layer when the write happens, and it's lost the moment the data hits the database unless someone deliberately captures it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Outbox Pattern Makes the Contract Explicit
&lt;/h3&gt;

&lt;p&gt;The outbox pattern makes the application responsible for producing reporting-quality records. Instead of letting the database schema define the downstream contract implicitly, the application writes a versioned record to an outbox table within the same database transaction as the domain state change. Either both commit or neither does. A separate process reads from the outbox and projects into whatever reporting store analytics needs. That relay can crash after publishing and before marking a record published, so delivery is at least once, and consumers deduplicate on the event ID. The application controls the payload shape, the versioning, and the context included in each record.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌─────────────┐
  │ Application │
  └──────┬──────┘
         │ single transaction
         v
  ┌──────────────────────────────────────┐
  │          Production DB               │
  │                                      │
  │  ┌──────────────┐  ┌──────────────┐  │
  │  │ Domain Table  │  │ Outbox Table │  │
  │  │ (orders,      │  │ (versioned   │  │
  │  │  customers)   │  │  records)    │  │
  │  └──────────────┘  └──────┬───────┘  │
  └───────────────────────────┼──────────┘
                              │ poll / stream
                              v
                     ┌─────────────────┐
                     │ Relay Process   │
                     │ (reads outbox)  │
                     └────────┬────────┘
                              │ publish
                              v
                     ┌─────────────────┐
                     │ Reporting Store │
                     └─────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Outbox table via a relational database:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;        &lt;span class="n"&gt;UUID&lt;/span&gt;          &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  &lt;span class="c1"&gt;-- globally unique, used downstream&lt;/span&gt;
    &lt;span class="n"&gt;aggregate_type&lt;/span&gt;  &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- e.g. 'Order', 'Customer'&lt;/span&gt;
    &lt;span class="n"&gt;aggregate_id&lt;/span&gt;    &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- e.g. order ID&lt;/span&gt;
    &lt;span class="n"&gt;event_type&lt;/span&gt;      &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- e.g. 'OrderCancelled'&lt;/span&gt;
    &lt;span class="n"&gt;schema_version&lt;/span&gt;  &lt;span class="nb"&gt;INT&lt;/span&gt;           &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- contract versioning&lt;/span&gt;
    &lt;span class="n"&gt;occurred_at&lt;/span&gt;     &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;   &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;initiated_by&lt;/span&gt;    &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;            &lt;span class="c1"&gt;-- who: user ID, system name&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt;          &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;            &lt;span class="c1"&gt;-- why: 'customer_request', 'admin_override'&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;         &lt;span class="n"&gt;JSONB&lt;/span&gt;         &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- full state snapshot + context&lt;/span&gt;
    &lt;span class="n"&gt;published&lt;/span&gt;       &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Outbox record via a Kinesis/Kafka stream (JSON envelope):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1b2c3d4-e5f6-7890-abcd-ef1234567890"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggregateType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggregateId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ORD-20260218-4417"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eventType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OrderCancelled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schemaVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occurredAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-02-18T14:32:08.771Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"initiatedBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user:jsmith"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer_request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ORD-20260218-4417"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"customerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CUST-8821"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"previousStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Confirmed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"newStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cancelled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lineItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"totalAmount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;284.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outbox has an explicit, versionable contract boundary. A breaking change to the outbox record is a deliberate code change that goes through review. If a developer renames a column in the production schema, the outbox record doesn't change unless someone deliberately updates it. A contract test in CI fails the build when the record's shape changes, before any dashboard breaks. Unlike a test on CDC tables, it lives in code its owners already test. The outbox table itself is a reporting concern in the production database, but domain tables never read or join it. Changing a domain table changes only the code that maps it into the record.&lt;/p&gt;

&lt;p&gt;Because a polling relay doesn't rely on transaction log capabilities or vendor-specific change feed APIs, any database that supports transactions supports the pattern. A relay built on CDC, such as Debezium's outbox event router, inherits CDC's operational risks but not its implicit contract.&lt;/p&gt;

&lt;p&gt;The outbox does not require a record for every database write. It only fires when a specific entity type has a meaningful state change, and only for the types that reporting cares about. Background jobs updating internal timestamps produce nothing. A deletion does produce a record, because knowing that an entity was removed and who removed it is itself meaningful. This keeps the coupling concentrated in write paths that produce meaningful state transitions, not spread across every query and update in the codebase.&lt;/p&gt;

&lt;p&gt;That selectivity is also the outbox's main risk. A bulk admin script or hand-written SQL fix that changes an order's status without writing to the outbox leaves reporting silently incomplete. CDC can't miss a change that way, because it reads every committed change. Emitting the record from the domain model's state transition closes most of that gap, and out-of-band SQL against tracked tables has to write its own outbox record.&lt;/p&gt;

&lt;p&gt;The outbox is my recommendation for teams that have outgrown a read replica, need reporting to know who acted and why, and don't need full event sourcing.&lt;/p&gt;

&lt;h3&gt;
  
  
  CQRS and Event Sourcing Keep Every Transition at the Highest Cost
&lt;/h3&gt;

&lt;p&gt;CQRS (Command Query Responsibility Segregation) formally separates the write model from the read model. The write side accepts commands and persists state. The read side maintains whatever views consumers need. The two sides don't have to share a schema, or even storage. What CQRS adds is the explicit acknowledgment that changing state and reading it are different jobs that can deserve different models. CQRS does not require event sourcing. Its write side can persist state normally in a traditional database.&lt;/p&gt;

&lt;p&gt;Event sourcing takes this further by changing what the write side stores. Instead of persisting current state and producing reporting records alongside it, every state mutation is recorded as an immutable event, and current state is derived by replaying those events. The event log becomes the source of truth, not the current snapshot. Every transition is preserved in the order it occurred. Production state is a projection of the event stream, and so is reporting state, and so is any other view you need. If the analytics team changes their requirements six months from now, you replay the same events through a new projection and the full history is there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                          Commands
                              │
                              v
                     ┌─────────────────┐
                     │   Write Side    │
                     │ (Command Handler│
                     │  + Aggregates)  │
                     └────────┬────────┘
                              │ append events
                              v
                     ┌─────────────────┐
                     │  Event Store    │
                     │  (append-only)  │
                     └────────┬────────┘
                              │ project
                              v
                     ┌─────────────────┐
                     │  Projected      │
                     │  State Tables   │
                     │  (per entity)   │
                     └────────┬────────┘
                              │ query (read side)
              ┌───────────────┼───────────────┐
              v               v               v
     ┌────────────┐  ┌─────────────┐  ┌────────────────┐
     │  Prod API  │  │  Reporting  │  │  Audit /       │
     │  Queries   │  │  Store      │  │  Compliance    │
     └────────────┘  └─────────────┘  └────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Event store document (append-only, NoSQL):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"streamId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Order-ORD-4417"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eventType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OrderCancelled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"occurredAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-02-18T14:32:07Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"initiatedBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user:jsmith"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer_request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"previousStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Confirmed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"newStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cancelled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lineItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"totalAmount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;284.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"correlationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req-88a1c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"causationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cmd-cancel-4417"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user:jsmith"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Projected state table (derived from events, used by reporting/ETL):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;order_projections&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;         &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt;      &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;current_status&lt;/span&gt;   &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;item_count&lt;/span&gt;       &lt;span class="nb"&gt;INT&lt;/span&gt;          &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total_amount&lt;/span&gt;     &lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;currency&lt;/span&gt;         &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;placed_at&lt;/span&gt;        &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;shipped_at&lt;/span&gt;       &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cancelled_at&lt;/span&gt;     &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cancelled_by&lt;/span&gt;     &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;cancel_reason&lt;/span&gt;    &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;last_event_pos&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt;          &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- tracks replay position&lt;/span&gt;
    &lt;span class="n"&gt;projected_at&lt;/span&gt;     &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The projected table above is what reporting reads in practice. Reporting consumers rarely subscribe to the event stream directly, which keeps the stream internal to the domain. A current-state projection like &lt;code&gt;order_projections&lt;/code&gt; keeps only the latest cancellation. When reporting needs history, the domain team that owns the projections builds a history-shaped one, such as a status-transition table.&lt;/p&gt;

&lt;p&gt;This is a good fit for domains where the complete history of state transitions is genuinely valuable, like financial ledgers, audit-critical workflows, or systems where "undo" and "replay" are first-class requirements.&lt;/p&gt;

&lt;p&gt;Most teams should not reach for this combination. Martin Fowler's CQRS article warns that most of the CQRS cases he has run into have not gone well. Many systems fit a CRUD mental model and should stay that way. CQRS should only apply to specific bounded contexts where the read and write access patterns are genuinely different, not across entire applications. Event sourcing compounds the cost. Events are immutable and permanent, so schema design requires careful thought. Aggregate replay gets expensive without snapshotting, and debugging production issues means reasoning about event sequences rather than inspecting current state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing an Approach by What Reporting Needs to Know
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Use when&lt;/th&gt;
&lt;th&gt;Key limitation&lt;/th&gt;
&lt;th&gt;Complexity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read replica&lt;/td&gt;
&lt;td&gt;Schema shape is close to what reporting needs; slight staleness is acceptable&lt;/td&gt;
&lt;td&gt;Replication lag under analytical load; no intermediate state history&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDC (e.g. Debezium)&lt;/td&gt;
&lt;td&gt;Write path can't be modified; full row history is needed&lt;/td&gt;
&lt;td&gt;Captures state, not business intent; table structure is an implicit contract&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbox pattern&lt;/td&gt;
&lt;td&gt;Business context (who/why) matters; write path can be modified&lt;/td&gt;
&lt;td&gt;A write path that skips the outbox silently drops data; relay process and versioning discipline needed&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CQRS + Event Sourcing&lt;/td&gt;
&lt;td&gt;Audit-critical domain; "undo" and "replay" are first-class requirements&lt;/td&gt;
&lt;td&gt;Events are permanent; aggregate replay is expensive without snapshotting&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Separate Early or Pay Later
&lt;/h2&gt;

&lt;p&gt;Separating early means separating the access path before dashboards depend on base tables, not building a pipeline. The first request for a reporting-only column or view on production is the cue. Every shortcut that ties these workloads together makes the eventual separation harder. The analytics team and the application team end up negotiating every schema change, and the production schema accumulates the concerns of whoever complained most recently.&lt;/p&gt;

&lt;p&gt;A read replica is enough to start, but only if dashboards query plain views rather than base tables. Those views live in a reporting schema the application team owns and can redefine, with grants that reach nothing else. Migrations still update the views, but now the team running the migrations owns them. When those views get too slow or too costly to redefine, move past the replica, to CDC if reporting needs only what changed and to an outbox if it needs why.&lt;/p&gt;

&lt;p&gt;Whichever step comes next, reporting reads an owned surface rather than raw tables, whether that's views on the replica, a transformation layer on CDC, or an outbox record. That owned surface keeps the schema from freezing, and the separate store keeps reporting load off production.&lt;/p&gt;

&lt;p&gt;Separation doesn't remove the coupling, but it gives the coupling an owner. With an outbox, the application team owns the record and its versions, and the analytics team owns the projection built from it. New questions about who or why still reach the application team, but as a reviewed change to a versioned record, and older records won't carry the new context. A production schema change that leaves the record's version intact ships without asking the analytics team.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>designpatterns</category>
      <category>database</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>How One Screen Holds the Entire Industry Hostage</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Mon, 23 Feb 2026 21:53:28 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/how-one-screen-holds-the-entire-industry-hostage-5fi0</link>
      <guid>https://dev.to/stevenstuartm/how-one-screen-holds-the-entire-industry-hostage-5fi0</guid>
      <description>&lt;p&gt;Frameworks like React Native, Flutter, and MAUI keep promising to end the "write it twice" problem across mobile platforms. One codebase, every platform, native-quality results. Yet every time, the abstraction leaks. Then it floods so fast that bailing water is all you have time to do. I've been working with MAUI recently, and the experience crystallized a question I should have asked sooner: why am I not just building a website?&lt;/p&gt;

&lt;p&gt;Once you pull that thread, it unravels fast. The web platform can do far more than the industry acknowledges. Most of what prevents universal web adoption is inertia, business incentives, or mental models rather than limits in the technology. On phones, the hardest of those limits trace back to one company's control of one screen.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The web can do the job. One company made sure you'd never trust it to.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This isn't an argument that native apps are obsolete or that local executables should disappear. There are good reasons to run code on your own hardware, and the pure thin-client terminal hasn't arrived yet. Maybe it shouldn't. But when teams default to native without questioning it, they accept costs and constraints on the client side that the backend abandoned years ago when it moved to the cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Web Platform Can Actually Do
&lt;/h2&gt;

&lt;p&gt;For the typical business application, on a phone, tablet, or desktop, the web platform already covers the core requirements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Web Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Offline support&lt;/td&gt;
&lt;td&gt;Service Workers, Cache API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push notifications&lt;/td&gt;
&lt;td&gt;Push API (iOS 16.4+, March 2023)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Camera, microphone, biometrics&lt;/td&gt;
&lt;td&gt;getUserMedia, WebAuthn/Passkeys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment processing&lt;/td&gt;
&lt;td&gt;Payment Request API (includes Apple Pay)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Home screen installation&lt;/td&gt;
&lt;td&gt;Web App Manifest, standalone window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU-accelerated graphics and compute&lt;/td&gt;
&lt;td&gt;WebGPU (all major browsers, Nov 2025)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peripheral device access&lt;/td&gt;
&lt;td&gt;WebUSB, WebSerial, WebBluetooth, WebHID (Chromium)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local file access&lt;/td&gt;
&lt;td&gt;File System Access API, Origin Private File System&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Near-native performance&lt;/td&gt;
&lt;td&gt;WebAssembly, Web Workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time communication&lt;/td&gt;
&lt;td&gt;WebRTC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most rows work in Safari on iOS today. The peripheral APIs and full local file access are Chromium-only. iOS delivers push only to a web app the user has added to the home screen, with no install prompt to help them do it. Safari also clears a site's stored data after seven days of Safari use without a visit unless it's installed, as WebKit's own blog documents. Feel is the other gap, since Safari's keyboard, viewport, and standalone-mode quirks take deliberate work, and they're Apple's to fix.&lt;/p&gt;

&lt;p&gt;That list covers what most apps do. Most are thin clients over an API: authenticate a user, fetch data, display it, let the user interact with it. The web handles all of that with a single codebase on every platform with a browser, and the deployment model alone should give teams pause. No App Store review cycles, no waiting days for a critical bug fix to clear approval, no separate release pipelines for each platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Genuinely Requires Native
&lt;/h2&gt;

&lt;p&gt;These capabilities have no web equivalent.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wearable integration and health data&lt;/strong&gt; like Apple Watch complications, Wear OS tiles, HealthKit, and Google Health Connect require platform SDKs with no web alternative&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced augmented reality&lt;/strong&gt; using LiDAR scanning, scene understanding, and body tracking exceeds what WebXR currently offers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deep OS integration&lt;/strong&gt; like Siri Shortcuts, Google Assistant routines, home screen widgets, and inter-app communication remains outside the web's reach&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;True background processing&lt;/strong&gt; for geofencing, long-running background jobs, and persistent location tracking requires native APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specific hardware access&lt;/strong&gt; like NFC writing on iOS, advanced camera controls, and screenshot blocking are native-only capabilities&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This list is relevant, but it's also narrow. Look at the apps on your phone and the software on your desktop, and count how many actually need any of these features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Platform Frameworks Are the Wrong Answer
&lt;/h2&gt;

&lt;p&gt;Cross-platform frameworks don't eliminate the two-codebase problem; they disguise it. React Native's JavaScript-to-native layer, Flutter's rendering engine, and MAUI's handler pattern each introduce their own category of bugs that don't exist in either native platform. You haven't removed the platform differences; you've added a third abstraction layer and still debug the two platforms underneath it.&lt;/p&gt;

&lt;p&gt;The tech debt is unprojectable because you don't control the framework's roadmap. When Apple changes iOS, you wait for the framework to catch up. When the framework ships breaking changes, you're locked into an unplanned upgrade. When a critical bug sits in the issue tracker for months, your only options are workarounds or forks.&lt;/p&gt;

&lt;p&gt;The original justification was that specialized native developers are expensive, so share code to reduce cost. AI code generation has lowered much of that barrier. A competent developer with AI assistance can get much further in Swift or Kotlin without years of platform experience, though lifecycle and accessibility details still take expertise. All the original disadvantages of cross-platform remain, and cheaper native still means two QA passes and two release pipelines. That duplication is an argument for the web, not for a framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Microsoft Opened Its Stack While Apple Closed Its Screen
&lt;/h2&gt;

&lt;p&gt;In the early 2000s, Microsoft was the villain. They owned the desktop, the browser, the runtime, and the development tools, and the DOJ antitrust case settled in 2001 was about exactly this: using a Windows monopoly to crush Netscape. Apple was the scrappy alternative making beautiful things for creative people, and when the iPhone launched in 2007 it felt like liberation from the carrier-controlled mobile landscape.&lt;/p&gt;

&lt;p&gt;Microsoft lost mobile and Windows 8 alienated more and more desktop users. Their response was to stop trying to own the screen and instead to compete on the stack. .NET went open source, Visual Studio Code became the most-used editor in Stack Overflow's developer survey, and they acquired GitHub and kept it open. The company that once tried to kill Linux now ships a Linux kernel inside Windows.&lt;/p&gt;

&lt;p&gt;Apple went the other direction. When the iPhone became the dominant computing device, Apple discovered what Microsoft had known in the 1990s: if you control the platform people depend on, you don't have to compete on openness. You compete on control.&lt;/p&gt;

&lt;p&gt;I write .NET code for a living and I choose to do it on a Mac because the experience is genuinely better. Notice what that reveals about both companies though. Microsoft made it possible by building .NET and VS Code to run everywhere. Try the reverse: building an iOS app without a Mac, submitting to the App Store without Xcode, running Swift on Windows with the same support .NET has on macOS. You can't. Microsoft earns developers by being useful everywhere. Apple captures them by being mandatory for anyone who ships to an iPhone.&lt;/p&gt;

&lt;p&gt;Apple's products deserve their loyalty. The Mac is excellent, the ecosystem integration is seamless, and users trust the brand for good reasons. That trust is exactly what makes the constraint on the iPhone so effective. When a company makes products this good, people don't scrutinize the walls. They assume the walls exist for good reasons.&lt;/p&gt;

&lt;p&gt;But look at what Apple controls versus what they build. Siri has been outperformed by competitors for over a decade, and it doesn't matter because Siri doesn't need to be good; it needs to be on the iPhone. Owning the screen means you don't have to be the best at anything that runs on it. You just need to be good enough at the thing people hold, and everything else flows through you.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Apple doesn't compete on technology. They compete on constraint ownership. The phone is the aperture, and Apple controls the aperture.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Walls Apple Built
&lt;/h2&gt;

&lt;p&gt;Apple's walls around iOS are higher than anything Microsoft built around Windows in the 1990s, and they're more sophisticated because they're framed as user protection rather than vendor control. Windows in the 1990s never stopped anyone from installing Netscape's engine.&lt;/p&gt;

&lt;p&gt;Outside the EU and Japan, every browser on iOS must use Apple's WebKit rendering engine. Chrome on your iPhone isn't really Chrome. It's a WebKit skin with Chrome's UI on top. Firefox, Edge, Brave: all WebKit underneath. So Apple alone controls what web capabilities exist on every iOS device, regardless of which browser icon a user taps.&lt;/p&gt;

&lt;p&gt;On Chrome and Android, web apps can access APIs unavailable on any iOS browser, including Bluetooth, NFC, Background Sync, USB, and serial devices. Web push reached Chrome on Android in 2015 and iOS in March 2023. On Android, any website can request push permission.&lt;/p&gt;

&lt;p&gt;In June 2020, as ZDNet reported, Apple declined to implement 16 Web APIs, citing "privacy and fingerprinting concerns." The concern isn't baseless, since a web page runs from any link while a native app gets Bluetooth and NFC only after store review and a deliberate install. Mozilla shares some of it. What sets Apple apart is reach. Firefox declining an API doesn't keep it off anyone's phone, because Chrome on Android ships these APIs behind permission prompts. Apple's refusal is the only one that binds every browser on the device.&lt;/p&gt;

&lt;p&gt;The EU's Digital Markets Act forced Apple to permit other browser engines in 2024, and the response was revealing. Alongside that change, Apple attempted to remove PWA support entirely in the EU, converting installed web apps into simple bookmarks. Their justification was "complex security and privacy concerns." After an open letter from Open Web Advocacy gathered thousands of signatures and the European Commission sent formal inquiries, Apple reversed the decision within two weeks, as The Register reported. Home-screen apps came back still running only on WebKit.&lt;/p&gt;

&lt;p&gt;Japan's smartphone law added the same requirement in December 2025, per Apple's developer page on changes to iOS in Japan. Even with engine choice required, as of early 2026 zero browsers have shipped a non-WebKit engine on iOS in the EU, according to Open Web Advocacy. The regulation exists on paper. The monopoly persists in practice.&lt;/p&gt;

&lt;p&gt;The App Store generated approximately $27 billion in global commissions in 2024 on cuts of 15 to 30%, per Appfigures data reported by TechCrunch. A web capable enough to replace paying apps would let them leave the store, so the web stays capped. Free business tools pay Apple nothing, but they live under the same cap. The U.S. Department of Justice drew this connection in its March 2024 antitrust lawsuit, whose complaint cites the WebKit requirement as part of Apple's monopoly maintenance, as Open Web Advocacy's summary quotes.&lt;/p&gt;

&lt;p&gt;Android doesn't have these restrictions. But it doesn't matter. Few product leaders will ship something that doesn't work on iPhones, because iPhone owners are too large and too valuable a share of most Western markets to give up. The most constrained major platform sets the ceiling for what anyone builds. Once a team must build an iOS app anyway, a matching Android app is the path of least resistance. The web rarely gets a fair trial on Android either, because the decision was made on iOS's terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Circular Logic of "Users Prefer Native"
&lt;/h2&gt;

&lt;p&gt;The most common justification for building native apps is market data, like eMarketer's, showing that users spend 88-92% of their mobile time in apps and only 8-12% in browsers.&lt;/p&gt;

&lt;p&gt;Much of that time goes to social, messaging, video, and games, so it says little about a typical business app. As evidence of preference, it's circular reasoning dressed up as market research. Of course the native experience retains users better. The web version usually received a fraction of the investment, because teams don't fund a channel the platform caps. You cannot measure user preference when one option was deliberately hobbled by the platform owner and underfunded by the developer.&lt;/p&gt;

&lt;p&gt;Framework adoption has the same circularity. Flutter and React Native adoption is growing, but teams reach for them to avoid writing two native apps. That problem exists largely because Apple won't let the web do on the iPhone what it already does on every other platform. A developer checks iOS web capabilities, finds background sync missing and Bluetooth unavailable, builds native instead, and that decision gets counted as evidence that the web isn't ready. The constraint creates the behavior that justifies the constraint.&lt;/p&gt;

&lt;p&gt;Not all of the pull is Apple's doing. Habit, a persistent icon, and lock-in favor native even where Apple has no say. But on phones, in the markets that set the industry's defaults, the counterfactual has never been tested at scale because Apple has prevented it. The assumption that native is inherently superior has become so embedded that most teams skip straight to "which framework?" without ever stopping at "does this need to be an app?"&lt;/p&gt;

&lt;p&gt;The closest tests come from companies that chose the web for business reasons. None is a controlled trial of web against native. The Financial Times left the App Store in 2011 and ran on the web alone for six years. It returned in 2017 with an app that sent new readers to the website to subscribe, as Talking Biz News reported, so the web stayed the product and the app became a convenience.&lt;/p&gt;

&lt;p&gt;Android-dominant markets like India and Southeast Asia show what happens where Apple's cap matters least. Apps still lead there, partly on habit and on product playbooks written iOS-first. But companies like Flipkart and JioSaavn treat the web as a first-class product rather than a fallback. Flipkart rebuilt its mobile site as a PWA after a failed app-only experiment, while keeping its native app. Users spent three times as long on the PWA as on the old mobile site, per Google's web.dev case study.&lt;/p&gt;

&lt;p&gt;Starbucks built a PWA 99.84% smaller than their iOS app, according to its developer NearForm. It told Google I/O it had doubled the number of people ordering through the web each day, as MSPoweruser reported. But Starbucks kept the native app too. That raises an important question I can't answer: did they keep it because native was genuinely better, or because no one was willing to ask "why do we still have this?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The Anxiety That Predates Mobile
&lt;/h2&gt;

&lt;p&gt;When the iPhone launched in 2007, Steve Jobs told developers to build web apps. The web genuinely wasn't ready, and the App Store arrived a year later. But the response to that gap matters more than the gap itself. Rather than rallying behind closing it, the industry built an entirely parallel native ecosystem.&lt;/p&gt;

&lt;p&gt;That response follows a pattern that has repeated since the 1960s: every generation of computing produces a viable thin-client model, and the market keeps finding reasons to resist it. Mainframe terminals gave way to PCs. Sun's network computer was technically sound and commercially dead. Chromebooks were dismissed as laptops that couldn't work offline, even as every application was migrating to the browser. Each shift had cost and latency reasons, but a recurring anxiety rode along with them: if computation lives somewhere else, you lose control. Companies that profit from local-first computing have always been happy to amplify that fear.&lt;/p&gt;

&lt;p&gt;The backend already completed the thin-client transition. Cloud won decisively, and few teams argue for on-premises-first anymore. Much desktop software has moved into the browser, but the phone is frozen at the same conceptual barrier that existed when the first PC replaced the first terminal. A native app over an API already keeps its data elsewhere, but it still ships its interface as installed code through a store. We accepted that our servers are someone else's computers. We haven't accepted that our applications could be someone else's rendering.&lt;/p&gt;

&lt;p&gt;Cloud broke through partly because no single company controlled the server. The web can't break through until it works on Apple's phone, and Apple decides what works on Apple's phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Default to the Web and Go Native Only for a Documented Gap
&lt;/h2&gt;

&lt;p&gt;Before committing to a native app, ask one question: "Do we have a specific, documented constraint that the web platform cannot satisfy?"&lt;/p&gt;

&lt;p&gt;For most mobile software needs, the answer is no. Safari, like a framework, is a roadmap you don't control, and its quirks take work. But those quirks sit in an engine you can test directly, not in a translation layer over two platforms. Where Safari lacks a feature, a native supplement covers exactly that gap. It brings back store review and a second release pipeline, but only for the features that need them.&lt;/p&gt;

&lt;p&gt;The immediate objection is discoverability, since an app outside the store seems invisible. But store search is often where users go to fetch an app they already heard about through web search, social media, ads, or word of mouth. The store is more of a checkout counter than a shopping mall. Google Play already supports Trusted Web Activities, which let PWAs appear as store listings. The Microsoft Store accepts PWAs directly. Deep links, QR codes, and social sharing put users straight into web experiences.&lt;/p&gt;

&lt;p&gt;Retention is the harder objection. A home-screen icon and push drive return visits, and on iOS both need a manual install that Safari never prompts for. Where retention depends on them, that is the documented gap a native supplement covers.&lt;/p&gt;

&lt;p&gt;The web app is your product. The native app, if you need one at all, exists only for the features that Apple won't let the browser handle.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Context&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Business or enterprise tools&lt;/td&gt;
&lt;td&gt;Web, with instant deploys, no store friction, and broad coverage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E-commerce, content, media&lt;/td&gt;
&lt;td&gt;Web, unless wearable integration or advanced AR is core to the product&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Field ops, inspections, data collection&lt;/td&gt;
&lt;td&gt;Web installed to the home screen, with Service Workers for offline and sync on open; native if data must sync in the background or geofencing is required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer app in Android-dominant markets&lt;/td&gt;
&lt;td&gt;Web PWA, with first-class app experience and no store tax&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer app with iOS as the primary platform&lt;/td&gt;
&lt;td&gt;Web first; go native for install and push only when retention depends on them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wearable companion (Apple Watch, Wear OS)&lt;/td&gt;
&lt;td&gt;Native required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent background location tracking&lt;/td&gt;
&lt;td&gt;Native required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Home screen widgets, Siri / Assistant routines&lt;/td&gt;
&lt;td&gt;Native required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Advanced AR with LiDAR or body tracking&lt;/td&gt;
&lt;td&gt;Native required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cost, velocity, and agility shouldn't be values we only demand from our backend infrastructure. Native apps aren't going away, and they shouldn't. But we should stop accepting a status quo where one company's business model sets the ceiling for how the entire industry ships code.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>website</category>
      <category>frontend</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Observability Is Authored, Not Installed</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Mon, 16 Feb 2026 19:04:51 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/observability-is-authored-not-installed-4lcc</link>
      <guid>https://dev.to/stevenstuartm/observability-is-authored-not-installed-4lcc</guid>
      <description>&lt;p&gt;I have been a part of a dev team where poor observability constantly brought us to a standstill. Not because the tooling was missing, but because the data it collected never carried meaningful context. Alerts fired constantly, so operations teams ignored them. Dashboards existed for every service, but none of them answered the questions that mattered during incidents. Investigations that should have taken minutes took hours. It got bad enough that slow diagnosis alone stretched incidents into significant SLA violations.&lt;/p&gt;

&lt;p&gt;We questioned the choice of platforms, dashboards, and alerting rules. Yet none of those could help, because no tool could report a distinction our code never recorded. The problem was upstream, since our code didn't know the difference between "I handled this correctly" and "something is actually broken."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Classification Problem: Handled Outcomes Logged as Errors
&lt;/h2&gt;

&lt;p&gt;Consider a payment processing system. A customer's card gets declined for insufficient funds. The payment gateway returns a rejection, and the system logs it as an ERROR.&lt;/p&gt;

&lt;p&gt;But this is the system working correctly. Insufficient funds is a handled business case, not an exception. Because it's logged as an error, though, it shows up in error dashboards, triggers error-rate alerts, and adds to the ambient noise that operators learn to tune out.&lt;/p&gt;

&lt;p&gt;Over time, "payment errors" become background radiation. The team knows most of them are just declined cards, so they stop investigating. Then the gateway starts timing out, or a partner pushes a breaking change, and the actual problem gets buried. Nobody notices because "payment errors are always high."&lt;/p&gt;

&lt;p&gt;The usual response is to blame the team for ignoring alerts. It is a discipline problem, yes, but the discipline that's missing is upstream, in the code that treats expected outcomes as errors.&lt;/p&gt;

&lt;p&gt;The fix belongs in that code, which should sort each outcome by what it means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expected success&lt;/strong&gt;: The happy path. Logged at DEBUG if at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expected failure&lt;/strong&gt;: Business logic correctly rejecting something, like declined payments, validation failures on input from outside callers, or rejecting a caller over its quota. This is INFO, not ERROR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degraded but functional&lt;/strong&gt;: The system recovered, but something is wearing thin. Retries running past their usual count, or a dependency throttling your calls. This is WARN. It isn't broken yet, but it needs attention before it becomes broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unexpected failure&lt;/strong&gt;: Something genuinely went wrong that demands investigation. This is the only category that should be ERROR. A call that fails past its retries belongs here even against a dependency known to be flaky, because no code path handled it. If that becomes routine, the fix is a handled path like a circuit breaker or fallback, which moves it to degraded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Typed results make this distinction structural instead of incidental. When expected failures are returned as typed results rather than thrown as exceptions, the classification is baked into the code's structure. A declined payment returns a result; a gateway timeout that exhausts its retries throws an exception. A logging boundary still has to map each result type to a log level, but typed results put that decision in one visible, testable place instead of leaving it to guesswork at every call site.&lt;/p&gt;

&lt;p&gt;The log level is only one way to carry that decision. A structured outcome field or a counter works as well, as long as the code sets it. For conditions that only show in aggregate, like routine retry counts, response times approaching SLA thresholds, or connection pools running hot, a metric carries the decision better than a WARN line per request.&lt;/p&gt;

&lt;p&gt;When classification is right, every downstream tool benefits. Dashboards that track error rates become genuine health indicators because errors represent actual unexpected failures, not business logic working as designed. Log queries become surgical because structured errors with proper context let you filter to a specific tenant or operation in minutes. Once thresholds and routing are set well, alerts become actionable, because every error they count demands investigation.&lt;/p&gt;

&lt;p&gt;When classification is wrong, the opposite happens. Alerts fire for expected outcomes, so operators learn to ignore them. Dashboards become decoration because nobody trusts what the numbers represent. Every investigation becomes archaeology because the data that should answer your questions is buried under noise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Only the Code Can Make the Call
&lt;/h3&gt;

&lt;p&gt;The usual alternative is to classify downstream, but no downstream tool can classify outcomes the code didn't make distinguishable. Each option runs into that limit in its own way.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A collector rule&lt;/strong&gt; can change the level of decline lines in one central place. But it matches message text, so it breaks silently when the message changes, and it never sees lines that sampling already dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filters by logger category or exception type&lt;/strong&gt; quiet framework and library noise without matching text. But they can't split a decline from a breakage inside the same operation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An SLO&lt;/strong&gt; holds the classification rule in one shared place. But it can only count a field the code emits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Status codes look like a field the code emits, and auto-instrumentation can tell a 402 decline from a 504 timeout. But a status code carries the meaning only when the code puts it there. A decline wrapped in a 200 response body reaches the platform with the wrong label. A partner's breaking schema change gets the wrong label too, because it arrives as a 400 that looks just like a validation failure.&lt;/p&gt;

&lt;p&gt;Only the code knows the difference, and it knows because someone designed it to. A partner integration shows what that design looks like. If the code validates each request against its own rules before sending, any schema or contract 400 the partner returns afterward is unexpected by definition. Sometimes the 400 reveals a rule your validator missed rather than a partner change. ERROR is still right, because the gap is a bug in your code, and fixing it removes that noise. Rejections based on the partner's own state, like a closed account, are different. They're expected outcomes, so map the partner's error codes for them explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Business Judgment Belongs in Counters, Not Log Levels
&lt;/h3&gt;

&lt;p&gt;When the system correctly declines a card for insufficient funds, it's tempting to log that as WARN because you want the metric reviewed often. But a correctly handled decline is the system working as designed, not degrading. Whether the decline rate is "concerning" is a business question that changes with strategy and context, so log levels shouldn't encode that judgment. Leave business interpretation to reports and dashboards where it can evolve, not to code where it gets baked in and forgotten.&lt;/p&gt;

&lt;p&gt;Declines still deserve watching, just not through the log level. Feed those reports from a decline counter rather than from INFO lines, because many teams sample INFO logs and a counter keeps every decline. The counter also catches breakage that looks like declines. A sudden spike in correctly handled declines can mean a bug sending wrong amounts or a card-testing attack, so the same counter can carry an anomaly alert routed to engineering, separate from the error rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context Is Authored, Not Accumulated
&lt;/h2&gt;

&lt;p&gt;The instinct is to compensate with volume: write verbose logs everywhere so you'll have context when you need it. But a trace-level log is not a dump file. Nearly every bug I've seen diagnosed from trace logs involved information the code already had at the point of failure and could have put in the error or warning itself. In those cases the problem wasn't insufficient logging volume; it was that nobody authored the context where it mattered.&lt;/p&gt;

&lt;p&gt;A stronger version of the volume argument comes from &lt;em&gt;Observability Engineering&lt;/em&gt;, by Charity Majors, Liz Fong-Jones, and George Miranda. It records one wide structured event per request with dozens of attributes, so you can ask questions afterward that nobody predicted. That approach handles unknowns far better than trace dumps, but it doesn't escape authoring. The event still needs an outcome field that says whether a decline was expected or broken, and domain fields like the tenant and the gateway's response code only exist if the code puts them there. Without them, a high-cardinality store is a faster way to search through the same unclassified noise.&lt;/p&gt;

&lt;p&gt;Failures should carry their own context. When an operation fails, the error log should include what was being attempted, what went wrong, and enough identifying information to correlate it. Consider a checkout failure. Instead of logging "payment processing failed," a properly authored error captures the operation ("charge_payment"), the correlation ID, the gateway's response code, and the amount. That's structured enough to query and specific enough to reproduce the failure without re-running production traffic.&lt;/p&gt;

&lt;p&gt;What solves most domain-logic bugs is understanding what the user did and what they sent, not tracing the code's internal flow. Connecting what they did across services takes a correlation key, and carrying that key is a solved problem. The W3C Trace Context standard defines how it travels between services, and most structured logging libraries can stamp it on every entry. With that key and errors authored like the checkout one, you have what you need to reproduce the problem.&lt;/p&gt;

&lt;p&gt;Reproducing a failure from its input, rather than tracing it, borrows the idea behind event sourcing, where a state can be rebuilt by replaying the events that produced it. For deterministic domain logic, you don't need to trace every intermediate step if you can reconstruct the scenario from the input, the outcome, and the state the operation read.&lt;/p&gt;

&lt;p&gt;The state the operation read is the part the request can't supply. A race, or a record that changed after the failure, can't be replayed from the input alone. So the error should carry the versions of the records it loaded. A version only helps while the old record still exists. If the store overwrites records in place, carry the field values the decision used instead.&lt;/p&gt;

&lt;p&gt;Most of the context a failure needs can be chosen in advance, because the facts that decide an outcome are ones the code already handles. They are the operation, its input, the response from whatever it called, and the versions of the records it read. Environment facts like the build version, config, feature flags, and host belong there too, and the logging setup can attach those to every entry without per-call code. Some deciding facts won't be predictable. Those show up as incidents where someone had to add logging to find the cause.&lt;/p&gt;

&lt;p&gt;What gets logged must be intentional. You know the domain, so you know the potential inputs, what's valid, and what's sensitive. That knowledge lets you author a safe context, enough to reproduce the problem without exposing data that shouldn't be in a log. An allowlist of input fields, hashed identifiers, or a reference to a stored payload can carry the input safely. If you don't understand the domain well enough to make that distinction, that's the source of the problem, not the logging infrastructure.&lt;/p&gt;

&lt;p&gt;The difference between a useful error and a useless one is whether someone authored the context intentionally or hoped that raw volume would cover it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installed Tooling Reports Mechanics, Not Meaning
&lt;/h2&gt;

&lt;p&gt;Classification and context both come from the code, but some observability really is installed. A platform catches transport failures on its own, like a timeout on an outbound call, and span timings and profilers expose creeping latency without any per-call code. Trace logging, toggled on temporarily for one flow, still diagnoses ordering bugs where the sequence itself is the evidence.&lt;/p&gt;

&lt;p&gt;What they can't supply is meaning. They see that a call failed or slowed down, but not whether a rejection was the system working as designed, which fields an error needs, or whether an answer was wrong. Wrong output that never raises an error only surfaces in business-outcome metrics, and those are authored too, because someone has to decide which outcome to count. That's the sense in which observability is authored. The installed part tells you something failed, and only the authored part tells you whether it matters and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Black Box Test: Diagnose From Outputs Alone
&lt;/h2&gt;

&lt;p&gt;The word observability comes from control theory, where Rudolf Kálmán defined it in 1960. A system is observable when its internal state can be inferred from its external outputs. The practical test applies that definition. When something breaks, can you diagnose it from the system's outputs alone? Or do you need to add logging, redeploy, and wait for it to happen again? If the answer is the latter, your code doesn't explain itself yet.&lt;/p&gt;

&lt;p&gt;Software never passes completely, so run the test as a rate. For each recent incident, ask whether the first responder could identify the failing operation, its input, and the cause from existing telemetry. Score it from the timeline rather than memory. An incident fails if anyone added logging before the cause was found, whether by redeploy, a live logpoint, or a trace toggle. That holds even when toggling trace logging was the right move for an ordering bug. The failing share should fall over several quarters. With only a few incidents a quarter, read the trend over a year.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attaching a Debugger Hides the Gaps
&lt;/h3&gt;

&lt;p&gt;Classification and context are design decisions, but many developers never test whether their logging actually answers the questions it needs to. One habit that contributes is the debugger. Stepping through code is the right tool for a logic bug in development, but the habit hurts when it becomes the only way surprises get explained. A developer who resolves every local surprise by stepping through code can get little practice diagnosing from outputs, so gaps in those outputs are easy to miss until production.&lt;/p&gt;

&lt;p&gt;Some organizations extend this habit into production with remote debugging, but that's a security liability. A debugger attached to a running container, or to any production process, can read its memory, including secrets and customer data. It can also change what it executes, so every open debug port is another way into production.&lt;/p&gt;

&lt;p&gt;Snapshot debuggers capture variables without pausing, so they're gentler, and redaction and audit can contain their exposure. But they still keep the habit of reaching into the process instead of fixing what it outputs. You should be observing system outputs, not reading a live process's memory.&lt;/p&gt;

&lt;p&gt;Production should be a black box. If your default instinct when something breaks in production is to attach to it rather than read the outputs, you'll rarely feel the pressure to make those outputs useful. The classification stays sloppy, the context stays thin, and the errors stay vague. Not because you don't know better, but because you've never needed better.&lt;/p&gt;

&lt;p&gt;Developers who diagnose from observable behavior, whether testing locally against containerized dependencies or against remote systems, tend to build the discipline. They feel the pain of vague errors and missing context firsthand, and they fix it at the source because they have no easier option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Owners Who Get Paged Close the Gaps
&lt;/h3&gt;

&lt;p&gt;That discipline holds best when builders own what they operate. You don't log payment declines as errors when you're the one who gets paged for "high error rate on payment service." You don't dump verbose logs instead of authoring context when you're the one parsing them at 3 AM.&lt;/p&gt;

&lt;p&gt;Paging pressure only fixes code when it reaches the code. A paged team can just as easily raise thresholds or mute alerts. What can turn the pain into a source fix is bringing the authors into incident diagnosis and filing every misclassified outcome or context-free error found there as a bug against the owning team.&lt;/p&gt;

&lt;p&gt;Tracking open telemetry bugs per owning team, beside the black box test's failing share, gives each team a number that backlog triage can see. Filing against the owning team matters most when the author sits on another team, because that author would otherwise never feel what their classification and context choices cause downstream.&lt;/p&gt;

&lt;p&gt;Better tooling alone won't make your code explain itself. Running the black box test against it, and filing what it finds against the code that caused it, is how it starts to.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>devops</category>
      <category>architecture</category>
      <category>monitoring</category>
    </item>
  </channel>
</rss>
