<?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>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 almost always reveals a belief about architecture that was never made explicit.&lt;/p&gt;

&lt;p&gt;Most teams treat this as an authentication problem, but the answer shapes how services are structured, where trust boundaries sit, and how teams own their work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Positional architecture&lt;/strong&gt;: legitimacy granted by placement.&lt;br&gt;
A request is legitimate because it arrived from the right place, to the right place: 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;&lt;strong&gt;Identity-oriented architecture&lt;/strong&gt;: legitimacy earned by ownership.&lt;br&gt;
A request is legitimate because the caller has proven who it is and what it is authorized to do. Credentials name 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, and that belief determines where authority lives in the system, how teams own their work, and whether the system can adapt as the domain evolves.&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 checkout client presents credentials each domain service validates directly. When OrderService calls PaymentService, it presents its own service identity; the client's token is never forwarded.&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 subtly different rules. No component is the definitive answer, and the drift between them is slow, then sudden.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Coordination Objection
&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. Cascading call chains are a sound instinct; a service should call domains it genuinely depends on, not reach sideways into peers that own unrelated concerns. Positional architecture uses layer placement to enforce the cascade, but placement without authority creates pass-through components that fragment the very authority they were supposed to preserve. When authority has broken down, sideways calls become necessary because no single component owns the full decision.&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 genuinely needs to coordinate with another, the call is direct and well-understood. The concern about sibling calls disappears when those siblings are precisely named and their authority is unambiguous. &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; the physical separation is required by operational reality. A facade tier that routes and shapes for a single consumer has no such requirement; identity enforces the same boundary without the deployment cost. Positional architecture conflates these, adding tiers to enforce layers and using placement where identity would suffice. The critique is not of tiers but of tiers that answer no operational question.&lt;/p&gt;

&lt;h3&gt;
  
  
  Legitimate Coordination
&lt;/h3&gt;

&lt;p&gt;When a multi-step workflow genuinely requires coordination across bounded domains, there are two legitimate forms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choreography&lt;/strong&gt;: each service reacts to domain events it subscribes to, applying its own authority to its own concern. No central coordinator sits in the call path; the workflow emerges from the sequence of autonomous reactions. 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 ── publishes checkout.initiated
                          │
             ┌────────────┴────────────┐
             ▼                         ▼
       OrderService             PaymentService
      [own authority]           [own authority]
    publishes order.created   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; it calls them as a peer with bounded authority, not as a layer above them.&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. If no: all state lives in the sub-services and the coordinator exists only to sequence calls. It becomes what the shared database was in an earlier decomposition: a bypass path that dissolves the authority boundaries the architecture was meant to enforce.&lt;/p&gt;

&lt;p&gt;A coordinator whose name reflects a process lifecycle has a genuine authority claim; one named for the services it coordinates, or for every concern it touches, does not. The name is a symptom; the underlying question is always what the service is the definitive source of truth for.&lt;/p&gt;

&lt;p&gt;What makes any composite structure legitimate is that every participant holds genuine authority over its own concern, not that it occupies the correct layer position.&lt;/p&gt;

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

&lt;p&gt;Positional architecture can be a deliberate choice; when it is, its trade-offs should be known and accepted. More often it arrives through one of a few recurring paths, each reasonable on its own terms, none of which examined the trust model they were collectively building.&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 visible in their public postmortems, pressures most teams haven't faced 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 mandates network isolation for the Cardholder Data Environment specifically, not for service architectures generally. HIPAA, SOX, and GDPR prescribe access controls and data protection outcomes; none specify network topology. The pressure toward perimeter models typically comes from implementation guidance and audit practices, not from the frameworks' own text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cases where positional architecture genuinely 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 scoped to one boundary, not a commitment to positional architecture throughout the system. Almost every other use arrived through one of the paths above, and the controls that would justify it arrive later, applied unevenly, because delivery pressure consistently wins.&lt;/p&gt;

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

&lt;p&gt;The costs compound predictably as the system grows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Debugging requires tracing the entire layer topology; a one-hour fix often takes a day&lt;/li&gt;
&lt;li&gt;Change velocity slows because a domain model change requires coordinated updates across every dependent layer&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;Infrastructure costs compound at every layer, sometimes several times per external request&lt;/li&gt;
&lt;li&gt;Capacity planning is nonlinear 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;The real coupling is not shared code but shared call chains: every consumer request travels through the same intermediary services in the same order, and every intermediary couples to the services below it. The layers are separate deployments with separate teams, but a change in any domain service propagates upward through every adapter and facade that depends on it, exactly as it would in a tightly coupled monolith. Every positional system carries the full cost of distributed architecture, including separate deployments, coordinated releases, and network hops, without the independence those costs were supposed to buy.&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, but the chain ends there. Each service can be reasoned about, scaled, and deployed on its own terms.&lt;/p&gt;

&lt;p&gt;At sufficient coupling depth, a true monolith is more defensible: it at least eliminates network hops, serialization overhead, and the coordination cost of deploying multiple services to ship a single feature. Those costs are the price of independence. When independence was never achieved, the price is paid with nothing received in return.&lt;/p&gt;

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

&lt;p&gt;Identity-oriented architecture carries its own operational costs. Every service credential requires a lifecycle: issuance, rotation, and revocation. Token expiry windows and revocation propagation require deliberate design rather than implicit trust. At scale, this becomes a distributed secrets management problem that positional architecture sidesteps by treating network membership as sufficient proof. The infrastructure for it, including workload identity systems and secrets managers, has matured, but 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.&lt;/p&gt;

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

&lt;p&gt;A positional system built to its full specification is the most controlled environment available: mutual TLS on every hop, audit logging at every tier, explicit authorization at each layer. The security problem is not the model; it is the belief that sustains the model and what that belief implies about where security effort should be concentrated.&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. Its identity is its tier: facade-tier service, orchestration-tier service, domain-tier service. When that service authenticates to call another, the most natural credential is one that proves it belongs to the trusted network: a cert granted by the internal CA, a service mesh identity that says "internal." That credential proves network citizenship, not bounded identity. It answers "are you one of us?" rather than "are you specifically OrderService with authority over orders?"&lt;/p&gt;

&lt;p&gt;Teams in positional systems concentrate security effort at the edge because the edge is where external callers prove they belong. Everything behind it applies implicit trust, because belonging to the internal network already proved legitimacy.&lt;/p&gt;

&lt;p&gt;That trust model is exactly what attackers exploit. A single vulnerability such as server-side request forgery, request smuggling, or a compromised internal service gives access to the entire soft interior. The blast radius is the full internal network, not the narrow scope of whatever was actually breached: the difference between rotating one service's credentials and conducting a full incident response across every internal system.&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. That solves authentication, not authorization. A mesh-issued cert says "authenticated internal service." It does not say "OrderService, authorized for these operations on order data." A compromised internal service holds an equally valid cert; the mesh cannot tell the difference because the credential encodes network membership, not bounded ownership. The mechanism changed; what it proves didn't.&lt;/p&gt;

&lt;p&gt;The distinction is clearance versus need-to-know. A top secret clearance does not entitle the holder to every document at that level; each access still requires a demonstrable need for this specific information. A service mesh cert that proves "authenticated internal service" is a clearance: it proves tier membership and nothing else. Positional architectures treat it as sufficient authorization; identity-oriented architectures demand need-to-know: a bounded authority claim for this caller, independent of where it sits in the network.&lt;/p&gt;

&lt;p&gt;Identity-oriented architecture inverts this at the root. Every service presents credentials that prove a specific bounded identity: not "I'm internal" but "I'm OrderService, authorized for these operations on order data." A compromised component can only act within its authorization scope. 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 Is Circular
&lt;/h3&gt;

&lt;p&gt;A common gut response is that identity-oriented architecture increases the attack surface by exposing domain services directly.&lt;/p&gt;

&lt;p&gt;Attack surface is a count of publicly reachable endpoints, and nothing about positional architecture bounds that count. A positional system can grow its public layer without end; every new consumer-facing feature adds endpoints regardless of whether intermediary tiers exist.&lt;/p&gt;

&lt;p&gt;In an identity-oriented system, rate limiting, IP flagging, geographic constraints, and gateway-level checks apply everywhere without exception, because there is no interior to fall back on. Positional architecture doesn't make the public layer more secure than a well-secured identity-oriented one; it adds internal layers that receive implicit trust, while the identity-oriented system applies the same controls to every surface.&lt;/p&gt;

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

&lt;p&gt;Positional architecture tends to produce horizontal teams organized around the layers themselves. The backend team owns domain services, the platform team owns orchestration, the API team owns the external facade. Each team's incentives point inward; the backend team is rewarded for internal quality, not consumer outcomes, and the orchestration team optimizes for the calls it coordinates rather than the features consumers need. Every capability that crosses a layer boundary requires coordination, negotiation, and synchronized releases.&lt;/p&gt;

&lt;p&gt;This is Conway's Law expressed architecturally: organizations design systems that mirror their communication structures, then their communication structures solidify around those systems. 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 actually 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;
  
  
  The Discipline Objection
&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. Penetration testing, contract testing between layers, and distributed tracing across hops all work, and teams that apply them consistently can operate positional systems at scale.&lt;/p&gt;

&lt;p&gt;The objection treats discipline as an architectural substitute, and it isn't. Both styles require the same disciplines. The difference is what those disciplines cost when you add layers: in a positional system, a change at any hop can ripple through every connected hop, and the connections aren't visible without tracing the full topology. In an identity-oriented system, the discipline a team applies to its own domain stays local; in a positional system, that discipline multiplies across a topology that grows with the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Does legitimacy come from who you are, or from where you sit? Positional architecture defaults toward position as the answer; identity-oriented architecture answers with ownership, and communication structure follows from that rather than preceding it. A system built to control communication paths will keep needing to control them as the domain evolves, because the structure was never derived from the domain.&lt;/p&gt;

&lt;p&gt;Positional architecture becomes the wrong answer when it arrives by default rather than by deliberate commitment, when teams inherit the cost without making the trade-off explicit. 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. The belief of where authority belongs shapes what the system can absorb when it changes and what it cannot.&lt;/p&gt;

&lt;p&gt;The best practices across software engineering are each a response to a specific observed failure. The 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 observed that a fact stored in two places produces an inconsistency when one is updated, causing data drift. Least privilege observed that a process able to affect state beyond its concern eventually does, introducing state drift at the execution level. Bounded contexts 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.&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: distributed decision-making produces drift. 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.&lt;/p&gt;

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

&lt;p&gt;Two properties measure 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&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CQRS, for example, splits read and write models for the same domain not because they are behaviorally incoherent, but because their operational envelopes are incompatible; reads run at far higher volume than writes.&lt;/p&gt;

&lt;p&gt;A well-contoured authority can be named precisely: "OrderCheckoutService" tells you what it owns, while "OrderService" does not. Needing a follow-up explanation is the signal 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, measured by the consequence of bypass: what breaks when the boundary fails.&lt;/p&gt;

&lt;p&gt;A strongly bonded authority has no known bypass; all interactions must 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. Bond strength is proportional to consequence. 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;h2&gt;
  
  
  Style, Characteristics, 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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Architectural Characteristics&lt;/strong&gt; are the primary authority in 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 are 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 measure whether authority in the code is correctly placed: whether the right components own the right decisions, and whether those boundaries hold 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. We should measure 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.&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 any sustained change activity, 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.&lt;/p&gt;

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

&lt;p&gt;The most persistent variation arrives through early optimization. Before a domain's behavioral coherence is understood, structural decisions get made. Such as, services decomposed, schemas separated, and ownership assigned. These optimize for what is visible right 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 for the current system, and examined from previous systems, arrives years after, and the cost is rarely expressed in terms legible to the people who make the final call.&lt;/p&gt;

&lt;p&gt;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 (for example); they trace it to human error. When a decomposed architecture becomes expensive to change, no one traces it back to service boundaries drawn before behavioral coherence was understood; they trace it to team coordination.&lt;/p&gt;

&lt;p&gt;The lag is measured in years, and by the time the drift is painful, 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 is a useful thread to follow; it 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;OrderPaymentMgr&lt;/code&gt; mixes order lifecycle logic with payment processing. &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. And underneath all of it, a single database holds everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contour&lt;/strong&gt;: undefined. Behavioral coherence was never applied; &lt;code&gt;OrderPaymentMgr&lt;/code&gt; conflates order lifecycle with payment processing, behaviors that change for entirely different reasons.&lt;br&gt;
&lt;strong&gt;Bond&lt;/strong&gt;: none. With no boundaries declared, the consequence of bypass is invisible; 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. They may even have separate schemas on the same database instance, or fully separate database instances. The infrastructure topology doesn't determine the authority structure; the data access patterns do.&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;Contour has improved on paper: there are named services with named responsibilities. Bond has improved in structure but not in practice. 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, which turns out to be all of them. The consequence surfaces not at the point of access but at the point of change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contour&lt;/strong&gt;: named but not coherent. The names exist, but the boundaries weren't drawn along behavioral coherence lines; PaymentService queries order data because order state and payment decisions are tightly coupled in practice, and the boundary didn't account for that.&lt;br&gt;
&lt;strong&gt;Bond&lt;/strong&gt;: declared but bypassed. The consequence of cross-schema access was underestimated; it materializes the first time the order data model changes and every dependent service breaks with it.&lt;/p&gt;

&lt;p&gt;This is the most 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;br&gt;
&lt;strong&gt;Bond&lt;/strong&gt;: split across two enforcement points. The consequence is inconsistent behavior; the rule a caller sees depends on which enforcement point their request path reaches first.&lt;/p&gt;

&lt;p&gt;A facade holds clear authority over presentation concerns: routing, shaping, and aggregating results. The moment it acquires business logic, it becomes a second authority over the domain; divergence is not a risk to manage but the mechanical consequence of the split. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contour&lt;/strong&gt;: named and coherent. Order lifecycle, payment processing, and inventory management each change for different reasons; the boundaries reflect that behavioral coherence.&lt;br&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; 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. Order state can still only change through the aggregate root.&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 but the consequence of the bypass is low: a read cannot modify order state.&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; materialized view joins across columns that would be split into two tables, and the nightly export job selects from it in a pipeline the reporting team controls on a separate release schedule. Coordinating the schema change, the view update, and the export job across two teams and two release schedules stalls the migration for two quarters. The production schema cannot change freely because the reporting concern has an implicit claim on its shape.&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. The &lt;code&gt;orders&lt;/code&gt; table now serves two unrelated access patterns, and neither can evolve without the other.&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.&lt;/p&gt;

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

&lt;p&gt;The corrected version keeps the production schema exclusively in OrderService's authority. Reporting access comes through the service's contract, not through the schema.&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 to a reporting store it controls, whether through events, a scheduled export, or a dedicated read model.&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;br&gt;
&lt;strong&gt;Bond&lt;/strong&gt;: maintained. The &lt;code&gt;orders&lt;/code&gt; schema has no bypass; the reporting store is a separate authority over reporting-shaped data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The first four stages of that workflow represent authority misplaced at the outset. The reporting bypass is a different class of failure. It eroded through a sequence of locally reasonable decisions: a read bypass for performance, a convenience view, and queries that took dependencies on both. The cost surfaced not when those decisions were made but when the schema needed to change.&lt;/p&gt;

&lt;p&gt;The vocabulary of contour and bond are critical not just at design time but as a check at every point the architecture evolves.&lt;/p&gt;

&lt;p&gt;Architectural disagreements about service boundaries, consistency models, 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: from aesthetic preference or pattern-matching to 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. &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 organizations force synchronization through universal sprint cadences. Timeboxes also conflate three concerns that should be independent: development cycles, deployment cycles, and feedback cycles. Each operates at its own natural frequency; sprints force artificial alignment across all three, stretching fast work to fill the interval and fragmenting slow work across multiple cycles.&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. None of these answer the question that matters mid-sprint: should we stop this work two days in because the assumptions were wrong? Most 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 won't throw away three sprints of committed work either. The sunk cost grows with every sprint boundary crossed.&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. If you don't know when work is done without a calendar, you 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;
  
  
  A Solution: Shaped Kanban
&lt;/h2&gt;

&lt;p&gt;What if you could have discipline without timeboxes?&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, 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 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 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;/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;Bets are on shaped work with understood scope, not vague user stories and story point estimates.&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;Unlike Shape Up's fixed 6-week cycles, shaping can happen more frequently 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;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;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, you stop and reassess: adjust scope, extend with stakeholder agreement, reshape based on what you learned, or drop the work. Per-feature boundaries 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;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;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 cooldown sprint 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;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 gets bet on. This demands more rigor than Shape Up's cooldown, but architectural health becomes visible and competes on the same terms as every other bet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Multi-Team Coordination?
&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 small-to-medium consulting work, refined project by project. 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. Scrum's greatest weakness gets worse as teams multiply because you cannot fix the collective without first empowering the individual. Scrum operates on collective abstractions: sprint commitments, velocity charts, planning poker. None of these give individual contributors the tools to recognize when work should stop. Shaped Kanban's core mechanisms (appetites and circuit breakers) work at the individual level. That is exactly the right lever when you need 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. In Shaped Kanban, dependencies are explicit from the shaping stage. 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 creates a predictable failure mode 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 makes that coordination explicit from the start rather than hiding it inside ceremonies that can't actually handle it.&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. That rigor is demanding, but it also creates the conditions for async, purpose-driven coordination instead of forced synchronization at sprint boundaries.&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. That is harder. 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;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;Shaped Kanban provides structure for these disciplines without artificial time constraints. It is not a perfect solution. Continuous flow across teams demands more intentional coordination than synchronized sprints. But the tradeoff is between value and coordination complexity, and I will take that tradeoff every time because one is intentional and the other is shipping on hope.&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;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 concerns share a single schema, every design decision becomes a negotiation between them, and reporting usually wins because it's the most visible to leadership and the most painful to change after the fact. These concerns can and should be separated so each model serves the workload it was designed for.&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 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. The refactor stalls, and the production schema fossilizes around a reporting concern.&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 distortion is predictable, not surprising. It emerges when reporting consumers multiply, when dashboards become load-bearing, and when schema changes require cross-team coordination. Architects who recognize this trajectory can keep the door open for separation without building the full pipeline prematurely, by resisting the urge to denormalize production schemas for reporting convenience and by 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
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A Dedicated Reporting Replica
&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, the production schema is close enough to what reporting consumers need, and 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. Still, it's the path of least resistance, and for many systems it works well enough that teams never move beyond it.&lt;/p&gt;

&lt;p&gt;The replica also serves as the foundation for ETL. Rather than querying the replica live, teams extract data from it on a schedule, transform it into reporting-friendly shapes, and load it into a warehouse or data lake. Same infrastructure, different consumption pattern. Live queries hit the replica directly for near-real-time results while ETL jobs use it as a source for batch aggregation and historical snapshots. Both approaches keep analytical workloads off the primary.&lt;/p&gt;

&lt;p&gt;The replica breaks down, for both live queries and ETL, 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. The replica also can't capture history. It mirrors current state, so if a record changes twice between queries the intermediate state is gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change Data Capture
&lt;/h3&gt;

&lt;p&gt;CDC tools like Debezium tap the database's transaction log and emit changes as events without any application code changes. The application writes normally to whatever schema makes sense, and CDC streams those changes to a separate store. The stream is async by default, and unlike the replica approach, CDC captures every intermediate state change because it reads from the transaction log rather than polling snapshots.&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, no additional transaction overhead, and no new abstractions in the write path. For legacy systems where the risk of changing the write path is too high, or for teams that need separation now and can't afford to modify every service that writes data, CDC is often the only viable option. It also solves payload completeness for free: the transaction log captures the full row state after each write regardless of whether the application only updated a single field, so downstream consumers never have to wonder whether a missing field means "unchanged" or "removed."&lt;/p&gt;

&lt;p&gt;CDC does have limitations.&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. The database can't distinguish between them because it only sees the state change, not the business intent. For domains where that distinction matters, like financial ledgers or audit-critical workflows, event sourcing is the appropriate tool because it captures the intent as the primary record.&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. A schema registry can partially close this gap by enforcing compatibility rules at deserialization, 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. Not every database has a strong CDC story. PostgreSQL and DynamoDB have mature options, but weaker change stream capabilities can push teams toward application-layer alternatives earlier than expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Intentional Separation
&lt;/h2&gt;

&lt;p&gt;Reactive approaches separate the workload but not the context. They can tell you &lt;em&gt;what&lt;/em&gt; changed, but 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. An intentional separation of concerns takes full advantage of the production context while serving the needs of both reporting and prod as equally vital priorities.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Outbox Pattern
&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, so consistency is guaranteed. A separate process reads from the outbox and projects into whatever reporting store analytics needs. 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;Because the application controls the payload, the outbox captures context that reactive approaches cannot: who initiated a change, whether it was a customer action or an admin override or a system timeout, and why it happened. The application has this context at write time, and it's impossible to recover after the fact.&lt;/p&gt;

&lt;p&gt;This also gives the outbox an explicit, versionable contract boundary. The application decides what the downstream record looks like and versions it independently. A breaking change to the outbox record is a code change that has to compile, pass tests, and go through review. If a developer renames a column in the production schema, the outbox record doesn't change unless someone deliberately updates it. And because the outbox doesn't rely on transaction log capabilities or vendor-specific change feed APIs, any database that supports transactions supports the pattern.&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. Even deletions 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;For most teams that have outgrown a read replica but don't need full event sourcing, the outbox is my recommendation. It provides intentional separation, explicit contracts, and rich context without the architectural commitment of an append-only event store.&lt;/p&gt;

&lt;h3&gt;
  
  
  CQRS and Event Sourcing
&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, shaped however they need them, updated as fast or as lazily as the use case demands. The two sides share no schema and no storage. What CQRS adds is the explicit acknowledgment that "what happened" and "what is the current state" are different questions that deserve different models. CQRS does not require event sourcing. It can sit in front of a traditional stateful database where the write side persists state normally and the read side maintains separate, denormalized views optimized for queries.&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. Nothing is overwritten. 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;"Shipped"&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;In practice, reporting consumers rarely subscribe to the event stream directly. Event sourcing produces projected state tables, one per entity, where each row represents the current state derived from the event history. Reporting and ETL pull from these projections rather than from raw events. This keeps the event stream internal to the domain, which matters because not everything in the stream is a clean domain event. The projections give reporting consumers a familiar, queryable surface while the event stream retains full history for replay and audit.&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. The combination of event sourcing and CQRS provides the most complete separation: full history, arbitrary projections, and independent evolution of read and write models.&lt;/p&gt;

&lt;p&gt;Most teams should not reach for this combination. Martin Fowler has &lt;a href="https://martinfowler.com/bliki/CQRS.html" rel="noopener noreferrer"&gt;warned consistently&lt;/a&gt; that CQRS is misapplied far more often than it's applied 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;
  
  
  Separate Early or Pay Later
&lt;/h2&gt;

&lt;p&gt;A read replica is enough to start, but every shortcut that ties these workloads together makes the eventual separation harder. Both production and reporting deserve to be first-class concerns, and treating them that way means decoupling from the schema entirely.&lt;/p&gt;

&lt;p&gt;Production databases can now optimize for their inserts and their queries. Dev teams can now deploy and evolve a component's database as needs are discovered, without asking permission. Reporting teams can now get richer, more contextual insights that are readily available. And the two groups can now stop being at each other's throats, because they're no longer competing for the same resource.&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, and 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's capability surface is far larger than the industry acknowledges, and nearly everything preventing universal web adoption is inertia, business incentives, or mental models rather than real technical constraints.&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.&lt;/p&gt;

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

&lt;p&gt;The capabilities list for the modern web is longer than most developers and decision-makers expect. For the typical business application, whether it runs on a phone, a tablet, or a 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;That list covers what the vast majority of apps actually 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;The web can't do everything. Some capabilities have no web equivalent and genuinely require native development.&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 bridge, 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 inherited all three bug surfaces.&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 collapsed that constraint. A competent developer with AI assistance can ship Swift or Kotlin without years of platform experience, but all the original disadvantages of cross-platform remain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Companies, Two Arcs
&lt;/h2&gt;

&lt;p&gt;To understand why the web hasn't become the default, it helps to look at how the two most influential companies in software development have traded places.&lt;/p&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 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;Then each company lost something important, and their responses tell you everything.&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 popular editor in the world, they acquired GitHub and kept it open, and Azure now runs more Linux workloads than Windows. The company that once tried to kill Linux now employs more Linux kernel contributors than most Linux companies.&lt;/p&gt;

&lt;p&gt;I am still baffled why Microsoft did not backpedal their bloated OS and clunky UX for Desktop as soon as their market assumptions proved to be so very wrong. Windows seems to have gotten worse with each version and with no sign of redemption. Two steps forward and one step back.&lt;/p&gt;

&lt;p&gt;Apple very quickly 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.&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 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;The walls Apple has constructed 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.&lt;/p&gt;

&lt;p&gt;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. This means 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 over 47 Web APIs including Bluetooth, NFC, Background Sync, USB, and serial devices. On iOS, none of those APIs are available on any browser. In June 2020, Apple publicly rejected 16 Web APIs citing "privacy and fingerprinting concerns." Android handles the same APIs with straightforward permission prompts. The privacy argument doesn't hold up when every other platform manages these capabilities without the problems Apple claims are unsolvable.&lt;/p&gt;

&lt;p&gt;Chrome on Android supported push notifications in 2015. iOS didn't get web push until March 2023, and even then Apple requires users to install the web app to their home screen first. On Android, any website can request push permission.&lt;/p&gt;

&lt;p&gt;The EU's Digital Markets Act forced Apple's hand on browser engine choice in 2024, but the response was revealing. Rather than comply, 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 gathered over 4,200 signatures and the European Commission sent formal inquiries, Apple reversed the decision within two weeks. Genuine security concerns don't evaporate under public pressure.&lt;/p&gt;

&lt;p&gt;And even after the DMA technically required browser engine choice, as of early 2026 zero browsers have shipped a non-WebKit engine on iOS in the EU. The regulation exists on paper. The monopoly persists in practice.&lt;/p&gt;

&lt;p&gt;The financial incentive is straightforward. The App Store generated approximately $27 billion in commissions in 2024 on a 30% cut. Every app that ships as a web app is revenue Apple doesn't collect. The U.S. Department of Justice made this connection explicit in their March 2024 antitrust lawsuit, which specifically cites the WebKit requirement as part of Apple's monopoly maintenance strategy.&lt;/p&gt;

&lt;p&gt;Android doesn't have these restrictions. Chrome supports the full suite of web APIs and PWAs work as first-class applications. But it doesn't matter. No product leader will ship something that doesn't work on iPhones, and Apple's users represent the highest-value demographic in every Western market. The most constrained major platform sets the ceiling for what anyone builds.&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 showing that users spend 88-92% of their mobile time in apps and only 8-12% in browsers. Native retains users at 32% after 90 days compared to 20% for web. The data seems decisive.&lt;/p&gt;

&lt;p&gt;But this is a post-hoc fallacy dressed up as market research. Of course the native experience retains users better; it received ten times the investment. Of course users spend more time in apps; they were never given an equivalent web alternative. Native gets the discovery mechanisms, the design talent, and the push notification support. Web gets a fraction of the budget and is treated as a fallback. 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;The developer survey data has the same circularity. Flutter and React Native adoption is growing, but these frameworks exist because Apple won't let the web do 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;The counterfactual has never been tested at scale because Apple has prevented it. Equivalent web and native experiences have never existed on iOS. 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 few times the counterfactual has been tested, the results are telling. The Financial Times left the App Store in 2011 and is still web-first over a decade later. Starbucks built a PWA 99.84% smaller than their iOS app and doubled daily active users. But Starbucks kept the native app too, which 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. This follows a pattern that has repeated since the 1960s: every generation of computing produces a viable thin-client model, and every generation finds reasons to reject 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. The anxiety is always the same: 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; nobody serious argues for on-premises-first anymore. But the frontend is frozen at the same conceptual barrier that existed when the first PC replaced the first terminal. 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;Mobile is also the reason the web became capable enough to challenge native at all. Service workers, WebGL, touch APIs, and WebAssembly weren't inevitable. They were a competitive response to native threatening to make the web irrelevant. The ecosystem that pressured the web into becoming a genuine application platform is now the same ecosystem preventing it from being used as one.&lt;/p&gt;

&lt;p&gt;Cloud broke through 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;
  
  
  Progress Often Comes by Getting Out of Its Way
&lt;/h2&gt;

&lt;p&gt;Before writing that new shiny app, ask yourselves: "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. The web runs everywhere, deploys instantly, requires no framework intermediary, and its capability surface grows with every browser release. Cross-platform frameworks tried to solve platform fragmentation by adding another platform on top. The web solved it by being the platform that was already there. In Android-dominant markets like India and Southeast Asia, companies like Flipkart and JioSaavn have already proven this works: one codebase, instant deployment, no App Store tax.&lt;/p&gt;

&lt;p&gt;The immediate objection is discoverability. People find apps by searching the App Store, so if you're not in the store, you're invisible. But most app discovery doesn't actually happen through store browsing; it happens through web search, social media, ads, and 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. For enterprise and B2B products, store discovery was never relevant to begin with. The discoverability argument is narrower than it sounds, and it gets narrower every year as deep links, QR codes, and social sharing put users directly into web experiences without a store in between.&lt;/p&gt;

&lt;p&gt;The pragmatic strategy might be web-first. Build for the browser as the default platform, and only build native when a specific capability genuinely can't be delivered through the web. 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;p&gt;Cost, velocity, and agility shouldn't be values we only demand from our backend infrastructure. The same expectations that drove the industry from on-premises servers to cloud should apply to how we build and deliver client software. Native apps aren't going away, and they shouldn't. But we should be progressing toward both efficiency and sustainability rather than accepting a status quo where one company's business model determines 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 operation teams ignored them, and 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 observability failures alone caused 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 the problem was never the tooling. 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
&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. The card was declined because it should have been declined. 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 real 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. Alert fatigue is the predictable consequence.&lt;/p&gt;

&lt;p&gt;The fix is upstream of your alerting platform:&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, or rate limiting. 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 succeeding after multiple attempts, response times approaching SLA thresholds, connection pools running hot. This is WARN: not broken yet, but worth watching 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.&lt;/li&gt;
&lt;/ul&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; 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;This is one of the places where result types earn their keep. 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 throws an exception. The distinction is explicit at the point where it matters most, and logging infrastructure can respect it without guessing.&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. Alerts become actionable because they fire only for conditions that demand 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. No monitoring platform compensates for what the code got wrong at the source.&lt;/p&gt;

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

&lt;p&gt;Getting the classification right is only half of it. The other half is what you include when something does fail.&lt;/p&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 log is not a dump file. Every bug I've seen diagnosed from trace logs involved information that should have already been in the error or warning itself. The problem was never insufficient logging volume; it was that nobody authored the context where it mattered.&lt;/p&gt;

&lt;p&gt;What actually solves bugs is understanding what the user did and what they sent, not tracing the code's internal flow. If your logs carry a correlation key across services (most structured logging libraries support this out of the box) and your errors capture the operation, the input, and what went wrong, you have what you need to reproduce the problem. The approach is the same one that makes event-sourcing systems reliable: capture the context that led to a state so you can replay it. You don't need to trace every intermediate step if you can reconstruct the scenario from the input and the outcome.&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. 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. If you don't understand the domain well enough to make that distinction, that's the source of the problem, not the logging infrastructure. Trace-level logging has its place for diagnosing specific flows when you can toggle it on temporarily, but it shouldn't be your primary mechanism for understanding what your system did.&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;
  
  
  The Black Box Test
&lt;/h2&gt;

&lt;p&gt;Classification and context are design decisions, but most developers never test whether their logging actually answers the questions it needs to. One reason is the debugger habit. When something behaves unexpectedly, the instinct is to attach a debugger, set breakpoints, and step through execution rather than read the outputs.&lt;/p&gt;

&lt;p&gt;Some organizations extend this habit into production with remote debugging, but that's a security liability. Direct access to a running container, or any production process, exposes the environment regardless of the layer. You should be observing system outputs, not attaching to live processes.&lt;/p&gt;

&lt;p&gt;Production should be a black box. If your default instinct when something breaks is to attach a debugger rather than read the outputs, you'll never 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, build the discipline naturally. They feel the pain of vague errors and missing context firsthand, and they fix it at the source because they have no other option.&lt;/p&gt;

&lt;p&gt;The practical test is straightforward: 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;That core discipline compounds 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. The feedback loop between writing code and living with it in production is what makes classification honest, context intentional, and alerts worth waking up for.&lt;/p&gt;

&lt;p&gt;Better tooling alone won't create that loop; only ownership will.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>devops</category>
      <category>architecture</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>How Shared Libraries Become Shared Shackles</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Tue, 03 Feb 2026 16:36:37 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/how-shared-libraries-become-shared-shackles-3f5o</link>
      <guid>https://dev.to/stevenstuartm/how-shared-libraries-become-shared-shackles-3f5o</guid>
      <description>&lt;p&gt;This is a highly opinionated take on shared libraries and the damage they do to team autonomy and development tempo.&lt;/p&gt;

&lt;p&gt;Teams deliver value faster and more consistently when they can make decisions, ship changes, and evolve their domains without coordinating across organizational boundaries. Shared libraries erode exactly that independence.&lt;/p&gt;

&lt;p&gt;The principle applies anywhere domains and teams need independence, but this post focuses on distributed architectures because that's where the consequences are most severe. When independently deployable components, owned and operated by different teams, get bound together by shared packages, those packages undermine the very independence the architecture was designed to provide.&lt;/p&gt;

&lt;p&gt;After seeing costs explode for trivial tasks and critical production updates failing to deliver on time in nearly every organization I have witnessed, I am willing to take a rather "extreme" stance on the subject.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared Libraries Violate Core Principles
&lt;/h2&gt;

&lt;p&gt;Distributing components isn't just about distributing work. It's about the Single Responsibility Principle applied at the system level: clear ownership, implementation isolation, and infrastructural independence. These benefits are often implicit in the decision to distribute, but they're the whole point. The share-nothing principle makes this explicit. Services should be autonomous, independently deployable, and free from implementation coupling. When services share nothing, teams can deploy, scale, and evolve on their own terms, at their own tempo.&lt;/p&gt;

&lt;p&gt;Shared libraries violate these principles. They couple teams through shared implementation despite being distributed in name, creating little monoliths that bind development tempo across teams that were meant to operate independently. What's at stake isn't code organization; it's each team's ability to make decisions, ship changes, and evolve their domain without waiting on teams that have different priorities and different timelines.&lt;/p&gt;

&lt;p&gt;Yet the pitch keeps coming: "We have this code in three places. Let's consolidate it into a shared library. We'll save time, ensure consistency, and make everyone's life easier." It sounds reasonable, it really does, yet it ignores decades of architectural pain and lessons learned. The decision only calculates the cost of duplication while potentially ignoring or incorrectly calculating the cost of sharing across teams, domains, and technical boundaries.&lt;/p&gt;

&lt;p&gt;There are important distinctions to draw here, like external libraries versus internal ones, SDKs versus shared packages, and whether this applies beyond distributed systems. We'll address all of those. But first, the costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Costs Nobody Calculates
&lt;/h2&gt;

&lt;p&gt;When someone proposes a shared library, they calculate the savings: "This code exists in five services. If we consolidate, we only maintain it once."&lt;/p&gt;

&lt;p&gt;What they don't always sufficiently calculate:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version conflicts and upgrade pain.&lt;/strong&gt; Five teams (at worst) now depend on your library. They release on different cadences and at some point one or more teams require a breaking change. Now you're either maintaining multiple versions indefinitely or forcing upgrades on teams that have other priorities. The "one place to maintain" becomes "one place that blocks everyone."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teams blocked waiting for changes.&lt;/strong&gt; A team needs functionality the library doesn't have. They can't just add it. They need to coordinate with the library owners, get the change approved, wait for a release, and then upgrade. What would have been a two-hour change becomes a two-week dependency chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debugging across boundaries.&lt;/strong&gt; When something breaks, the investigation now spans your code and the library code. Your team doesn't own the library. Maybe they don't fully understand it. The abstraction that was supposed to simplify their lives has added a layer they have to dig through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bloat or fragmentation, pick your poison.&lt;/strong&gt; The library starts focused. Then another team needs something slightly different. Then another. The library accumulates features to serve multiple masters, becoming a grab-bag of loosely related functionality coupled together because they share a package, not because they belong together. The disciplined alternative is to split it into many small, focused packages, but that creates its own problem: an entourage of dependencies that each consuming team must track, version, and coordinate with. Instead of one bloated library blocking you, ten focused ones collectively recreate the same burden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Obscured accountability.&lt;/strong&gt; Shared libraries don't reduce your quality burden; they move it somewhere less visible. If the library has a bug, your service has a bug. Every service still needs its own load testing, chaos testing, penetration testing, and UAT regardless of whether the underlying code is shared or duplicated. The library doesn't absorb responsibility for your service's behavior. It just adds a dependency you don't own and can't fully verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cohesion and Coupling Diagnosis
&lt;/h2&gt;

&lt;p&gt;If two services genuinely need the same function, you have three possibilities:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's a cohesion problem.&lt;/strong&gt; That function belongs in one place and should be called, not duplicated. Extract it into a service with an API. Now there's a clear owner, a clear contract, and no shared implementation coupling consumers together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's a coupling problem.&lt;/strong&gt; You've drawn your boundaries wrong. The services that "need" the same code are actually more related than you thought. Reconsider where the boundary belongs rather than papering over the boundary violation with a shared dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's genuinely independent.&lt;/strong&gt; The similarity is coincidental. Both services need to format dates or parse JSON or validate email addresses. Copy the code. Move on. The duplication costs less than the coordination, and the implementations can evolve independently as each service's needs diverge.&lt;/p&gt;

&lt;p&gt;A shared library is almost never the right answer because the problem it solves (duplicated code) rarely justifies the problems it creates (coupling, versioning, blocked teams).&lt;/p&gt;

&lt;p&gt;The common rebuttal is "but if there's a bug, I fix it once and it propagates everywhere." Consider what code that would actually be in a well-architected distributed system. Cross-cutting concerns like logging, networking, and observability are handled by infrastructure through sidecars and service meshes. Security is already an acknowledged exception. Third-party libraries have their own maintenance cycles. What remains is business logic, and if your business logic is so coupled across services that a single bug requires simultaneous fixes everywhere, you don't have a sharing problem, you have a boundary problem, which brings you back to the diagnosis above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't Reinvent the Wheel vs. Don't Share Internal Types
&lt;/h2&gt;

&lt;p&gt;There's a meaningful distinction between using established external libraries and sharing internal abstractions.&lt;/p&gt;

&lt;p&gt;Using mature, well-tested libraries for universal problems makes sense. Logging frameworks, HTTP clients, serialization libraries, and authentication middleware exist because these problems are universal and well-understood. Someone else solved them better than you would, and the cost of depending on their solution is low because the solution is stable.&lt;/p&gt;

&lt;p&gt;Sharing your internal &lt;code&gt;CustomerDto&lt;/code&gt; across services is different. Sharing your "standard" repository pattern is different. Sharing your domain models between bounded contexts is different. These aren't universal problems with stable solutions. They're your internal abstractions, and forcing them on other teams assumes those teams should think the same way you do.&lt;/p&gt;

&lt;p&gt;The distinction matters: external libraries abstract universal problems. Internal shared libraries impose your specific mental model on teams that might have legitimately different needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  SDKs Are Different
&lt;/h2&gt;

&lt;p&gt;There's also an important distinction between shared libraries and SDKs published for external consumers.&lt;/p&gt;

&lt;p&gt;An SDK abstracts what you expose: the public contract of a service or platform. A good SDK earns its existence by encoding integration complexity that would be expensive and error-prone for every consumer to reimplement: orchestrating multi-step workflows, managing state across API calls, handling idempotency, and abstracting version differences. The value isn't hiding HTTP calls (documentation handles that); it's centralizing integration logic complex enough to justify the maintenance cost across supported runtimes.&lt;/p&gt;

&lt;p&gt;An SDK also has a different lifecycle. The platform is built first; the SDK comes afterward for a different audience. Its development and release cycles are separate from the internal teams building features, because the dynamics with external customers differ from the dynamics between internal teams.&lt;/p&gt;

&lt;p&gt;A shared library abstracts how you think internally: your domain models, your patterns, your "standard way" of doing things. It exists because someone decided other teams should think the same way. The shared library serves a governance impulse, not the consumer. And unlike an SDK, it tries to couple internal teams to the same release cycle and the same implementation decisions.&lt;/p&gt;

&lt;p&gt;The SDK says: "Here's how to use our thing."&lt;br&gt;
The shared library says: "Here's how you should build your thing."&lt;/p&gt;

&lt;p&gt;One is a service to consumers. The other is an imposition on autonomous teams disguised as help.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your Runtime Already Solved This
&lt;/h2&gt;

&lt;p&gt;The shared library pitch often targets "utility code" that your runtime already provides. If you're using .NET, the framework gives you HTTP clients, JSON serialization, logging abstractions, dependency injection, and configuration management. Why would you need an internal shared library wrapping &lt;code&gt;HttpClient&lt;/code&gt; when &lt;code&gt;HttpClient&lt;/code&gt; exists and is battle-tested by millions of applications?&lt;/p&gt;

&lt;p&gt;The urge to share usually targets exactly this kind of code: wrappers, helpers, and utilities that add a thin layer over framework primitives. But the framework primitives are already shared. They're already tested. They're already documented. Your wrapper just adds coordination overhead on top of something that didn't need wrapping.&lt;/p&gt;

&lt;p&gt;This varies by ecosystem. For example, Python's dependency management is notoriously painful, and shared internal libraries compound the problem. You're coordinating versions across teams in an ecosystem that already struggles with version conflicts. The runtime that makes sharing easiest is often the one where sharing is least necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Principle Is Broader Than Distribution
&lt;/h2&gt;

&lt;p&gt;An obvious question: if shared libraries are a problem in distributed systems, were they also a problem in the modular monolith that preceded them?&lt;/p&gt;

&lt;p&gt;Wherever different teams own different domains, yes. In a modular monolith, shared packages between domains still couple teams to the same change cycles. The difference is severity. In a monolith, the blast radius is contained: teams share a deployable and version conflicts manifest as build errors rather than runtime failures. The pain is real but manageable. In a distributed system, that same coupling spans deployment pipelines, release cadences, and versioning strategies. A change that would have been a merge conflict in a monolith becomes a multi-team coordination effort with blocked releases and stale dependencies.&lt;/p&gt;

&lt;p&gt;Layered architectures sidestep this by design because layers already enforce separation; sharing across layers is a violation of the architecture itself, not a shared library problem. But in domain-oriented architectures, the discipline matters regardless of deployment topology. If Domain A and Domain B need to evolve independently, coupling them through shared implementation undermines that independence whether they're projects in the same solution or services in different repositories.&lt;/p&gt;

&lt;h2&gt;
  
  
  No Architecture Style Wants This
&lt;/h2&gt;

&lt;p&gt;The shared library pitch assumes that code reuse across boundaries is inherently valuable. But examine any coherent architectural paradigm and the opposite becomes clear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layered architecture&lt;/strong&gt; separates concerns into distinct layers. If your presentation layer and your data layer share a library, you've coupled what you explicitly designed to be independent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Domain-driven architecture&lt;/strong&gt; creates autonomous domains with clear boundaries. If Domain A and Domain B share implementation code, they're not really autonomous. They're a distributed monolith with extra steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Functional/technical architecture&lt;/strong&gt; defines components accessed through explicit interfaces. The behavior should live in a component that others call, not in a library that everyone imports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Polyglot architectures make it worse.&lt;/strong&gt; The shared library pitch assumes a homogeneous technology landscape that rarely exists. If your organization has services in C#, Java, Python, and Go, do you maintain and keep four versions of every shared library in sync? In polyglot environments, the "shared" library becomes a second-class citizen in every language except the one the authoring team actually uses. The promise of consistency becomes a guarantee of inconsistency across language boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API Client Library Obsession
&lt;/h2&gt;

&lt;p&gt;The most common incarnation of shared library dysfunction is the API client package: a library containing contracts, DTOs, and client code that consumers are expected to import when calling your service. I have never seen this pattern result in anything short of chaos.&lt;/p&gt;

&lt;p&gt;The pitch sounds reasonable: "We'll publish a client library so consumers don't have to write their own HTTP calls or define their own contracts." But this solves a problem that doesn't exist while creating several that do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every API should have documentation describing its contracts.&lt;/strong&gt; If your API is well-documented with clear schemas, consumers can generate or write their own clients trivially. The documentation is the contract. A client library doesn't replace documentation; it's a poor substitute for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every consumer has different needs.&lt;/strong&gt; Service A might need three fields from one endpoint. Service B might need ten fields from a different endpoint. Service C might need to call the same endpoint but transform the response differently. When you force everyone to use your client library, you're imposing your view of how your API should be consumed. But consumers know their own needs better than you do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client libraries confuse application concerns with infrastructure concerns.&lt;/strong&gt; Teams building client libraries inevitably add caching strategies, retry policies, circuit breakers, and connection pooling configurations. These aren't client concerns. They're infrastructure concerns that belong in service meshes, sidecars, and API gateways where they can be configured, observed, and tuned without redeploying applications.&lt;/p&gt;

&lt;p&gt;A client library buries these decisions in application code where they're invisible to operations and impossible to change without a coordinated release across every consumer. The library author predicts traffic patterns and failure modes as if every consumer will behave identically. They won't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The absurdity becomes obvious with frontend consumers.&lt;/strong&gt; Nobody would publish an npm package for their React app to import API contracts, or a Swift package for iOS. Frontend teams read documentation, call endpoints, and map responses to whatever structures suit their application. Backend services have the same needs. The consumer's requirements don't change based on what language they're written in.&lt;/p&gt;

&lt;p&gt;This reflexive reach for client libraries has been conditioned by years of cargo-culting patterns from contexts where they made sense (public cloud SDKs with complex auth flows) into contexts where they don't (internal services with straightforward REST endpoints). It's a tax on every consumer and a maintenance burden on every producer, justified by an efficiency that never materializes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Governance Theater Problem
&lt;/h2&gt;

&lt;p&gt;Shared libraries often emerge from a governance impulse: "Teams are doing things inconsistently. We need to standardize."&lt;/p&gt;

&lt;p&gt;The instinct isn't wrong, and consistency matters. But shared libraries are governance theater. They create the appearance of consistency without addressing the underlying problem.&lt;/p&gt;

&lt;p&gt;If teams are building things inconsistently, the question is why. Usually it's because they don't share the same understanding of what matters, what the tradeoffs are, and what "good" looks like. That's an alignment problem. It requires conversation, documentation, and shared values.&lt;/p&gt;

&lt;p&gt;Forcing everyone to use the same library doesn't create alignment. It creates compliance. Teams will use your library and still build inconsistent systems because the library doesn't encode the thinking and testing.&lt;/p&gt;

&lt;p&gt;Governance through values: "Here's why we authenticate this way, here are the tradeoffs, here's what we're optimizing for. Align your implementation to these principles."&lt;/p&gt;

&lt;p&gt;Governance through code: "Use this library or you're non-compliant."&lt;/p&gt;

&lt;p&gt;The first creates alignment while preserving autonomy. Teams understand the principles and can make good decisions in novel situations. The second creates coupling while providing the illusion of alignment. Teams comply without understanding, and the moment they hit a situation the library doesn't cover, they're lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Exception: Security Protocols
&lt;/h2&gt;

&lt;p&gt;There's one domain where shared libraries make sense. Shared libraries can work for security protocols like ingress handling, service-to-service authentication, and encryption standards.&lt;/p&gt;

&lt;p&gt;Why security is different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The domain is stable and well-understood.&lt;/strong&gt; Authentication patterns don't change week to week. The library doesn't need constant evolution to serve its consumers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cost of getting it wrong is catastrophic.&lt;/strong&gt; Security isn't a place for teams to make independent decisions and learn from mistakes. The blast radius is too large.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The surface area is thin and focused.&lt;/strong&gt; A good security library does one thing. It's not a grab-bag of utilities that grows to serve multiple purposes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autonomy isn't the goal.&lt;/strong&gt; You actually want teams to do security the same way. The coupling is a feature, not a bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even here, the library should be as minimal as possible. Provide the security primitive and get out of the way. The moment it starts accumulating "helpful" utilities beyond its core purpose, it's sliding toward the problems that plague other shared libraries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Do Instead
&lt;/h2&gt;

&lt;p&gt;When you feel the urge to create a shared library, pause and diagnose the actual problem:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If it's a capability multiple services need:&lt;/strong&gt; Build a service, not a library. Expose an API. Now there's clear ownership, independent deployment, and consumers that can't get version-locked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If it's a pattern you want to standardize:&lt;/strong&gt; Write documentation. Explain the principles, the tradeoffs, and the reasoning. Let teams implement the pattern in their own codebases. They'll understand it better than if they'd just imported your abstraction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If it's truly just duplicated code:&lt;/strong&gt; Let it be duplicated. The coordination cost of sharing exceeds the maintenance cost of duplication. And the duplicates can evolve independently as needs diverge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If it's a security primitive:&lt;/strong&gt; Fine. Build the library. Keep it minimal, stable, and focused. Recognize it's a necessary evil, not a model to emulate.&lt;/p&gt;

&lt;p&gt;The shared library is a solution to a problem that rarely exists in the form people imagine. Code duplication isn't what slows teams down. Coordination overhead is. Obsessing over shared code compliance and version alignment diverts attention from what actually produces consistency: shared understanding of principles, tradeoffs, and what "good" looks like. Teams that understand the reasoning make good decisions without needing a library to make decisions for them.&lt;/p&gt;

&lt;p&gt;Share values, and the shared library more often becomes unnecessary.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>microservices</category>
      <category>dotnet</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Making Invalid States Unrepresentable: The Billion-Dollar Mistake That Wasn't</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Thu, 29 Jan 2026 23:39:45 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/making-invalid-states-unrepresentable-the-billion-dollar-mistake-that-wasnt-2b6m</link>
      <guid>https://dev.to/stevenstuartm/making-invalid-states-unrepresentable-the-billion-dollar-mistake-that-wasnt-2b6m</guid>
      <description>&lt;p&gt;The billion-dollar mistake. That's what Tony Hoare called his invention of the null reference in 1965. The quote gets repeated so often that "null is dangerous" has become conventional wisdom, especially among entry-level and intermediate developers who hear it as dogma without understanding the context or the alternatives that can be far worse.&lt;/p&gt;

&lt;p&gt;But I think we're blaming the wrong villain. Null may have saved far more than it ever cost. Every null reference exception that crashed a system may also have prevented that system from proceeding with corrupted data and invalid logical decisions. The billion-dollar mistake framing counts the crashes but ignores the corruption that never happened. You can count the cost of bug fixes, but what about the disasters those "bugs" prevented?&lt;/p&gt;

&lt;p&gt;Information security professionals value the CIA triad of Confidentiality, Integrity, and Availability. Software developers tend to obsess over availability, and that's understandable since a crashed service is visible, embarrassing, and can violate business SLAs. But integrity failures are far worse. Data that looks valid but isn't can corrupt your system just as surely as SQL injection or a man-in-the-middle attack. The corruption just compounds slower and is harder to detect. This is the lens through which the null debate should be understood.&lt;/p&gt;

&lt;p&gt;The most common way that developers try to avoid the null issue entirely is to implement default values. However, default values may allow invalid data to flow silently through the system until it corrupts something important. Null crashes loudly at the point of misuse, which is an availability problem you can see and fix. A default value that masks missing data? That proceeds quietly until it causes a security vulnerability, a financial miscalculation, or data corruption that might require weeks to detect and more to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Making Invalid States Unrepresentable" Actually Means
&lt;/h2&gt;

&lt;p&gt;The phrase comes from type theory and functional programming, but the concept is practical: structure your data so that invalid combinations cannot exist. Invalid states should fail at construction time, not at runtime deep in business logic.&lt;/p&gt;

&lt;p&gt;Consider a user registration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRegistration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Password&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&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;This class allows every invalid state imaginable. Empty email, empty password, any combination. The defaults make it easy to construct an object that looks valid but isn't. Code that receives this object has no way to know whether the empty string represents "not provided" or "explicitly set to empty" or "bug in upstream code."&lt;/p&gt;

&lt;p&gt;Compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRegistration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Password&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;UserRegistration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Email is required"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Password is required"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;Password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;;&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;Now invalid states cannot be constructed. There's no default email that masks a missing value. There's no way to create a registration without providing required data. The validation happens once, at the boundary, and everything downstream can trust the object is valid.&lt;/p&gt;

&lt;p&gt;C# 11 introduced the &lt;code&gt;required&lt;/code&gt; keyword, which moves this enforcement to compile time for simpler cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRegistration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Password&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&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 compiler refuses to let you construct a &lt;code&gt;UserRegistration&lt;/code&gt; without setting both properties. This is "making invalid states unrepresentable" at its purest: the invalid state literally cannot be expressed in code that compiles.&lt;/p&gt;

&lt;p&gt;The distinction between &lt;code&gt;required&lt;/code&gt; and constructor validation is straightforward: &lt;code&gt;required&lt;/code&gt; enforces &lt;em&gt;presence&lt;/em&gt;, constructors enforce &lt;em&gt;validity&lt;/em&gt;. Use &lt;code&gt;required&lt;/code&gt; when presence is all you need. Use constructors when you need validation logic, like checking that the email contains an &lt;code&gt;@&lt;/code&gt; or that the password meets complexity requirements.&lt;/p&gt;

&lt;p&gt;However, &lt;code&gt;required&lt;/code&gt; works best for internal domain objects and state that you control. At system boundaries where data arrives via deserialization, serializers and ORMs use parameterless constructors and set properties via reflection, bypassing the compile-time enforcement entirely. The same applies to some dependency injection containers and mocking frameworks. For API contracts and external data, you still need runtime validation with &lt;code&gt;[Required]&lt;/code&gt; attributes or explicit checks. The compiler enforcement is powerful, but it only reaches code paths that go through normal construction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the Problem Usually Starts: API Contracts
&lt;/h3&gt;

&lt;p&gt;The domain model above is clean, but most developers encounter this tension at the API boundary first. Consider a typical request DTO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateUserRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Password&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;RegisterForAlerts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&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 &lt;code&gt;[Required]&lt;/code&gt; attribute signals intent, but the developer adds &lt;code&gt;= ""&lt;/code&gt; out of habit or a misguided sense of defensive coding. Now there's a contradiction: the attribute says "required" while the code says "default to empty string."&lt;/p&gt;

&lt;p&gt;When JSON is deserialized, a missing field becomes null because the serializer doesn't know about your default. The &lt;code&gt;= ""&lt;/code&gt; only takes effect when code constructs the object directly, bypassing deserialization entirely. So the default creates a split: API calls get null (correctly rejected by &lt;code&gt;[Required]&lt;/code&gt;), but test code or internal construction gets empty string (silently accepted). The &lt;code&gt;[Required]&lt;/code&gt; attribute is runtime validation, not compile-time enforcement.&lt;/p&gt;

&lt;p&gt;The fix is simple: don't add the default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateUserRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Password&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;//The business could decide that this should default to false but test that assumption first!&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;RegisterForAlerts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&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 &lt;code&gt;[Required]&lt;/code&gt; attribute ensures the framework validates these fields before your code ever touches them. If validation is bypassed and the property is accessed while null, you get a null reference exception rather than an empty string that looks valid. Null forces handling; the empty string would have propagated silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Defaults Are More Dangerous Than Null
&lt;/h2&gt;

&lt;p&gt;Default values create four categories of problems that null avoids.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent propagation of invalid state.&lt;/strong&gt; When a required field defaults to an empty string or zero, the invalid state propagates through the system. Each layer assumes the previous layer validated the data. Nobody validated it because it never looked invalid. The corruption accumulates until something finally breaks far from the source.&lt;/p&gt;

&lt;p&gt;Consider a payment processing system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;MerchantId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&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;A bug upstream fails to set the amount. The payment proceeds with &lt;code&gt;Amount = 0&lt;/code&gt;. No crash, no exception, no alert. The transaction logs show a valid-looking payment. Days later, someone notices revenue is wrong. The investigation takes hours because nothing obviously failed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ambiguous semantics.&lt;/strong&gt; Does &lt;code&gt;Amount = 0&lt;/code&gt; mean "free transaction," "not set," or "bug"? Does &lt;code&gt;Email = ""&lt;/code&gt; mean "user declined to provide" or "form field wasn't rendered"? Default values overload meaning. Null is unambiguous: this value is absent.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A default value claims knowledge it doesn't have. Null admits ignorance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This ambiguity becomes critical in update operations. When a client submits an update request, the API needs to distinguish between "set this field to empty" and "don't touch this field." Without null, there's no way to express that distinction. Entity Framework relies on this exact semantic: when you load an entity without its relationships, those navigation properties are null. EF interprets null as "not loaded, don't modify" rather than "delete all relationships." If null didn't exist, EF would need the client to re-submit every relationship on every update, or every API would need to accept key-value collections instead of typed objects. Null isn't just tolerable here; it's the simplest solution to a problem that has no good alternatives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation bypass.&lt;/strong&gt; Code that checks &lt;code&gt;if (amount != null)&lt;/code&gt; correctly identifies missing data. Code that checks &lt;code&gt;if (amount != 0)&lt;/code&gt; conflates "missing" with "zero." Legitimate zero values become impossible to represent. Business logic contorts to handle the ambiguity that defaults introduced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security vulnerabilities.&lt;/strong&gt; Default values can silently create security holes. Consider a &lt;code&gt;RateLimitPerMinute&lt;/code&gt; field that defaults to &lt;code&gt;0&lt;/code&gt;. In some systems, zero means "no limit," so a malformed request that should be rejected instead gets unlimited access. Or a &lt;code&gt;Permissions&lt;/code&gt; string that defaults to empty, which a downstream parser interprets as "inherit all permissions from parent." The request looked valid, passed through every layer, and granted access it shouldn't have. With null, the missing field would have forced explicit handling: reject the request, require the field, or make a conscious decision about what absence means.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Billion-Dollar Mistake
&lt;/h2&gt;

&lt;p&gt;Hoare called null his billion-dollar mistake, and the criticism was valid for its time. ALGOL W and its descendants treated every reference as implicitly nullable. There was no type-level distinction between "this can be null" and "this is never null." The compiler couldn't help you, and nothing forced developers to consider absence. In that context, null was dangerous because the type system provided no guardrails.&lt;/p&gt;

&lt;p&gt;But modern type systems solved this problem without eliminating null. C# 8.0 introduced nullable reference types that distinguish &lt;code&gt;string&lt;/code&gt; (never null) from &lt;code&gt;string?&lt;/code&gt; (might be null). Kotlin distinguishes &lt;code&gt;String&lt;/code&gt; from &lt;code&gt;String?&lt;/code&gt;. TypeScript has strict null checks. These languages preserve null's benefits while adding type safety. The billion-dollar mistake wasn't null itself; it was nullable references in type systems that didn't require handling.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hoare's mistake wasn't inventing null. It was inventing null without inventing &lt;code&gt;string?&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The mistake we keep making today is different. It's the pattern of masking errors with defaults instead of failing fast. Every system that returned &lt;code&gt;-1&lt;/code&gt; instead of throwing an exception. Every API that substituted empty arrays for error responses. Every constructor that initialized required fields to placeholder values. These patterns hide bugs rather than reveal them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counterarguments and When Defaults Make Sense
&lt;/h2&gt;

&lt;p&gt;This isn't a blanket condemnation of all default values. Some counterarguments deserve serious consideration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Null reference exceptions are the most common runtime error."&lt;/strong&gt; They are, and that's actually the point. Null crashes at the point of use when absence wasn't handled upstream, revealing the bug rather than hiding it. The frequency of null reference exceptions reflects how often code fails to handle absent values, not a flaw in null itself. The alternative isn't fewer bugs; it's bugs that manifest as data corruption instead of crashes.&lt;/p&gt;

&lt;p&gt;But a high frequency of null reference exceptions also signals something deeper: continuous misalignment between the development team and stakeholders about what the system should accept and produce. Unit tests exist to test assumptions and prove agreement in both application logic and API contracts. If null reference exceptions keep appearing, the team hasn't captured those agreements in tests, or the agreements themselves are unclear. The exceptions are symptoms of a collaboration problem, not just a coding problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Option/Maybe types are strictly better than null."&lt;/strong&gt; For representing intentional absence, they genuinely are better. &lt;code&gt;Option&amp;lt;User&amp;gt;&lt;/code&gt; makes it explicit that a user might not exist, and pattern matching forces you to handle both cases. Functional programmers rightly point out that &lt;code&gt;Option.getOrElse(default)&lt;/code&gt; is a code smell because the whole point is to force handling, not to provide an escape hatch.&lt;/p&gt;

&lt;p&gt;But this proves my argument rather than refuting it. Option types work precisely because they make you handle absence explicitly. They fail at compile time if you ignore the &lt;code&gt;None&lt;/code&gt; case. That's the same principle I'm advocating: force handling, don't mask absence. The problem isn't null versus Option. It's whether your system forces you to confront missing data or lets you paper over it. An Option that returns a default value on &lt;code&gt;None&lt;/code&gt; has the same problem as a nullable field with a default. Most runtimes depend on null, and used correctly with modern type systems, it fulfills the same purpose that Option types serve in functional languages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Defensive programming means providing safe defaults."&lt;/strong&gt; This conflates two different concerns. Resilience at system boundaries means handling malformed external input gracefully, but that's different from masking bugs internally. External APIs should validate input and return clear errors. Internal code should fail fast on invalid state. Providing "safe" defaults inside the system just moves the failure somewhere harder to diagnose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Users shouldn't see crashes."&lt;/strong&gt; Correct, which is why you handle errors at system boundaries. But the crash should still happen internally. Catch exceptions at the API layer, log the details, return a user-friendly error. The internal crash gave you the information to fix the bug. A silent default would have hidden it. And consider: even in runtimes that avoid null entirely, you still need this exception handling infrastructure. Network failures, file system errors, out-of-memory conditions, and database constraint violations all throw exceptions regardless of your null strategy. The boundary handling you need for those exceptional failures handles null reference exceptions too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Some fields genuinely have sensible defaults."&lt;/strong&gt; True. A &lt;code&gt;CreatedAt&lt;/code&gt; timestamp defaulting to &lt;code&gt;DateTime.UtcNow&lt;/code&gt; makes sense. A &lt;code&gt;RetryCount&lt;/code&gt; defaulting to &lt;code&gt;0&lt;/code&gt; represents legitimate initial state. The distinction is between defaults that represent valid initial state versus defaults that mask missing required data. Configuration values, counters, and timestamps often have legitimate defaults. User-provided data, external inputs, and required business fields typically don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exceptions vs. Result Types
&lt;/h2&gt;

&lt;p&gt;If failing loudly is the goal, why not use exceptions everywhere? The distinction is semantic: exceptions for bugs, Result types for expected outcomes.&lt;/p&gt;

&lt;p&gt;A null on a required field represents a violated constraint, something the system was promised it wouldn't receive. That's a bug. The correct response is to crash, log, and fix the code. A Result type represents an expected domain outcome: "user not found" or "validation failed" aren't bugs, they're legitimate results that correct code produced from valid input.&lt;/p&gt;

&lt;p&gt;Ask whether the failure represents a bug or a legitimate outcome. If correct code with valid input could produce this result, use a Result type. If not, fail fast with an exception. Both approaches force handling; neither lets you ignore failure and proceed with corrupted state. The danger is when either mechanism gets misused to mask absence: catching exceptions and substituting defaults, or calling &lt;code&gt;Result.GetValueOrDefault()&lt;/code&gt; without handling the failure case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Construction vs. Consumption
&lt;/h2&gt;

&lt;p&gt;The confusion around null often stems from conflating two different phases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At construction time&lt;/strong&gt;, invalid states should fail immediately. Required fields should not have defaults that mask their absence. Validation should happen once, at the boundary, with clear errors for invalid input. Objects that exist should be valid by construction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At consumption time&lt;/strong&gt;, code shouldn't need to check validity. If an object exists, it's valid. The null checks happen at construction and boundaries. Internal code that receives a &lt;code&gt;UserRegistration&lt;/code&gt; shouldn't need to re-validate the email because the constructor already guarantees it's present and valid.&lt;/p&gt;

&lt;p&gt;This unsettles developers who've been taught to validate defensively at every layer. But spreading validation across layers is itself a source of bugs. When validation logic lives in the controller, the service, the repository, and the domain model, you've scattered what should be encapsulated business rules across your entire codebase. When validation rules change, you update three places and miss the fourth. When different layers implement slightly different rules, you get inconsistent behavior that's nearly impossible to debug. The same principle applies whether you prefer exceptions or Result types: you don't handle every possible failure at every layer. You propagate failures up to the correct boundary where they can be handled appropriately. Validation belongs at trust boundaries, not scattered throughout internal code that should be able to assume valid input.&lt;/p&gt;

&lt;p&gt;Null is dangerous when it appears unexpectedly in consumption code because construction failed to validate. Null is valuable when it represents intentional absence or when it forces construction to fail on invalid input.&lt;/p&gt;

&lt;p&gt;This doesn't mean a single validation layer. Systems have multiple trust boundaries: the API gateway, service boundaries, aggregate roots, database constraints. Each boundary validates what it needs to trust. The principle is that once data crosses a boundary and is accepted, code on the inside shouldn't re-validate it. Validate at each door, trust everyone inside that room.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Guidelines
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Enforce requirements at the correct layer.&lt;/strong&gt; At ingress boundaries (API DTOs, deserialization), fields may be nullable because input might be missing. After validation, domain objects should have non-nullable required fields because their existence proves validity. A nullable &lt;code&gt;int?&lt;/code&gt; signals "this is optional." If a value is required, it should be non-nullable in the domain model because validation already guaranteed its presence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reserve defaults for genuinely optional fields with valid initial states.&lt;/strong&gt; Retry counts, timestamps, configuration values, and accumulator fields often have legitimate defaults that represent real initial state, not masked absence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate at boundaries, trust internally.&lt;/strong&gt; System boundaries (API endpoints, message handlers, deserialization) should validate everything and reject invalid input. Internal code should trust that objects exist because they're valid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefer crashes to silent corruption.&lt;/strong&gt; A null reference exception in development or staging catches bugs immediately. A default value that hides the bug lets it reach production and corrupt data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know when to use Result types versus exceptions.&lt;/strong&gt; When an operation might legitimately fail, use Result types. When something unexpected happens, fail fast with an exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failing Loudly Is a Feature
&lt;/h2&gt;

&lt;p&gt;The real billion-dollar mistake isn't null. It's the widespread practice of substituting defaults for validation, prioritizing code that runs over code that runs correctly. Given the choice between an availability problem you can see and fix, and an integrity problem that compounds invisibly until something important breaks, I'll take the availability problem every time.&lt;/p&gt;

</description>
      <category>security</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>designpatterns</category>
    </item>
    <item>
      <title>SEO Still Works, Just Not How We Hoped</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Mon, 26 Jan 2026 17:50:45 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/seo-still-works-just-not-how-we-hoped-3nij</link>
      <guid>https://dev.to/stevenstuartm/seo-still-works-just-not-how-we-hoped-3nij</guid>
      <description>&lt;p&gt;A few months ago, I read a post about how SEO is dead and that we need to let the past die and find greener pastures. I'm finally getting around to writing my thoughts on it. That contention was correct in many ways, but it also drew an undeserved binary between SEO and social media as two means to the same end. I think that framing misses the point.&lt;/p&gt;

&lt;p&gt;SEO does have a real problem. The timeline to build authority hasn't changed (it still takes 2-5 years), but the gap keeps widening. A startup in 2015 competed against sites with 5-10 years of accumulated authority. A startup in 2025 competes against sites with 15-25 years. Authority signals that once filtered spam now create insurmountable barriers for newcomers because incumbents have had decades to compound their advantages.&lt;/p&gt;

&lt;p&gt;And yet, SEO still works perfectly well for what it has become good at. Search for CNN, Mayo Clinic, or Amazon and established brands rank exactly as designed. When you need something tested and proven and you already know what you're looking for, SEO delivers authority and trust.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The answer isn't choosing between SEO and social channels. It's recognizing what each does well: social channels for democratic reach and speed, SEO for authority and trust.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you are just getting your start as a business or source of information, you should play all angles. Grow quickly through the more democratic and expedited nature of social platforms while positioning yourself to benefit from SEO's authority signals as they compound over time. The binary framing gets it wrong because it treats these as competing alternatives rather than complementary strategies serving different purposes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Changed
&lt;/h2&gt;

&lt;p&gt;What changed isn't SEO's rules; it's business expectations. In the 2010s, investors tolerated long growth curves. Venture capital funded multi-year SEO strategies. By 2020, interest rates rose, funding contracted, and investors demanded profitability over growth. Show traction in 6 months or you're dead. SEO's timeline didn't adjust. The business environment did.&lt;/p&gt;

&lt;p&gt;So businesses fragmented discovery. When you need customers in 6 months and SEO takes 3 years, you adopt whatever works now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parasitic SEO&lt;/strong&gt;: Publish on Medium, LinkedIn, Substack, and dev.to to piggyback on domains that already rank. You sacrifice ownership, but gain immediate visibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Social-first distribution&lt;/strong&gt;: TikTok, YouTube, and Instagram function as discovery mechanisms independent of traditional search. Engagement elevates content immediately rather than waiting years for authority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Community platforms&lt;/strong&gt;: Discord, Reddit, and Hacker News provide direct access to target audiences without algorithmic intermediaries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct advertising&lt;/strong&gt;: When organic timelines don't align with business needs, companies pay for visibility through Google Ads and social media advertising.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flood toward alternative platforms doesn't prove SEO is broken; it proves businesses can't wait for authority to compound.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Broken Promise of Democratization
&lt;/h2&gt;

&lt;p&gt;SEO never delivered on the promise of democratization. Tim Berners-Lee designed the web with an explicit rejection of gatekeeping, making it royalty-free and open so anyone could publish and be found. Google's PageRank promised the "democracy of the web", where community links would surface quality over editorial control.&lt;/p&gt;

&lt;p&gt;But those democratic "votes" became the gatekeeping mechanism they were supposed to replace. Links became currency to game. Authority signals made sense when the web was young and spam was rampant, but they compounded over time into insurmountable advantages for incumbents.&lt;/p&gt;

&lt;p&gt;The promise decayed into algorithmic gatekeeping that serves Google's advertising revenue. Search advertising generated approximately $175 billion for Google in 2023, roughly 58% of Alphabet's total revenue (&lt;a href="https://abc.xyz/investor/" rel="noopener noreferrer"&gt;Alphabet 2023 Annual Report&lt;/a&gt;). Google's search engine exists primarily to serve ads. This explains why Google killed Google Reader (which competed with web traffic), why AMP attempted to keep content within Google's ecosystem, and why search results increasingly feature Google-owned properties.&lt;/p&gt;

&lt;p&gt;This mirrors pre-internet gatekeeping, when only established publishers could reach mass audiences. The internet promised to democratize that access. SEO re-centralized it through algorithmic authority. The difference is that SEO's barriers are algorithmic and opaque. You can't argue with an algorithm or pitch your case to a human gatekeeper.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;The solution isn't "fix SEO" or "abandon SEO." SEO became constrained, but it still provides real value for authority and trust. The solution is treating discovery as genuinely multi-channel and funding it accordingly.&lt;/p&gt;

&lt;p&gt;Many organizations still treat alternative platforms as afterthoughts. They produce content for SEO, then repurpose scraps for social media and community engagement. This inverts the reality: if SEO takes years and businesses need reach now, alternative channels deserve primary investment, not leftover budget.&lt;/p&gt;

&lt;p&gt;The web has already started routing around authority-based discovery. TikTok, Discord, and Reddit elevate content through engagement rather than accumulated authority. YouTube prioritizes watch time over channel age. Hacker News can surface a blog post from an unknown developer based on upvotes alone. The alternatives exist. What's missing is the budget allocation to use them properly.&lt;/p&gt;

&lt;p&gt;SEO's trajectory follows the natural lifecycle of human systems: innovation solves a real problem, adoption scales it, power consolidates, incentives shift toward self-preservation, and the system decays until disruption restarts the cycle. SEO now optimizes for protecting its own integrity more than for democratizing discovery. But it still delivers authority and trust for established sources, and that value doesn't disappear just because the system became constrained. Whatever eventually disrupts SEO will follow the same path. Engagement-based ranking will get gamed. New gatekeepers will emerge. The cycle continues.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Take From This
&lt;/h2&gt;

&lt;p&gt;SEO has a real problem: it became more about protecting its own integrity than providing the discovery service it promised. Authority signals that once filtered spam now exclude newcomers. But SEO still excels at what it has become: a system for surfacing established, authoritative sources when you need something proven and trustworthy.&lt;/p&gt;

&lt;p&gt;The "SEO is dead" narrative gets it wrong by framing this as a binary choice. Social channels and SEO serve different purposes: social platforms provide democratic reach and speed while SEO provides authority and trust.&lt;/p&gt;

&lt;p&gt;There's another reason to pursue SEO excellence even if you won't rank soon: the standards themselves are a roadmap for good UX. Accessibility, mobile responsiveness, page speed, clear structure, quality content. These practices make your product better regardless of whether search engines reward you for them.&lt;/p&gt;

&lt;p&gt;Stop treating these as competing alternatives. Instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use social channels for democratic reach and speed; treat them as primary investment, not leftover scraps&lt;/li&gt;
&lt;li&gt;Build platform-native content for TikTok, YouTube, Discord, and Reddit&lt;/li&gt;
&lt;li&gt;Let SEO compound in the background as a long-term asset for authority and trust&lt;/li&gt;
&lt;li&gt;Recognize that newcomers need both: social to grow quickly, SEO to establish credibility over time&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>seo</category>
      <category>marketing</category>
      <category>socialmedia</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Why 'Tech Debt' Does Not Get Fixed</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Tue, 20 Jan 2026 23:49:04 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/why-tech-debt-does-not-get-fixed-1kh1</link>
      <guid>https://dev.to/stevenstuartm/why-tech-debt-does-not-get-fixed-1kh1</guid>
      <description>&lt;p&gt;Most engineering teams have a backlog of work they call "tech debt." Developers understand how it can slow down feature development, increase support costs, and threaten system stability. Yet when they bring these concerns to stakeholders, the work often stays deprioritized indefinitely. So why is that? Why would something so obviously important be ignored. In most cases, it is because the term 'tech debt' positions engineering work as backward-looking cleanup rather than forward-looking value creation. It's defensive, it's ambiguous, and it guarantees the work never gets prioritized.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Tech debt" is a self-fulfilling prophecy that perpetuates the communication gap that created it in the first place.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why "Tech Debt" Guarantees Deprioritization
&lt;/h2&gt;

&lt;p&gt;The metaphor creates the outcome everyone complains about by shaping how people think about and discuss the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The term is too ambiguous to be actionable.&lt;/strong&gt; When someone says "tech debt," what do they actually mean? Intentional tradeoffs made under time constraints? Unanticipated consequences of reasonable decisions? Outright mistakes? Code that worked fine but is now outdated due to evolving requirements? The term conflates deliberate strategy with failure, which makes it impossible to have productive conversations about what to do next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The metaphor obscures actual costs and consequences.&lt;/strong&gt; Real financial debt has clear terms: borrow $100K at 5% interest, pay it back over 5 years. "Tech debt" has no such clarity. What's the interest rate? When is it due? What happens if we don't pay it? The metaphor lets everyone avoid confronting actual costs and timelines. Without clear costs, there's no urgency, and without clear consequences, there's no accountability. Stakeholders hear "the code is messy" and think "so what?" They don't hear "we're losing $50K per month in support costs because this implementation is brittle, and we can't ship the feature roadmap because every change breaks three other things."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The debt metaphor implies inevitability.&lt;/strong&gt; "We'll always accumulate some debt; that's just how software works." This defeatist framing makes people accept poor decisions as unavoidable rather than asking "why are we making decisions without enough information?" The term normalizes dysfunction instead of demanding clarity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The term frames it as engineering's problem.&lt;/strong&gt; When you say "we have debt to pay down," stakeholders hear "you made a mess, now clean it up." This doesn't invite collaborative problem-solving. It creates an adversarial dynamic where engineering owns the problem and stakeholders reluctantly allocate time to "let them fix their mistakes."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing architectural context creates an assumption of incompetence.&lt;/strong&gt; When architectural decision records don't exist, future teams assume incompetence rather than recognizing intentional tradeoffs. The original context disappears: why this approach was chosen, what constraints existed at the time, what the intended evolution path was. Without that clarity, the current team either blindly perpetuates a bad pattern because they don't understand the original intent, or rewrites everything because they assume the previous team didn't know what they were doing. Both outcomes are expensive.&lt;/p&gt;

&lt;p&gt;Consider how different this looks with context. If the architect had documented "We chose NoSQL here because we needed to ship in 3 months with the team we had. The long-term design uses relational storage; we've isolated this behind an interface so we can swap it later without touching business logic," the team has a roadmap instead of a mystery. The architect becomes the translator between constraints, decisions, and evolution paths. Without that translation, the cycle repeats: poor communication creates problems, vague language prevents fixes, and the gap widens.&lt;/p&gt;

&lt;h2&gt;
  
  
  An Alternative: Categories That Communicate Impact
&lt;/h2&gt;

&lt;p&gt;Developers can keep using "tech debt" internally as shorthand within engineering teams, but when talking to product owners and stakeholders, retire the term entirely.&lt;/p&gt;

&lt;p&gt;One approach is to categorize work by business impact: &lt;strong&gt;Corrections&lt;/strong&gt;, &lt;strong&gt;Optimizations&lt;/strong&gt;, and &lt;strong&gt;Re-Alignments&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Corrections: Problems Causing Harm Now&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it is&lt;/strong&gt;: Mistakes, tradeoffs, or outdated decisions actively harming the business right now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Examples&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Security vulnerabilities exposing customer data&lt;/li&gt;
&lt;li&gt;Bugs causing support escalations or customer churn&lt;/li&gt;
&lt;li&gt;Reliability issues causing downtime or SLA violations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this works&lt;/strong&gt;: Stakeholders already understand bugs and security problems as priorities because they're causing measurable harm today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Language to use&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"We have a security vulnerability that exposes customer payment data. The fix takes 2 weeks."&lt;/li&gt;
&lt;li&gt;"This bug is costing us $30K per month in support escalations. Fixing it unblocks the support team."&lt;/li&gt;
&lt;li&gt;"The authentication service has 99.5% uptime. Our SLA guarantees 99.9%. The gap creates $100K annual credit exposure. Fixing the root cause takes 3 weeks and eliminates the SLA risk."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Corrections communicate urgency. The business is being hurt now, and addressing it stops the bleeding immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimizations: Improving Efficiency and Cost&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it is&lt;/strong&gt;: Mistakes, tradeoffs, or outdated decisions affecting cost, performance, or efficiency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Examples&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database queries causing slow page loads (affecting conversion rates)&lt;/li&gt;
&lt;li&gt;Infrastructure configuration costing more than necessary (budget impact)&lt;/li&gt;
&lt;li&gt;Manual deployment process taking hours per release (velocity impact)&lt;/li&gt;
&lt;li&gt;Inefficient algorithms causing excessive cloud compute costs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this works&lt;/strong&gt;: Stakeholders understand optimization as improving what exists. It's not "paying debt," it's "increasing margin" or "improving user experience."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Language to use&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Our cloud costs are $50K per month. A 3-week optimization brings that to $20K per month, saving $360K annually."&lt;/li&gt;
&lt;li&gt;"Checkout page loads in 8 seconds. Optimizing to 2 seconds increases conversion by 15% based on industry benchmarks. The work takes 4 weeks and projects to $500K additional annual revenue."&lt;/li&gt;
&lt;li&gt;"Automating deployments cuts release time from 4 hours to 15 minutes, letting us ship features faster. The automation work takes 2 weeks and doubles deployment frequency."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Optimizations communicate efficiency gains with measurable ROI. The business improves margins, performance, or velocity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-Alignments: Unlocking Future Capabilities&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it is&lt;/strong&gt;: Mistakes, tradeoffs, or outdated decisions that, when fixed, unblock new features, integrations, or business capabilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Examples&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monolithic architecture preventing independent team scaling&lt;/li&gt;
&lt;li&gt;API design preventing mobile app development&lt;/li&gt;
&lt;li&gt;Data model preventing real-time analytics feature&lt;/li&gt;
&lt;li&gt;Vendor lock-in preventing multi-cloud strategy&lt;/li&gt;
&lt;li&gt;Legacy authentication system preventing enterprise SSO integrations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this works&lt;/strong&gt;: Stakeholders understand opportunity cost. If the current architecture blocks a $2M revenue opportunity, fixing it isn't "paying debt"; it's "unlocking growth."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Language to use&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"We can't build the mobile app until we redesign the API. The redesign takes 6 weeks and unblocks a $2M annual opportunity."&lt;/li&gt;
&lt;li&gt;"Our current data model prevents real-time dashboards (top customer request). Re-aligning the schema takes 4 weeks and delivers the feature."&lt;/li&gt;
&lt;li&gt;"The monolith prevents us from scaling the checkout team independently. Splitting it out takes 8 weeks and doubles that team's velocity."&lt;/li&gt;
&lt;li&gt;"Moving to OAuth 2.0 unblocks enterprise SSO integrations. The $500K deal waiting on this capability closes once we deliver it. The migration takes 5 weeks."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Re-Alignments communicate strategic value. The business unlocks capabilities that enable growth, close deals, or meet customer demands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breaking the Cycle
&lt;/h2&gt;

&lt;p&gt;The self-fulfilling prophecy persists because both sides perpetuate it. Developers tend to use use vague language, stakeholders have little choice but to ignore those vague requests, and the cycle continues.&lt;/p&gt;

&lt;p&gt;Break it by being the solution:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replace "tech debt" with Corrections, Optimizations, and Re-Alignments when talking to stakeholders&lt;/li&gt;
&lt;li&gt;Communicate business value from the start in architectural proposals and decisions&lt;/li&gt;
&lt;li&gt;Mentor developers on translating technical concerns into stakeholder priorities&lt;/li&gt;
&lt;li&gt;Enforce quality standards at every increment through code reviews, architecture reviews, and quality gates&lt;/li&gt;
&lt;li&gt;Document decisions with ADRs so context doesn't disappear and future teams have roadmaps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These aren't debts to be paid; they're opportunities for value. If the term itself guarantees the problem, replace it.&lt;/p&gt;

</description>
      <category>leadership</category>
      <category>techdebt</category>
      <category>architecture</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Package Updates Are Investments, Not Hygiene Tasks</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Mon, 12 Jan 2026 15:13:44 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/package-updates-are-investments-not-hygiene-tasks-2166</link>
      <guid>https://dev.to/stevenstuartm/package-updates-are-investments-not-hygiene-tasks-2166</guid>
      <description>&lt;p&gt;It is time to update a third-party package in your repository, or at least to consider it. So how do you know what is safe, what is needed, what is prudent, and what will keep our company from melting down in record time? To address these questions, most teams pick one of two general reflexes: always update immediately to "stay current," or ignore updates entirely until forced.&lt;/p&gt;

&lt;p&gt;Both approaches treat package updates like chores, something to batch process or avoid. But package updates are investments. They consume time and introduce risk, which means they deserve the same deliberate evaluation you'd apply to any other technical decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Distributed Systems Uniformity Trap
&lt;/h2&gt;

&lt;p&gt;In distributed systems, a curious assumption often takes hold: all services must run the same package versions to maintain debuggability and behavioral consistency. And teams lacking clear governance see version alignment as a proxy for unity and control.&lt;/p&gt;

&lt;p&gt;This assumption fails on multiple fronts. Distributed systems with shared-nothing architectures don't gain meaningful debugging benefits from version uniformity. Service A running on library v2.1 and Service B on v2.3 rarely creates the problems teams fear. And each service operates independently, communicates through well-defined contracts, and fails or succeeds on its own terms.&lt;/p&gt;

&lt;p&gt;Version uniformity does matter in specific contexts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shared libraries and contracts&lt;/strong&gt;: When services share a common library that defines data contracts or communication protocols, mismatched versions can cause subtle serialization bugs or contract violations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security vulnerabilities&lt;/strong&gt;: When a CVE affects multiple services, coordinated updates prevent attackers from exploiting the weakest link&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework-level breaking changes&lt;/strong&gt;: When a platform upgrade (like .NET major versions) requires coordinated migration across services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Outside of these cases, enforcing uniformity wastes time and introduces unnecessary risk. Governance clarity (understanding which dependencies matter for coordination and which don't) beats version number theater. When coordination does matter, focus on the boundaries: version your APIs explicitly, pin shared contract libraries, and establish migration windows rather than demanding instant synchronization across all services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making Intentional Update Decisions
&lt;/h2&gt;

&lt;p&gt;Before updating any dependency, evaluate the change type and context. Semantic versioning provides a starting framework, but not all maintainers follow it rigorously, and even those who do sometimes misjudge what constitutes a breaking change. Read the changelog, not just the version number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Patch updates (x.y.Z)&lt;/strong&gt; should favor security fixes and critical bug patches, but verify relevance first. If a patch fixes a theoretical vulnerability in code you don't execute, the risk of updating may exceed the risk of staying put. Check whether the vulnerability applies to your usage patterns, whether the bug affects code paths you use, and whether the community has reported regressions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minor updates (x.Y.z)&lt;/strong&gt; require evaluating value against risk. New features and non-breaking changes matter only if they solve problems you have or deliver performance improvements that affect your workload. Check community adoption rates and feedback; minor updates with low adoption and thin feedback deserve skepticism. Let others find the edge cases first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Major updates (X.y.z)&lt;/strong&gt; demand a business case. Breaking changes consume significant engineering time for migration, testing, and bug fixes. The value must justify the investment. Ask what capabilities become available, what technical debt gets resolved, and what risk comes from delaying (losing vendor support, missing future security patches). Treat major updates as planned initiatives with dedicated time and clear success criteria, not as squeezed-in tasks during feature development.&lt;/p&gt;

&lt;p&gt;For any update, walk through these core questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Problem definition&lt;/strong&gt;: What specific problem does this solve (security, feature, bug, performance, vendor requirement)? If there's no clear problem, question the update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Research&lt;/strong&gt;: Review changelogs for breaking changes, deprecations, and known issues. Check security scan results. Monitor community feedback (GitHub issues, forums, Stack Overflow).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing&lt;/strong&gt;: Focus regression tests on affected code paths. Run load tests against production traffic patterns to validate SLA compliance (response times, throughput, error rates). Ensure you have a rollback plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollout&lt;/strong&gt;: Test in a canary environment first if possible. For distributed systems, roll out incrementally (one service at a time). Define who monitors the rollout and what metrics matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This framework doesn't guarantee perfection, and perhaps not every step is always needed. But it does at least encourage deliberate thinking and decisions instead of reflexive action.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost of Delay
&lt;/h2&gt;

&lt;p&gt;Delaying updates indefinitely creates different risks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Security exposure&lt;/strong&gt;: Unpatched vulnerabilities accumulate; attackers target known CVEs in outdated packages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendor abandonment&lt;/strong&gt;: Falling too far behind loses access to vendor support and community knowledge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compounding migration cost&lt;/strong&gt;: The longer you wait, the larger the gap between current and target versions, making eventual migration more painful&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ecosystem drift&lt;/strong&gt;: New libraries and tools may assume newer dependency versions, limiting your options&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Recognizing when delay shifts from prudent caution to mounting debt requires regular review cycles. Quarterly or semi-annual assessments help teams determine whether staying put still makes sense or whether the debt is growing.&lt;/p&gt;

&lt;p&gt;If you're already multiple versions behind, don't try to catch up all at once. Audit your dependencies, identify the high-risk gaps (unpatched CVEs, unsupported versions, libraries blocking other upgrades), and create a prioritized update roadmap. Treat it like technical debt: chip away systematically rather than attempting a big-bang migration that creates more risk than it resolves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test Based on What Changed
&lt;/h2&gt;

&lt;p&gt;Fear drives teams toward exhaustive testing: "We changed a dependency, so we need to test everything." This wastes time and often misses the actual risks.&lt;/p&gt;

&lt;p&gt;Target your testing based on what changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Regression tests&lt;/strong&gt;: Focus on code paths that use the updated dependency directly or indirectly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load tests&lt;/strong&gt;: Replicate production traffic patterns against the specific features that changed; validate SLA compliance (response times, throughput, error rates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration tests&lt;/strong&gt;: If the dependency handles I/O (databases, APIs, file systems), test those boundaries thoroughly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Load testing deserves special attention. Bugs that surface only under concurrent load won't appear in functional tests. Functional tests with serial requests can pass cleanly while hiding race conditions, deadlocks, or resource exhaustion that only manifest under production concurrency. Load tests should mirror production traffic volume and patterns, not arbitrary "stress everything" scenarios.&lt;/p&gt;

&lt;p&gt;Avoid the temptation to test everything out of fear. Exhaustive testing creates a false sense of security while consuming time better spent on targeted, high-value validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AWS SDK Lesson
&lt;/h2&gt;

&lt;p&gt;Even when you decide updates don't require cross-team coordination, you still need deliberate evaluation. The assumption that trusted vendors always ship safe updates fails regularly.&lt;/p&gt;

&lt;p&gt;Recently, AWS released version 4 of many of their .NET SDK packages with a series of breaking changes. Teams that treated AWS as a trusted source and updated without thorough review faced a flood of critical errors which were hard to detect and thus sometimes make their way to production.&lt;/p&gt;

&lt;p&gt;The most damaging change wasn't a breaking API; it was a critical bug introduced in the SDK core authentication workflow. The bug created silent deadlocks when calling AWS services under specific load conditions. Services appeared healthy in development and early testing but locked up under production traffic patterns.&lt;/p&gt;

&lt;p&gt;Two Truths From the AWS SDK Incident&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upfront due diligence has limits&lt;/strong&gt;: You can review changelogs, run regression tests, and validate functionality, but some bugs only surface under production conditions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ongoing vigilance matters&lt;/strong&gt;: Staying plugged into ticket systems, community forums, and issue trackers helps you catch problems before they spread&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even trusted sources ship bugs. Intentional updates include monitoring what happens after updates ship, not just before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Objections
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"We don't have time for this level of due diligence. Just like TDD, it sounds good in theory but slows us down in practice."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The framework above takes 15-30 minutes per update decision, not hours. Compare that to the time spent dealing with broken production deployments, emergency rollbacks, and firefighting that follows hasty updates. Spending 20 minutes reading a changelog and running targeted tests beats spending 4 hours debugging a silent authentication deadlock at 2 AM.&lt;/p&gt;

&lt;p&gt;Deliberate updates consume predictable, scheduled time during normal work hours. Autopilot updates consume unpredictable, high-stress time during incidents. The time spent is roughly equivalent, but one approach happens during office hours with full context, while the other happens during outages with incomplete information.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Our security team requires us to apply all patches within 48 hours of release. We don't have a choice."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Security policies that mandate blanket timelines without risk assessment create more risk than they prevent. A policy that forces teams to apply an untested patch faster than they can validate it treats all vulnerabilities as equally critical, which they aren't.&lt;/p&gt;

&lt;p&gt;When possible, present data to security leadership. Show the difference between a critical remote code execution vulnerability in your authentication layer (apply immediately) versus a theoretical XSS vulnerability in a library function your codebase never calls (evaluate deliberately). Most security teams will adjust policies when presented with risk-based reasoning rather than blanket compliance.&lt;/p&gt;

&lt;p&gt;If your organization won't budge, at least apply the intentional framework to prioritize which updates get thorough validation versus rubber-stamp approval. Not every patch deserves the same scrutiny.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Automated tooling already handles this for us."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Automation helps with detection and scanning: finding available updates, flagging known CVEs, checking for outdated versions. What automation cannot do is decide whether an update makes sense for your context.&lt;/p&gt;

&lt;p&gt;Security scanners tell you a vulnerability exists. They don't tell you whether it affects code paths you actually execute. Automated PRs surface new versions. They don't evaluate community feedback, breaking changes, or production risk.&lt;/p&gt;

&lt;p&gt;Auto-applying updates (even patch versions) without review is worse than no automation. A tool that automatically merges dependency updates trades predictable, bounded risk (staying on a known version) for unpredictable, unbounded risk (silently introducing bugs you didn't test for). Automation should notify, not decide.&lt;/p&gt;

&lt;p&gt;Treat automated tools as early-warning systems. When a security scan flags a CVE or a bot opens a PR, use it as a trigger to walk through the intentional update framework. The automation saves you from manually checking for updates; it doesn't save you from thinking about whether the update makes sense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"We have too many dependencies to evaluate each one individually. We'd spend all day reviewing changelogs."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not all dependencies deserve equal attention. Apply the Pareto principle: 20% of your dependencies (authentication libraries, database drivers, core frameworks, HTTP clients) account for 80% of your risk. Focus your evaluation effort there.&lt;/p&gt;

&lt;p&gt;For lower-risk dependencies (date formatting libraries, color palette utilities, markdown parsers), batch review them during scheduled maintenance windows. Check for breaking changes and security issues in aggregate, test once across the batch, and apply together. Reserve deep evaluation for high-impact dependencies where bugs cause production incidents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Our competitors ship faster because they don't overthink updates like this."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You don't know what your competitors do internally. You see their marketing velocity, not their operational reality. Companies that ship fast and stay fast do so because they avoid the context-switching cost of constant firefighting. They make fewer unforced errors, which means they spend less time recovering from self-inflicted wounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leadership Sets the Tone
&lt;/h2&gt;

&lt;p&gt;Team leads determine how their teams approach updates. If leadership treats updates as chores to batch and rush through, teams will cut corners. If leadership asks hard questions, prioritizes based on value, and accepts that "not yet" is sometimes the right answer, then teams will follow their example.&lt;/p&gt;

&lt;p&gt;Shipping fast and thinking deliberately aren't opposites. Teams that update thoughtfully ship faster over time because they spend less time debugging mysterious production issues traced back to an unconsidered dependency change two sprints ago.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Package updates are investment decisions, not hygiene tasks. Treat them with the same rigor you apply to feature development.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>leadership</category>
      <category>softwaredevelopment</category>
      <category>testing</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Adaptability Over Cleverness: What Makes Code Actually Good</title>
      <dc:creator>Steven Stuart</dc:creator>
      <pubDate>Mon, 05 Jan 2026 15:56:07 +0000</pubDate>
      <link>https://dev.to/stevenstuartm/adaptability-over-cleverness-what-makes-code-actually-good-50bj</link>
      <guid>https://dev.to/stevenstuartm/adaptability-over-cleverness-what-makes-code-actually-good-50bj</guid>
      <description>&lt;h2&gt;
  
  
  Adaptability Over Cleverness
&lt;/h2&gt;

&lt;p&gt;Systems that survive aren't the ones written perfectly from the start. They're the ones that bend without breaking when requirements shift, technologies evolve, and teams discover what they didn't know upfront. Building for change beats chasing premature perfection every time.&lt;/p&gt;

&lt;p&gt;You will never get it right the first time. That's not a failure; it's how software development works. Requirements clarify through building, edge cases emerge through usage,   and performance issues surface under real load. Teams that treat first attempts as gospel spend months polishing solutions to the wrong problem.&lt;/p&gt;

&lt;p&gt;Instead, build systems that can evolve. Give yourself room to deliver, learn, and adapt as reality proves what matters and what doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Principle and Practice
&lt;/h2&gt;

&lt;p&gt;Adaptable code isn't magic. It follows principles that reduce coupling, isolate change, and make breakage obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single Responsibility Principle&lt;/strong&gt; keeps each component focused on one job, whether that's a microservice owning a bounded context or a class handling a single concern. When requirements change, you modify the piece responsible for that concern without cascading edits across the system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clean interfaces and separation of concerns&lt;/strong&gt; apply at both macro and micro levels. Services communicate through contracts, not implementation details. Business logic doesn't know about HTTP; database layers don't make authorization decisions. Abstractions at the right granularity let you pivot implementations as understanding evolves without rewriting everything upstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Externalize what changes&lt;/strong&gt; by making variable behavior configurable. Environment-specific settings keep the same code deployable to dev, staging, and production. Tunable timeouts, batch sizes, and retry policies let operations adapt the system's behavior without engineering involvement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail fast and loud&lt;/strong&gt; surfaces problems immediately. Silent failures cascade into confusing bugs far from their source. Explicit validation at system boundaries, defensive assertions in critical paths, and structured logging create clear signals when something breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests that give you confidence&lt;/strong&gt; let you refactor with impunity. Unit tests verify component behavior. Integration tests catch interface mismatches. End-to-end tests confirm critical workflows still work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consistent naming and structure&lt;/strong&gt; reduce cognitive load. Developers understand the codebase faster when patterns repeat. Services follow the same lifecycle. Repositories expose the same CRUD operations. Consistency makes the unfamiliar feel familiar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seek Balance
&lt;/h2&gt;

&lt;p&gt;The goal isn't maximum flexibility; it's appropriate flexibility. Optimize for the changes you can reasonably anticipate based on domain knowledge and past experience. A payments system will need to support new payment providers. An internal admin tool probably won't need a plugin architecture.&lt;/p&gt;

&lt;p&gt;Perfect code written for yesterday's requirements fails when reality shifts. Over-engineered code collapses under its own weight. Adaptable code finds the balance: flexible where change is likely, simple where it isn't.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>designpatterns</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
