<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged distributed-systems</title>
<link>https://coderlegion.com/tag/distributed-systems</link>
<description>Powered by Question2Answer</description>
<item>
<title>Reliable Event-Driven Architecture in Spring Boot: Outbox, Inbox, Retries, and Idempotency</title>
<link>https://coderlegion.com/32907/reliable-event-driven-architecture-in-spring-boot-outbox-inbox-retries-and-idempotency</link>
<description>&lt;p&gt;&lt;em&gt;Why publishing an event is only the beginning of building a reliable distributed system.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Building an event-driven application with Spring Boot is relatively straightforward.&lt;/p&gt;
&lt;p&gt;You create a Kafka producer, publish an event, implement a consumer, and process the message.&lt;/p&gt;
&lt;p&gt;Everything works beautifully during development.&lt;/p&gt;
&lt;p&gt;Then production introduces the situations your happy-path tests never covered.&lt;/p&gt;
&lt;p&gt;What happens when the database transaction commits, but Kafka is unavailable? What happens when the consumer processes an event successfully but crashes before acknowledging it? What happens when two application instances attempt to process the same message?&lt;/p&gt;
&lt;p&gt;These aren't unusual edge cases. They're fundamental challenges of distributed systems.&lt;/p&gt;
&lt;p&gt;And solving them requires more than adding a retry annotation.&lt;/p&gt;
&lt;p&gt;Let's explore the reliability boundaries behind a production-ready event-driven architecture.&lt;/p&gt;
&lt;h3 id=&quot;1-the-dual-write-problem&quot;&gt;1. The dual-write problem&lt;/h3&gt;
&lt;p&gt;Imagine a payment service that must perform two operations:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Save a payment in PostgreSQL.&lt;/li&gt;
&lt;li&gt;Publish a &lt;code&gt;PaymentCompleted&lt;/code&gt; event to Kafka.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A straightforward implementation might look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public void completePayment(Payment payment) {
    paymentRepository.save(payment);
    kafkaTemplate.send(
        &quot;payment.completed&quot;,
        new PaymentCompleted(payment.getId())
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The code looks reasonable, but there's a serious problem.&lt;/p&gt;
&lt;p&gt;The database transaction and Kafka publication do not share a single atomic transaction.&lt;/p&gt;
&lt;p&gt;Several outcomes are possible:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The database commits, but the event is never published.&lt;/li&gt;
&lt;li&gt;Kafka accepts the event, but the database transaction rolls back.&lt;/li&gt;
&lt;li&gt;Publication times out, leaving the application uncertain whether Kafka received the event.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Adding retries doesn't eliminate the fundamental inconsistency.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The problem is not whether Kafka is reliable. The problem is coordinating two independent systems.&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id=&quot;the-transactional-outbox&quot;&gt;The transactional outbox&lt;/h4&gt;
&lt;p&gt;The transactional outbox pattern changes the transaction boundary.&lt;/p&gt;
&lt;p&gt;Instead of publishing directly to Kafka, the application writes an outbox record within the same database transaction as the business operation.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Payment Service
      |
      v
Database Transaction
      |
      +-- Save Payment
      |
      +-- Save Outbox Event
      |
      v
    Commit
      |
      v
Outbox Publisher
      |
      v
    Kafka
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Either the payment and outbox event are committed together, or neither is committed.&lt;/p&gt;
&lt;p&gt;A separate publisher retrieves pending outbox records and delivers them to Kafka.&lt;/p&gt;
&lt;p&gt;This solves the atomicity problem between the business state and the durable intent to publish.&lt;/p&gt;
&lt;p&gt;It does not guarantee that an event will be published exactly once.&lt;/p&gt;
&lt;p&gt;That distinction matters.&lt;/p&gt;
&lt;h3 id=&quot;2-why-the-outbox-still-needs-idempotency&quot;&gt;2. Why the outbox still needs idempotency&lt;/h3&gt;
&lt;p&gt;Consider this sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The outbox publisher sends an event to Kafka.&lt;/li&gt;
&lt;li&gt;Kafka successfully accepts it.&lt;/li&gt;
&lt;li&gt;The application crashes before marking the outbox record as published.&lt;/li&gt;
&lt;li&gt;The publisher restarts and sends the event again.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Kafka may now contain two copies of the same logical event.&lt;/p&gt;
&lt;p&gt;This is a normal consequence of at-least-once delivery.&lt;/p&gt;
&lt;p&gt;The correct response is not to assume duplicates can always be prevented.&lt;/p&gt;
&lt;p&gt;Instead, design consumers that can safely tolerate them.&lt;/p&gt;
&lt;h3 id=&quot;3-the-transactional-inbox&quot;&gt;3. The transactional inbox&lt;/h3&gt;
&lt;p&gt;An inbox provides a durable record of incoming events that have already been processed.&lt;/p&gt;
&lt;p&gt;Each event should carry a stable identifier, such as an event ID generated when the logical event is created.&lt;/p&gt;
&lt;p&gt;When the consumer receives a message, it checks whether that event has already been processed.&lt;/p&gt;
&lt;p&gt;A simplified model looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Kafka Event
     |
     v
Consumer
     |
     v
Database Transaction
     |
     +-- Claim Event ID in Inbox
     |
     +-- Apply Business Changes
     |
     +-- Mark Inbox Completed
     |
     v
   Commit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important part is the transaction boundary.&lt;/p&gt;
&lt;p&gt;The inbox record and the business changes must participate in the same transaction when using the same transactional database.&lt;/p&gt;
&lt;p&gt;Otherwise, a consumer can record an event as completed even though its business operation failed.&lt;/p&gt;
&lt;h4 id=&quot;a-subtle-but-important-case&quot;&gt;A subtle but important case&lt;/h4&gt;
&lt;p&gt;Suppose a consumer receives &lt;code&gt;PaymentCompleted&lt;/code&gt;, updates an invoice, and produces &lt;code&gt;InvoicePaid&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Now the reliability boundary becomes:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Inbox + Business State + Outbox&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;All three should be committed atomically.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Incoming Event
      |
      v
Database Transaction
      |
      +-- Inbox Claim
      |
      +-- Update Invoice
      |
      +-- Create Outbox Event
      |
      v
    Commit
      |
      v
Outbox Publisher
      |
      v
Outgoing Event
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is one of the most useful patterns for building dependable event-driven workflows.&lt;/p&gt;
&lt;p&gt;The inbox protects the incoming side. The outbox protects the outgoing side.&lt;/p&gt;
&lt;p&gt;Neither eliminates the need for careful business-level idempotency, especially when external side effects are involved.&lt;/p&gt;
&lt;h3 id=&quot;4-retries-are-a-lifecycle-not-just-an-annotation&quot;&gt;4. Retries are a lifecycle, not just an annotation&lt;/h3&gt;
&lt;p&gt;Retries are often introduced with a simple configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Retryable(
    retryFor = TransientException.class,
    maxAttempts = 3
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For short-lived operations, that can be perfectly reasonable.&lt;/p&gt;
&lt;p&gt;But what happens when a dependency is unavailable for 30 minutes? What happens when the application restarts? What happens when an event requires operator intervention?&lt;/p&gt;
&lt;p&gt;An in-memory retry loop cannot reliably manage every long-running failure scenario.&lt;/p&gt;
&lt;p&gt;For durable asynchronous processing, retries should be modeled as persistent state.&lt;/p&gt;
&lt;p&gt;A typical lifecycle might look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;PENDING
   |
   v
PROCESSING
   |
   +---- Success ----&amp;gt; COMPLETED
   |
   +---- Retryable failure
   |           |
   |           v
   |      RETRY_SCHEDULED
   |           |
   |           v
   |        PENDING
   |
   +---- Terminal failure
               |
               v
             FAILED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each attempt can record useful information:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Attempt count&lt;/li&gt;
&lt;li&gt;Next eligible execution time&lt;/li&gt;
&lt;li&gt;Last failure category&lt;/li&gt;
&lt;li&gt;Processing status&lt;/li&gt;
&lt;li&gt;Relevant timestamps&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Exponential backoff with jitter helps avoid repeatedly overwhelming an unhealthy dependency.&lt;/p&gt;
&lt;p&gt;But retry policies should distinguish transient failures from permanent ones.&lt;/p&gt;
&lt;p&gt;Retrying an invalid business request a thousand times won't make it valid.&lt;/p&gt;
&lt;h3 id=&quot;5-multi-instance-processing-changes-everything&quot;&gt;5. Multi-instance processing changes everything&lt;/h3&gt;
&lt;p&gt;A background publisher may work perfectly when there's only one application instance.&lt;/p&gt;
&lt;p&gt;Then the service is scaled to three pods.&lt;/p&gt;
&lt;p&gt;All three publishers query the same pending records.&lt;/p&gt;
&lt;p&gt;Without coordination, multiple instances may attempt to publish or process the same work concurrently.&lt;/p&gt;
&lt;p&gt;One common PostgreSQL technique is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id
FROM outbox_event
WHERE status = 'PENDING'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 100;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; allows concurrent workers to skip rows already locked by another transaction.&lt;/p&gt;
&lt;p&gt;This can improve coordination between workers, but it's not a complete delivery protocol.&lt;/p&gt;
&lt;p&gt;The design must still consider:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How work is claimed and how long claims remain valid&lt;/li&gt;
&lt;li&gt;Transaction duration and lock ownership&lt;/li&gt;
&lt;li&gt;Recovery after a worker crashes&lt;/li&gt;
&lt;li&gt;Retry scheduling and fairness&lt;/li&gt;
&lt;li&gt;Ordering requirements&lt;/li&gt;
&lt;li&gt;Duplicate delivery&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For example, holding database locks across a slow network call can increase contention and create unnecessarily long transactions.&lt;/p&gt;
&lt;p&gt;A robust implementation may use short claim transactions, explicit processing ownership, and recovery mechanisms rather than keeping database transactions open while publishing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Concurrency control and delivery guarantees are related, but they are not interchangeable.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;6-exactly-once-is-not-an-end-to-end-promise&quot;&gt;6. Exactly-once is not an end-to-end promise&lt;/h3&gt;
&lt;p&gt;It's tempting to describe an event-driven system as exactly-once.&lt;/p&gt;
&lt;p&gt;But exactly-once semantics depend on the boundaries being discussed.&lt;/p&gt;
&lt;p&gt;Kafka transactions can provide guarantees for supported Kafka operations. A PostgreSQL transaction can atomically update records within PostgreSQL.&lt;/p&gt;
&lt;p&gt;Neither automatically makes a database update, Kafka publication, HTTP request, and external payment operation one globally atomic action.&lt;/p&gt;
&lt;p&gt;A more useful engineering goal is:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;At-least-once delivery with effectively-once business outcomes wherever possible.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That typically requires durable event identities, transactional inbox processing, idempotent business operations, and explicit handling of external side effects.&lt;/p&gt;
&lt;p&gt;For payment systems, this distinction is particularly important.&lt;/p&gt;
&lt;p&gt;Processing the same event twice must not mean charging a customer twice.&lt;/p&gt;
&lt;h3 id=&quot;7-observability-is-part-of-reliability&quot;&gt;7. Observability is part of reliability&lt;/h3&gt;
&lt;p&gt;A system that retries failures silently can appear healthy while accumulating thousands of unprocessed events.&lt;/p&gt;
&lt;p&gt;Operational visibility should answer questions such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How many events are pending?&lt;/li&gt;
&lt;li&gt;How old is the oldest unpublished event?&lt;/li&gt;
&lt;li&gt;How many retries are scheduled?&lt;/li&gt;
&lt;li&gt;Which events are permanently failing?&lt;/li&gt;
&lt;li&gt;Are workers making progress?&lt;/li&gt;
&lt;li&gt;Are duplicates being detected?&lt;/li&gt;
&lt;li&gt;Is event processing latency increasing?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Metrics, structured logs, tracing, and operator-facing failure information are not optional extras for a production system.&lt;/p&gt;
&lt;p&gt;They help distinguish a temporary delay from a workflow that has stopped making progress.&lt;/p&gt;
&lt;p&gt;A reliable system should make failure visible and recovery understandable.&lt;/p&gt;
&lt;h3 id=&quot;8-putting-these-patterns-together&quot;&gt;8. Putting these patterns together&lt;/h3&gt;
&lt;p&gt;A practical architecture combines several responsibilities:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Business Operation
       |
       v
Transactional Outbox
       |
       v
Durable Publisher
       |
       v
Message Broker
       |
       v
Transactional Inbox
       |
       v
Business Processing
       |
       +-- State Changes
       |
       +-- Optional New Outbox Events
       |
       v
Commit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Around that flow, we need retry policies, concurrency coordination, idempotency, recovery, and observability.&lt;/p&gt;
&lt;p&gt;Each component solves a different failure mode.&lt;/p&gt;
&lt;p&gt;The outbox prevents losing the intent to publish when business data commits.&lt;/p&gt;
&lt;p&gt;The inbox protects consumers from repeated processing.&lt;/p&gt;
&lt;p&gt;Retries help transient failures recover.&lt;/p&gt;
&lt;p&gt;Concurrency controls coordinate multiple workers.&lt;/p&gt;
&lt;p&gt;Observability makes the system operable.&lt;/p&gt;
&lt;p&gt;None of these patterns should be treated as magic.&lt;/p&gt;
&lt;p&gt;Their value comes from understanding exactly which failure boundary each one protects.&lt;/p&gt;
&lt;h3 id=&quot;9-why-i-built-nerv-event&quot;&gt;9. Why I built NERV Event&lt;/h3&gt;
&lt;p&gt;After working with event-driven enterprise applications, I wanted these reliability concerns to be reusable rather than repeatedly implemented in individual services.&lt;/p&gt;
&lt;p&gt;That led to &lt;strong&gt;NERV Event&lt;/strong&gt;, an open-source Java and Spring Boot library focused on patterns such as transactional outbox, inbox processing, retries, and idempotency.&lt;/p&gt;
&lt;p&gt;The goal isn't to hide distributed-systems complexity behind annotations.&lt;/p&gt;
&lt;p&gt;It's to provide reusable infrastructure while keeping the reliability model explicit.&lt;/p&gt;
&lt;p&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/czetsuyatech/nerv-event&quot;&gt;https://github.com/czetsuyatech/nerv-event&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The library is one possible implementation of these ideas. The architectural principles are useful regardless of which framework or library you choose.&lt;/p&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h3&gt;
&lt;p&gt;Publishing an event is easy.&lt;/p&gt;
&lt;p&gt;Building a system that behaves predictably when databases, brokers, application instances, and downstream dependencies fail is much harder.&lt;/p&gt;
&lt;p&gt;Reliable event-driven architecture begins with a simple question:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens if the application crashes at this exact point?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Ask that question at every transaction boundary, every message acknowledgment, and every external call.&lt;/p&gt;
&lt;p&gt;The answers will reveal where durability, idempotency, retries, and recovery are actually needed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I'd be interested to hear how other teams approach this:&lt;/strong&gt; Do you use a transactional inbox alongside your outbox, or do you handle consumer idempotency directly in your business tables?&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.czetsuyatech.com&quot;&gt;CzetsuyaTech&lt;/a&gt;. Explore more articles on Java, Spring Boot, distributed systems, and software architecture there.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32907/reliable-event-driven-architecture-in-spring-boot-outbox-inbox-retries-and-idempotency</guid>
<pubDate>Thu, 29 Oct 2026 00:07:44 +0000</pubDate>
</item>
<item>
<title>Tuesday Isn't a Valid State Transition</title>
<link>https://coderlegion.com/31233/tuesday-isnt-a-valid-state-transition</link>
<description>&lt;p&gt;&lt;em&gt;Calendars, evidence, and the difference between done and stuck&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I expected my pear wine to spend two to three weeks in primary fermentation.&lt;/p&gt;
&lt;p&gt;It spent nineteen days. The yeast went in on September 23, and on October 12 I siphoned the wine off its sediment and into a glass carboy.&lt;/p&gt;
&lt;p&gt;So the calendar was right. That still isn't why I moved it.&lt;/p&gt;
&lt;h3 id=&quot;what-the-calendar-is-for&quot;&gt;What the calendar is for&lt;/h3&gt;
&lt;p&gt;Every fermentation recipe comes with time estimates. Primary takes one to three weeks. Secondary takes a few more. Bulk age for a few months. Those numbers aren't wrong. They're averages across a lot of batches, and they're useful for exactly one thing: planning. They tell you when to have a clean carboy ready, when to clear some space, when to stop making weekend plans.&lt;/p&gt;
&lt;p&gt;What they can't do is tell you that &lt;em&gt;this&lt;/em&gt; batch is ready. Yeast doesn't read recipes. It works at a speed set by temperature, sugar, nutrients, acidity, the particular strain, and the particular fruit, none of which the recipe author has ever met.&lt;/p&gt;
&lt;p&gt;So the recipe gives you a forecast, and the fermentation gives you evidence. Only one of those gets to move the wine. If they happen to agree, as they did this time, that's a pleasant coincidence, not an authorization.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-winemakers-actually-use&quot;&gt;The rule winemakers actually use&lt;/h3&gt;
&lt;p&gt;The traditional test for &quot;primary is done, time to rack&quot; is simple: take readings over several days, and when you get the same reading three times running, fermentation has finished.&lt;/p&gt;
&lt;p&gt;What I like about that rule is what it &lt;em&gt;isn't&lt;/em&gt;. It isn't a date. It also isn't a single reading, because one measurement tells you where the fermentation is, not whether it's still moving. It's evidence sustained over a window. Time is part of the test, but only as the space in which the evidence has to hold still.&lt;/p&gt;
&lt;p&gt;Here's what the pear's refractometer readings actually looked like:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Date &lt;/th&gt;&lt;th&gt;Raw Brix&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Sep 23 &lt;/td&gt;&lt;td&gt;12.9 (before the yeast)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Sep 25 &lt;/td&gt;&lt;td&gt;6.0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Sep 30 &lt;/td&gt;&lt;td&gt;6.0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Oct 2 &lt;/td&gt;&lt;td&gt;5.7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Oct 4 &lt;/td&gt;&lt;td&gt;5.6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Oct 7 &lt;/td&gt;&lt;td&gt;5.6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Oct 10 &lt;/td&gt;&lt;td&gt;5.6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Oct 11 &lt;/td&gt;&lt;td&gt;5.6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Oct 12 &lt;/td&gt;&lt;td&gt;5.6&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two things in that table are worth a closer look.&lt;/p&gt;
&lt;p&gt;First, the early plateau. Most of the sugar was gone within two days, and then the reading sat at exactly 6.0 for five days. If I'd been looking for stillness and nothing else, that looked a lot like the end. It wasn't. The fermentation was slowing down, not stopping, and over the next week it crept down further before it finally held. Two matching readings were a pause, not a verdict, which is precisely why the rule asks for three.&lt;/p&gt;
&lt;p&gt;Second, I didn't stop at three. By October 10 I had three consecutive readings at 5.6, which satisfies the rule. I kept reading anyway, and waited until five readings across eight days agreed.&lt;/p&gt;
&lt;p&gt;Then the racking itself handed me one more piece of evidence. There was more wine in the primary fermenter than the glass carboy could hold. Rather than pour the extra down the drain, I filled a hydrometer jar with it: a completely different instrument, measuring a completely different physical property. The refractometer, once corrected for alcohol, put the wine at about 1.004. The hydrometer said 1.002. Two independent instruments, close enough to agree, and the second one confirmed that the correction formula I'd been trusting for three weeks was doing its job.&lt;/p&gt;
&lt;p&gt;I like that this happened by accident, because it isn't really an accident. A hydrometer needs a large sample, which in a one-gallon batch is too expensive to take routinely. At the moment of a transition, when wine is being moved anyway, that same sample costs nothing. The transition is when the more trustworthy evidence becomes cheap.&lt;/p&gt;
&lt;p&gt;None of that is the calendar talking. That's a guard, and it was satisfied before the wine moved.&lt;/p&gt;
&lt;h3 id=&quot;status-is-a-column-anyone-can-write&quot;&gt;Status is a column anyone can write&lt;/h3&gt;
&lt;p&gt;In a lot of systems, state is a string in a database, and a transition is an &lt;code&gt;UPDATE&lt;/code&gt; statement.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE orders SET status = 'shipped' WHERE id = 4417;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing in that statement requires that anything was shipped. It's a label, and anyone with write access can apply it for any reason: a cron job that marks orders shipped two days after payment because that's usually true, a release train that promotes whatever's on the branch because it's Tuesday, a support tool that flips a flag to make a ticket go away.&lt;/p&gt;
&lt;p&gt;Each of these is the software version of racking the wine because the recipe said two to three weeks. Most of the time it'll even be right, just as my calendar was. The transition happens on schedule, the dashboard updates, and the system now asserts a state that nobody verified.&lt;/p&gt;
&lt;p&gt;The fix isn't elaborate. A transition should have a guard, a condition that has to be true, based on evidence, before the state can change. &quot;Shipped&quot; should require a carrier scan. &quot;Released&quot; should require the checks that define releasable. &quot;Primary complete&quot; should require stable readings. The state machine's job is to refuse the transition when the evidence isn't there, however much the calendar insists, and to keep refusing even on the days the calendar happens to be right.&lt;/p&gt;
&lt;h3 id=&quot;same-evidence-two-states&quot;&gt;Same evidence, two states&lt;/h3&gt;
&lt;p&gt;Here's where it gets interesting. &quot;Three consistent readings&quot; is the signal for &lt;em&gt;done&lt;/em&gt;. But a fermentation that has stalled also produces three consistent readings.&lt;/p&gt;
&lt;p&gt;Mead is notorious for this. Honey has very little natural buffering, so as fermentation proceeds the pH drifts down, and past a certain point the yeast slows or quits entirely, often well short of dry. From the refractometer's point of view, a stuck mead and a finished mead look exactly the same: a number that has stopped moving.&lt;/p&gt;
&lt;p&gt;My fireweed mead is a few days from that question right now. Its pH started at 3.5 and dropped to 3.1 within the first four days, where it has mostly stayed. Here are its last few readings:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Date &lt;/th&gt;&lt;th&gt;Raw Brix &lt;/th&gt;&lt;th&gt;Corrected Brix &lt;/th&gt;&lt;th&gt;Estimated ABV&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Oct 7 &lt;/td&gt;&lt;td&gt;11.8 &lt;/td&gt;&lt;td&gt;4.3 &lt;/td&gt;&lt;td&gt;10.4%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Oct 10 &lt;/td&gt;&lt;td&gt;9.0 &lt;/td&gt;&lt;td&gt;-0.3 &lt;/td&gt;&lt;td&gt;12.8%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Oct 11 &lt;/td&gt;&lt;td&gt;8.6 &lt;/td&gt;&lt;td&gt;-1.0 &lt;/td&gt;&lt;td&gt;13.1%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Oct 12 &lt;/td&gt;&lt;td&gt;8.4 &lt;/td&gt;&lt;td&gt;-1.3 &lt;/td&gt;&lt;td&gt;13.3%&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Look at the corrected column. The calculator now claims the mead contains less than no sugar, which is impossible. That's a model being pushed past the range it was built for, and it's a nice reminder that a derived number can confidently assert something no physical system can be. If my guard were &quot;corrected Brix at or below zero,&quot; I'd have racked the mead two days ago.&lt;/p&gt;
&lt;p&gt;The raw readings tell a different story: they're still falling, slowly. The mead isn't still yet, so the guard isn't satisfied, no matter what the corrected number says. And when it does go still, the question becomes &lt;em&gt;where&lt;/em&gt;. The yeast I used, 71B, is generally rated to tolerate around 14% alcohol, and the estimate is already over 13%. A mead that stops near its yeast's limit with the sugar it was expected to consume gone is probably done. One that stops early, with sugar left over and the pH low, is probably stuck.&lt;/p&gt;
&lt;p&gt;The mead has already taught me this lesson once, in miniature. Mead yeast needs nutrients added in stages early on, and the textbook guidance ties the last addition to the &quot;one-third sugar break,&quot; the point where about a third of the sugar has been consumed. I scheduled my three additions by day instead. As it happened, the last one landed right around the sugar break anyway. Sometimes luck happens. But luck is exactly what it was: the schedule didn't know where the sugar was, and if the mead had been a few days slower, the same calendar would have fed it at the wrong time.&lt;/p&gt;
&lt;p&gt;In other words, the guard for &quot;done&quot; can't just be &quot;stable.&quot; It has to be &quot;stable &lt;em&gt;where a finished fermentation should be&lt;/em&gt;,&quot; which means the guard needs a model of the expected end state, not just a test for motion. And it needs to know which of its inputs to trust when they disagree.&lt;/p&gt;
&lt;p&gt;Software has the same ambiguity everywhere. A batch job whose progress counter hasn't moved in an hour has either finished or hung. A queue depth sitting at zero means either everything was processed or the consumer died. A replication lag of zero might mean the replica is caught up, or that it stopped receiving anything to lag behind. In every case, &quot;nothing is changing&quot; is evidence consistent with two opposite states, and a transition that only checks for stillness will eventually pick the wrong one.&lt;/p&gt;
&lt;h3 id=&quot;a-transition-is-an-event-with-a-receipt&quot;&gt;A transition is an event with a receipt&lt;/h3&gt;
&lt;p&gt;The row in my pear log for October 12 reads, in effect: 11:10 a.m., 5.6 Brix, hydrometer 1.002, moved to glass carboy. The transition and the evidence that justified it are on the same line.&lt;/p&gt;
&lt;p&gt;That matters more than the new status. Months from now, if the wine turns out strange, &quot;racked on October 12&quot; tells me almost nothing. &quot;Racked after five stable readings, confirmed by a second instrument&quot; tells me what I believed, what I based it on, and whether I might have been fooled by a stall.&lt;/p&gt;
&lt;p&gt;Systems benefit from the same habit. A state change shouldn't just overwrite the previous state. It should be recorded as an event: what the state was, what it became, what evidence satisfied the guard, and who or what made the call. Then the current state is something you can explain, not just something you can read. When &quot;shipped&quot; turns out to be wrong, you can see whether the guard was bad, the evidence was bad, or the guard was never checked at all.&lt;/p&gt;
&lt;h3 id=&quot;what-time-is-actually-allowed-to-do&quot;&gt;What time is actually allowed to do&lt;/h3&gt;
&lt;p&gt;None of this means time is irrelevant. It plays three legitimate roles, and it's worth keeping them straight.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Time can permit a transition.&lt;/strong&gt; Some transitions shouldn't happen before a minimum interval no matter what the evidence says, like a waiting period before a refund clears, or a wine that needs some minimum aging before bottling. Here time is a precondition alongside the evidence, not a replacement for it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Time can create the conditions for evidence.&lt;/strong&gt; The three-readings rule only works because readings are spread over days. Stability over a window requires a window. The pear's false plateau is exactly why: two readings five days apart looked still, and only more time showed they weren't.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Time can raise a question.&lt;/strong&gt; If a fermentation hasn't finished long after it should have, that's worth investigating. But notice what the timeout actually tells you: &lt;em&gt;something is unusual&lt;/em&gt;. It doesn't tell you the batch is done, and it doesn't tell you the batch has failed.&lt;/p&gt;
&lt;p&gt;That last one is where software most often goes wrong. Timeouts routinely get wired straight to a terminal state: no response in thirty seconds means failed, no update in three days means complete. A timeout is legitimate evidence that you need to look. It is rarely evidence of what you'll find when you do. A timeout should produce a question, not an answer.&lt;/p&gt;
&lt;p&gt;What time shouldn't do, on its own, is &lt;em&gt;cause&lt;/em&gt; a transition. Nineteen days arriving was not a fact about the pear wine.&lt;/p&gt;
&lt;h3 id=&quot;guarding-your-transitions&quot;&gt;Guarding your transitions&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Treat schedules as forecasts.&lt;/strong&gt; Plan with them. Don't let them move state, even when they're right.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Put a guard on every transition that matters.&lt;/strong&gt; If a state can be reached by writing a string, eventually it will be reached for the wrong reason.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Test for the expected end state, not just stillness.&lt;/strong&gt; Stable and finished are different claims. A guard that can't tell them apart will someday declare a stall a success.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Know which evidence to trust when it disagrees.&lt;/strong&gt; A derived value can be confidently impossible. Raw observations and independent instruments are how you catch it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Record the evidence with the transition.&lt;/strong&gt; The new state is the conclusion. Keep the reasons next to it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Let timeouts ask, not answer.&lt;/strong&gt; Route them to &quot;needs attention,&quot; not to &quot;done&quot; or &quot;failed.&quot;&lt;/p&gt;
&lt;p&gt;The pear is in its carboy now, clearing slowly. October 12 was the day it moved. It happened to be a Monday. That wasn't the reason either.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31233/tuesday-isnt-a-valid-state-transition</guid>
<pubDate>Tue, 20 Oct 2026 14:06:00 +0000</pubDate>
</item>
<item>
<title>From E-Commerce to Agentic AI: Paul Chen’s 30-Year Developer's Journey</title>
<link>https://coderlegion.com/26582/from-e-commerce-to-agentic-ai-paul-chens-30-year-developers-journey</link>
<description>&lt;h2 id=&quot;from-e-commerce-to-agentic-ai-a-30-year-developer-s-journey&quot;&gt;From E-Commerce to Agentic AI: A 30-Year Developer’s Journey&lt;/h2&gt;
&lt;p&gt;I wrote my first real program in the early 1990s, sitting in a computer lab at university while working toward a Computer Science degree.&lt;/p&gt;
&lt;p&gt;Back then, programming wasn't the career everyone was racing toward.&lt;/p&gt;
&lt;p&gt;There was no app store, no cloud, and barely a World Wide Web.&lt;/p&gt;
&lt;p&gt;You wrote code because you were genuinely fascinated by the idea that a machine could be made to think, even if only a little bit.&lt;/p&gt;
&lt;p&gt;More than 30 years later, I'm still chasing that idea.&lt;/p&gt;
&lt;p&gt;The technologies have changed dramatically — from e-commerce and distributed systems to cloud computing, serverless platforms, and now agentic AI.&lt;/p&gt;
&lt;p&gt;But the fundamental question hasn't changed:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How can we make software do more with less human direction?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is the story of that journey.&lt;/p&gt;
&lt;h3 id=&quot;how-it-all-started&quot;&gt;How It All Started&lt;/h3&gt;
&lt;p&gt;That fascination with programming led me to IBM straight out of university in 1997.&lt;/p&gt;
&lt;p&gt;My first project was integrating tooling for WebSphere Commerce Server — e-commerce in its most primitive form.&lt;/p&gt;
&lt;p&gt;This was before payment gateways were commodities and before anyone had figured out how to make online shopping feel safe.&lt;/p&gt;
&lt;p&gt;We were building the pipes.&lt;/p&gt;
&lt;p&gt;And the pipes were leaking.&lt;/p&gt;
&lt;p&gt;Every day was a new problem nobody had solved before, and I loved it.&lt;/p&gt;
&lt;p&gt;I joined the team right in the middle of a release crunch.&lt;/p&gt;
&lt;p&gt;The very first version of the product was days away from shipping.&lt;/p&gt;
&lt;p&gt;I was handed the storefront code and told to build the payment and shipping functions using JSP.&lt;/p&gt;
&lt;p&gt;People were willing to help, but the release schedule had everyone stretched thin. There's only so much time senior engineers can spend hand-holding the new person when a deadline is breathing down everyone's neck.&lt;/p&gt;
&lt;p&gt;You figure things out.&lt;/p&gt;
&lt;p&gt;And you figure them out fast.&lt;/p&gt;
&lt;p&gt;And figure things out I did — sometimes the hard way.&lt;/p&gt;
&lt;p&gt;Somewhere in the middle of building the JSP wizard for shipping destinations, I quietly excluded Puerto Rico from the United States address list.&lt;/p&gt;
&lt;p&gt;It wasn't a dramatic mistake.&lt;/p&gt;
&lt;p&gt;It was just a missing entry in a form.&lt;/p&gt;
&lt;p&gt;But a tester caught it.&lt;/p&gt;
&lt;p&gt;And it taught me something I haven't forgotten:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The edge cases you miss are almost always the ones you didn't know to look for.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;After that, I helped build a common, reusable library for payment and shipping address handling, so no one else on the team would have to rediscover the same geography lesson.&lt;/p&gt;
&lt;p&gt;We shipped on time.&lt;/p&gt;
&lt;p&gt;After one intense week, the team released the first version of the e-commerce engine.&lt;/p&gt;
&lt;p&gt;I had joined late in the cycle and contributed only a slice of the whole, but I remember the feeling of that release clearly — the specific kind of pride that comes from being part of something that actually ships, even when you're new and still finding your footing.&lt;/p&gt;
&lt;h3 id=&quot;twenty-years-at-ibm-growing-into-the-problem&quot;&gt;Twenty Years at IBM: Growing Into the Problem&lt;/h3&gt;
&lt;p&gt;I stayed at IBM for more than twenty years.&lt;/p&gt;
&lt;p&gt;That might sound like inertia, but it wasn't.&lt;/p&gt;
&lt;p&gt;The problems kept changing faster than I could outrun them.&lt;/p&gt;
&lt;h4 id=&quot;learning-what-distributed-systems-really-feel-like&quot;&gt;Learning What Distributed Systems Really Feel Like&lt;/h4&gt;
&lt;p&gt;The provisioning years, from 2003 to 2011, were where I learned what distributed systems actually feel like at scale.&lt;/p&gt;
&lt;p&gt;I was the Technology Design Lead on IBM Tivoli Provisioning Orchestration, and later the Architect for Tivoli Provisioning Manager.&lt;/p&gt;
&lt;p&gt;Provisioning servers across an enterprise sounds mundane until you've debugged a race condition that only surfaces at three in the morning when five hundred machines try to boot simultaneously.&lt;/p&gt;
&lt;p&gt;Those years taught me to think about systems as living, breathing things.&lt;/p&gt;
&lt;p&gt;Not static components.&lt;/p&gt;
&lt;p&gt;Dynamic interactions.&lt;/p&gt;
&lt;h3 id=&quot;when-cloud-made-problems-invisible&quot;&gt;When Cloud Made Problems Invisible&lt;/h3&gt;
&lt;p&gt;The caching and cloud years, from 2011 to 2017, pushed me into a different dimension entirely.&lt;/p&gt;
&lt;p&gt;I became the Lead Architect for IBM Bluemix Data and Session Cache and eventually an IBM Senior Technical Staff Member (STSM) leading WebSphere eXtremeScale and Distributed Caching as a Service.&lt;/p&gt;
&lt;p&gt;The shift to cloud meant the problems didn't just get harder.&lt;/p&gt;
&lt;p&gt;They got invisible.&lt;/p&gt;
&lt;p&gt;Performance issues weren't in your hands anymore.&lt;/p&gt;
&lt;p&gt;They lived somewhere in a data centre you'd never visit.&lt;/p&gt;
&lt;p&gt;I had to develop a different mental model:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Design for failure, not against it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;By this point, I had been recognised as an IBM Master Inventor, a title that came from contributing to more than twenty patents spanning automation, distributed systems, cloud data replication, and platform integration.&lt;/p&gt;
&lt;p&gt;But the patents weren't the point.&lt;/p&gt;
&lt;p&gt;Each one represented a problem that had no concise answer in the literature, where we had to work from first principles.&lt;/p&gt;
&lt;p&gt;That habit of thinking from first principles is probably the most transferable thing I've carried across my entire career.&lt;/p&gt;
&lt;h3 id=&quot;the-challenges-that-shaped-me&quot;&gt;The Challenges That Shaped Me&lt;/h3&gt;
&lt;p&gt;I want to be honest here, because that's the point of this kind of post.&lt;/p&gt;
&lt;p&gt;The hardest technical challenge I faced wasn't necessarily the most complex one.&lt;/p&gt;
&lt;p&gt;It was designing for geo-distributed active-active data replication at Huawei, where two data centres in different regions both accept writes and must eventually converge to a consistent state.&lt;/p&gt;
&lt;p&gt;The theory is elegant.&lt;/p&gt;
&lt;p&gt;The reality is a minefield of clock drift, network partitions, and conflict-resolution edge cases that will humble you.&lt;/p&gt;
&lt;p&gt;We published peer-reviewed research on it, which helped.&lt;/p&gt;
&lt;p&gt;But what really helped was accepting something fundamental:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Distributed systems are fundamentally non-deterministic, and your job as an architect is to make uncertainty manageable, not to eliminate it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;from-technical-contributor-to-technical-leader&quot;&gt;From Technical Contributor to Technical Leader&lt;/h3&gt;
&lt;p&gt;The hardest career challenge was different.&lt;/p&gt;
&lt;p&gt;It was making the jump from deep technical contributor to technical leader and then again from technical leader to entrepreneur.&lt;/p&gt;
&lt;p&gt;Each transition required me to let go of something I was good at.&lt;/p&gt;
&lt;p&gt;When I became a Chief Architect at Huawei leading 20–30 engineers, I had to accept that my leverage was now in the clarity of my technical vision and the health of the team, not in the elegance of my own code.&lt;/p&gt;
&lt;p&gt;When I later co-founded a startup, I had to accept something else:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;There are no playbooks, and the speed of learning matters more than the depth of experience.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;when-the-architecture-was-right-but-the-tools-weren-t-ready&quot;&gt;When the Architecture Was Right but the Tools Weren't Ready&lt;/h3&gt;
&lt;p&gt;One project comes to mind clearly.&lt;/p&gt;
&lt;p&gt;In 2024, I was leading a serverless computing initiative to build lightweight AI agent runtimes using WebAssembly (WASM).&lt;/p&gt;
&lt;p&gt;The idea was that WASM's sandboxed, near-native execution model was a natural fit for running AI agents efficiently at the edge.&lt;/p&gt;
&lt;p&gt;The architecture was sound.&lt;/p&gt;
&lt;p&gt;The problem was that WASM tooling for Python wasn't mature enough at the time.&lt;/p&gt;
&lt;p&gt;You couldn't simply compile Python code into a WASM module and run it.&lt;/p&gt;
&lt;p&gt;Every integration required manual workarounds, bespoke bridging code, and a lot of patience.&lt;/p&gt;
&lt;p&gt;We made it work.&lt;/p&gt;
&lt;p&gt;But it cost significantly more engineering effort than the design had accounted for, and the result was more brittle than I would have liked.&lt;/p&gt;
&lt;p&gt;Looking back now, I can see exactly what would have helped: using AI-assisted code generation to produce a standard, consistent interface layer for each WASM module.&lt;/p&gt;
&lt;p&gt;That would have removed most of the manual friction.&lt;/p&gt;
&lt;p&gt;The tooling wasn't there yet in 2024.&lt;/p&gt;
&lt;p&gt;But the approach was right.&lt;/p&gt;
&lt;p&gt;We were just slightly ahead of the ecosystem.&lt;/p&gt;
&lt;p&gt;The lesson I took from it:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Keep exploring new things, and never hang up.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Technologies that feel half-baked in one year become load-bearing infrastructure two years later.&lt;/p&gt;
&lt;p&gt;The teams that struggled through the rough edges are the ones who know how to use it properly when the rest of the industry catches up.&lt;/p&gt;
&lt;h3 id=&quot;what-i-ve-learned-after-30-years&quot;&gt;What I've Learned After 30 Years&lt;/h3&gt;
&lt;p&gt;After 30 years, if I strip away the buzzwords and the titles, here is what I actually know.&lt;/p&gt;
&lt;h4 id=&quot;1-every-technology-wave-has-the-same-shape&quot;&gt;1. Every Technology Wave Has the Same Shape&lt;/h4&gt;
&lt;p&gt;E-commerce in the 90s.&lt;/p&gt;
&lt;p&gt;Dot-com in 2000–2005.&lt;/p&gt;
&lt;p&gt;Cloud in the 2010s.&lt;/p&gt;
&lt;p&gt;Serverless in the 2020s.&lt;/p&gt;
&lt;p&gt;Agentic AI today.&lt;/p&gt;
&lt;p&gt;Each wave starts with practitioners saying:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;This changes everything.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Then comes a messy middle where the hard engineering problems reveal themselves.&lt;/p&gt;
&lt;p&gt;And then comes a settling period where the technology becomes infrastructure.&lt;/p&gt;
&lt;p&gt;I've now lived through enough waves that I can feel when we're in the messy middle.&lt;/p&gt;
&lt;p&gt;And that's exactly where the most interesting work happens.&lt;/p&gt;
&lt;h4 id=&quot;2-the-language-is-the-least-important-part&quot;&gt;2. The Language Is the Least Important Part&lt;/h4&gt;
&lt;p&gt;I've shipped production code in C, Java, Python, Rust, and Node.js.&lt;/p&gt;
&lt;p&gt;The language matters.&lt;/p&gt;
&lt;p&gt;Rust taught me to think about memory in a way Java never forced me to, and that changed how I reason about performance.&lt;/p&gt;
&lt;p&gt;But the language is never the point.&lt;/p&gt;
&lt;p&gt;The point is understanding the problem deeply enough to choose the right tool.&lt;/p&gt;
&lt;h4 id=&quot;3-performance-problems-are-architecture-problems-in-disguise&quot;&gt;3. Performance Problems Are Architecture Problems in Disguise&lt;/h4&gt;
&lt;p&gt;Every time I've chased a performance issue far enough down, I've found an architectural decision made too early, under constraints that no longer exist.&lt;/p&gt;
&lt;p&gt;The lesson isn't to avoid early decisions.&lt;/p&gt;
&lt;p&gt;It's to make them visibly, with the assumptions documented, so the next person — including future you — knows what to revisit when the world changes.&lt;/p&gt;
&lt;h4 id=&quot;4-patents-are-a-record-of-unsolved-problems&quot;&gt;4. Patents Are a Record of Unsolved Problems&lt;/h4&gt;
&lt;p&gt;I have 20+ filed or issued patents.&lt;/p&gt;
&lt;p&gt;I'm prouder of the publications.&lt;/p&gt;
&lt;p&gt;A patent means you found an answer nobody had before.&lt;/p&gt;
&lt;p&gt;A peer-reviewed paper means you explained it clearly enough that other people can build on it.&lt;/p&gt;
&lt;p&gt;Both matter.&lt;/p&gt;
&lt;p&gt;But the second one is harder.&lt;/p&gt;
&lt;h4 id=&quot;5-the-best-teams-share-a-mental-model-not-just-a-codebase&quot;&gt;5. The Best Teams Share a Mental Model, Not Just a Codebase&lt;/h4&gt;
&lt;p&gt;At IBM and Huawei, I led large distributed teams, sometimes across time zones and cultures.&lt;/p&gt;
&lt;p&gt;What separated the high-performing teams from the rest wasn't raw talent.&lt;/p&gt;
&lt;p&gt;It was whether everyone in the room could draw the same system diagram from memory.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Technical alignment is a leadership output, not a documentation artifact.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;growth-as-a-developer-the-honest-version&quot;&gt;Growth as a Developer: The Honest Version&lt;/h3&gt;
&lt;p&gt;I went from writing integration tooling for e-commerce in 1997 to architecting an agentic AI platform for the industrial domain in 2026.&lt;/p&gt;
&lt;p&gt;The path looks linear on a résumé.&lt;/p&gt;
&lt;p&gt;It wasn't.&lt;/p&gt;
&lt;p&gt;There were long stretches of being the least experienced person in the room — and shorter, more uncomfortable stretches of being the most experienced person and realising I was wrong about something important.&lt;/p&gt;
&lt;p&gt;The Gartner iPaaS Visionary Quadrant recognition for Huawei's cloud integration platform within four years was a team achievement I'm proud of.&lt;/p&gt;
&lt;p&gt;But I remember more vividly the architectural dead-ends that preceded it, and the whiteboard sessions at night where we talked each other back from decisions that would have been expensive to unwind.&lt;/p&gt;
&lt;p&gt;The real growth came from three habits I'd encourage any developer to build early.&lt;/p&gt;
&lt;h4 id=&quot;capture-your-reasoning-not-just-your-results&quot;&gt;Capture Your Reasoning, Not Just Your Results&lt;/h4&gt;
&lt;p&gt;When you make an architectural decision, write down the why.&lt;/p&gt;
&lt;p&gt;What problem were you solving?&lt;/p&gt;
&lt;p&gt;What did you consider?&lt;/p&gt;
&lt;p&gt;What did you rule out?&lt;/p&gt;
&lt;p&gt;Code tells you what the system does.&lt;/p&gt;
&lt;p&gt;Only your notes tell you why it does it that way.&lt;/p&gt;
&lt;p&gt;I can't count the number of times I returned to a system months later, stared at a design choice that looked strange, and had no record of the constraints that made it sensible at the time.&lt;/p&gt;
&lt;p&gt;That missing context is expensive.&lt;/p&gt;
&lt;p&gt;It means re-doing the analysis, or worse, undoing a decision that was actually correct.&lt;/p&gt;
&lt;p&gt;Over the years, this became one of the most underrated disciplines I've tried to instil in every team I've led.&lt;/p&gt;
&lt;h4 id=&quot;read-outside-your-stack&quot;&gt;Read Outside Your Stack&lt;/h4&gt;
&lt;p&gt;Some of the best ideas I've applied to software engineering came from operations research, organisational theory, and systems biology.&lt;/p&gt;
&lt;p&gt;Distributed systems are complex adaptive systems, and the vocabulary for understanding them has existed in other fields for decades.&lt;/p&gt;
&lt;h3 id=&quot;developers-in-the-ai-era&quot;&gt;Developers in the AI Era&lt;/h3&gt;
&lt;p&gt;In the AI era, coding is no longer the scarcest skill.&lt;/p&gt;
&lt;p&gt;And that changes everything.&lt;/p&gt;
&lt;p&gt;I've watched developers at every stage of their career worry about being replaced by AI.&lt;/p&gt;
&lt;p&gt;I think they're asking the wrong question.&lt;/p&gt;
&lt;p&gt;AI is already a capable coding assistant.&lt;/p&gt;
&lt;p&gt;It will only get better.&lt;/p&gt;
&lt;p&gt;What it cannot do is understand why a business needs a system to work a certain way, see the architectural trade-offs across a five-year roadmap, or translate a domain expert's vague requirements into a design that will still be maintainable when the team changes.&lt;/p&gt;
&lt;p&gt;Those are human skills.&lt;/p&gt;
&lt;p&gt;And they matter more now than they did when writing code was the bottleneck.&lt;/p&gt;
&lt;p&gt;My advice to developers who are early in their careers is simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Invest in your ability to think in systems, understand the business problems behind the technical requirements, and communicate clearly across both worlds.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The developers who thrive in the next decade will be the ones who know how to direct AI effectively, not compete with it.&lt;/p&gt;
&lt;h3 id=&quot;what-i-m-building-now&quot;&gt;What I'm Building Now&lt;/h3&gt;
&lt;p&gt;I'm currently co-founding an AI startup focused on autonomous agentic systems for specific industrial domain sectors.&lt;/p&gt;
&lt;p&gt;We're designing enterprise AI agents that can reason, plan, and act with minimal human intervention.&lt;/p&gt;
&lt;p&gt;It's early.&lt;/p&gt;
&lt;p&gt;It's hard.&lt;/p&gt;
&lt;p&gt;And it's the most interesting technical problem I've worked on.&lt;/p&gt;
&lt;p&gt;The thread from my first line of C code to today's agentic systems is actually straightforward:&lt;/p&gt;
&lt;p&gt;I've always been drawn to the problem of making software do more with less human direction.&lt;/p&gt;
&lt;p&gt;E-commerce automation in 1997.&lt;/p&gt;
&lt;p&gt;Server provisioning orchestration in 2003.&lt;/p&gt;
&lt;p&gt;Serverless computing on the edge in 2020.&lt;/p&gt;
&lt;p&gt;Autonomous AI agents in 2026.&lt;/p&gt;
&lt;p&gt;The abstraction layer has risen.&lt;/p&gt;
&lt;p&gt;The fundamental question hasn't changed.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If you're a developer earlier in your journey, find that question — the one you keep circling back to no matter which technology is in fashion — and follow it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;The career will take care of itself.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;a-note-on-coderlegion&quot;&gt;A Note on CoderLegion&lt;/h3&gt;
&lt;p&gt;Communities like CoderLegion matter most in the messy middle of a wave when everyone is confident the technology is important but nobody yet agrees on the right way to use it.&lt;/p&gt;
&lt;p&gt;Agentic AI is squarely in that middle right now.&lt;/p&gt;
&lt;p&gt;The conversations I've found here, from practitioners who are actually building, not just theorising, are exactly the kind of signal that helps me calibrate what problems are real and which are manufactured by hype cycles.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;about-paul-chen&quot;&gt;About Paul Chen&lt;/h3&gt;
&lt;p&gt;Paul Chen is a &lt;strong&gt;COO focused on Agentic AI &amp;amp; Autonomous Systems and Intelligent Cloud &amp;amp; Edge Services&lt;/strong&gt;, with more than 30 years of experience in distributed systems, advanced cloud applications, and serverless platforms. He is currently focused on agentic AI, autonomous systems, and multi-agent systems. :contentReference[oaicite:1]{index=1}&lt;/p&gt;
&lt;p&gt;His career has spanned e-commerce, distributed systems, cloud computing, serverless platforms, and now autonomous AI systems.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/user/PaulChen088&quot;&gt;View Paul Chen's CoderLegion Profile&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Developer:&lt;/strong&gt; Paul Chen&lt;br&gt;
&lt;strong&gt;Series:&lt;/strong&gt; CoderLegion Developer Stories&lt;br&gt;
&lt;strong&gt;Interviewed and edited by:&lt;/strong&gt; Mehadi Hasan, Community &amp;amp; Editorial Team, CoderLegion&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26582/from-e-commerce-to-agentic-ai-paul-chens-30-year-developers-journey</guid>
<pubDate>Wed, 09 Sep 2026 09:22:02 +0000</pubDate>
</item>
<item>
<title>Idempotency Starts with Business Operation Identity</title>
<link>https://coderlegion.com/24207/idempotency-starts-with-business-operation-identity</link>
<description>&lt;p&gt;The hardest part of idempotency is often not the retry. It is deciding when two attempts represent the same business operation.&lt;/p&gt;
&lt;p&gt;Sometimes the identity is a request ID or external message ID. Sometimes it is a business key or a state transition that may happen only once. Once that identity is explicit, a transaction and database uniqueness constraint can enforce it, even when two attempts arrive together.&lt;/p&gt;
&lt;p&gt;Without identity, &quot;make it idempotent&quot; is not yet a design. The system still does not know what must not happen twice. This matches how Stripe and AWS describe safe retries: repeated attempts need a stable identity so the original side effect is not performed again.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24207/idempotency-starts-with-business-operation-identity</guid>
<pubDate>Thu, 06 Aug 2026 09:13:38 +0000</pubDate>
</item>
<item>
<title>Determinism Is a Core Contract in Build Caching</title>
<link>https://coderlegion.com/23974/determinism-is-a-core-contract-in-build-caching</link>
<description>&lt;p&gt;If you have set up a remote build cache and watched the hit rate sit stubbornly low, nondeterministic outputs are one important suspect — but not the only one. Source churn, changing flags, platform differences, toolchain changes, eviction, and incomplete input declarations also cause misses. Nondeterminism is particularly dangerous because it can quietly reduce reuse or make cached results untrustworthy without producing an obvious build error. It is worth understanding exactly how that happens and how to isolate it.&lt;/p&gt;
&lt;h3 id=&quot;why-determinism-is-the-contract-not-a-nice-to-have&quot;&gt;Why determinism is the contract, not a nice-to-have&lt;/h3&gt;
&lt;p&gt;A build cache works by fingerprinting an action's inputs and using that fingerprint to look up a previously computed output. The entire scheme rests on one assumption: identical inputs produce identical outputs. That is the contract. Break it and one of two bad things happens.&lt;/p&gt;
&lt;p&gt;If the cache keys only on inputs — which is how these systems work — and your action is nondeterministic, then the cache will happily hand back &lt;em&gt;an&lt;/em&gt; output for those inputs, but not necessarily the one a fresh run would produce. Best case, the variation is cosmetic and harmless. Worst case, you have just served a subtly wrong artifact from cache and you will spend a very confusing afternoon. Either way, the cache is now a source of doubt rather than speed.&lt;/p&gt;
&lt;p&gt;Another operational outcome is cascading misses, and that is where determinism stops being abstract.&lt;/p&gt;
&lt;h3 id=&quot;one-bad-action-poisons-everything-downstream&quot;&gt;One bad action poisons everything downstream&lt;/h3&gt;
&lt;p&gt;Builds are graphs. The output of one action is the input to the next. So consider what happens when a single early action — say, a code generator — embeds the current timestamp in its output. Each time that generator actually runs, the file it produces is byte-for-byte different, so its content hash changes.&lt;/p&gt;
&lt;p&gt;Whenever that generator has to execute again — because its entry was evicted, the cache was bypassed, or another declared input changed — it produces a new content digest. Every action that consumes the generated file then sees a different input and misses its previous cache entry. Their outputs feed further actions, so the effect can cascade through the subgraph. You did not merely lose reuse for one action; you invalidated downstream keys even when the semantic content did not need to change.&lt;/p&gt;
&lt;h3 id=&quot;where-nondeterminism-actually-comes-from&quot;&gt;Where nondeterminism actually comes from&lt;/h3&gt;
&lt;p&gt;The sources are mundane, which is what makes them easy to miss. The usual suspects:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Timestamps baked into outputs — &lt;code&gt;__DATE__&lt;/code&gt; and &lt;code&gt;__TIME__&lt;/code&gt; macros, build IDs, and the modification times stored inside archive formats like &lt;code&gt;.a&lt;/code&gt;, &lt;code&gt;.tar&lt;/code&gt;, and &lt;code&gt;.zip&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Absolute paths leaking into outputs — debug info that records the full build directory, &lt;code&gt;__FILE__&lt;/code&gt; expansions, paths embedded by the linker. Build the same code in &lt;code&gt;/home/alice&lt;/code&gt; and &lt;code&gt;/home/bob&lt;/code&gt; and you get different bytes.&lt;/li&gt;
&lt;li&gt;Iteration order — generating code by walking a hash map or set, whose iteration order is unspecified, so the output lines come out shuffled run to run.&lt;/li&gt;
&lt;li&gt;Concurrency — actions that assemble output from parallel work without imposing a stable order on the result.&lt;/li&gt;
&lt;li&gt;Randomness — UUIDs, unseeded PRNGs, anything that reaches for entropy at build time.&lt;/li&gt;
&lt;li&gt;The ambient environment — locale, hostname, username, and stray environment variables that the action reads but never declared as inputs.&lt;/li&gt;
&lt;li&gt;Filesystem ordering — relying on &lt;code&gt;readdir&lt;/code&gt; order, which varies across filesystems and runs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these throws an error. Each just makes one action's output wiggle, and the cache does the rest of the damage.&lt;/p&gt;
&lt;h3 id=&quot;how-to-hunt-them-down&quot;&gt;How to hunt them down&lt;/h3&gt;
&lt;p&gt;The basic diagnostic is to execute the same build twice from clean, isolated directories with cache reuse disabled, then compare the outputs bit for bit. Anything that differs deserves investigation. &lt;a rel=&quot;nofollow&quot; href=&quot;https://diffoscope.org/&quot;&gt;&lt;code&gt;diffoscope&lt;/code&gt;&lt;/a&gt; is the right tool here — it understands archives, binaries, and debug information and can show not just &lt;em&gt;that&lt;/em&gt; two outputs differ but &lt;em&gt;where&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The fixes are well-trodden, because the reproducible-builds community has been fighting this war for years:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Timestamps: honor &lt;code&gt;SOURCE_DATE_EPOCH&lt;/code&gt; so embedded dates are fixed rather than &quot;now&quot;; build archives in deterministic mode (&lt;code&gt;tar --sort=name --mtime=...&lt;/code&gt;, &lt;code&gt;ar&lt;/code&gt; in its deterministic &lt;code&gt;D&lt;/code&gt; mode).&lt;/li&gt;
&lt;li&gt;Absolute paths: use &lt;code&gt;-ffile-prefix-map&lt;/code&gt; / &lt;code&gt;-fdebug-prefix-map&lt;/code&gt; to rewrite build paths to a stable placeholder, so debug info does not depend on where you built.&lt;/li&gt;
&lt;li&gt;Iteration order: sort before you emit. If codegen walks a map, sort the keys first. Never let an unordered container decide output order.&lt;/li&gt;
&lt;li&gt;Environment: pin the locale (&lt;code&gt;LC_ALL=C&lt;/code&gt;), and run actions in a sandbox that only exposes declared inputs, so an undeclared environment dependency fails loudly instead of silently varying.&lt;/li&gt;
&lt;li&gt;Randomness: seed it, or design it out. A build is not the place for entropy.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;hermeticity-is-how-you-keep-it-fixed&quot;&gt;Hermeticity is how you keep it fixed&lt;/h3&gt;
&lt;p&gt;Fixing the determinism bugs you have is half the job. The other half is preventing new ones, and hermetic, sandboxed builds remove many sources of ambient input. A filesystem sandbox can hide undeclared files and tools, and a controlled environment can pin locale and environment variables. Sandboxing alone does not necessarily hide the wall clock, randomness, hostname, kernel details, or every network source; those must be blocked, virtualized, or deliberately normalized. The goal is to make undeclared dependencies fail loudly or become stable rather than silently changing outputs.&lt;/p&gt;
&lt;h3 id=&quot;measure-it-because-it-regresses&quot;&gt;Measure it, because it regresses&lt;/h3&gt;
&lt;p&gt;Determinism is not a one-time cleanup. It rots. Someone adds a code generator, someone embeds a build timestamp &quot;just for diagnostics,&quot; and reuse slides without anyone noticing. Treat cache hit rate as a first-class metric, but segment it by action type, platform, branch, and miss reason because determinism is only one contributor. Track reproducibility separately with scheduled rebuild-and-compare tests. When hit rate drops and action keys churn without meaningful input changes, reach for build-twice-and-diff before blaming the cache infrastructure.&lt;/p&gt;
&lt;p&gt;That is the real point. Determinism is not purity for its own sake. Along with complete input declarations, compatible platforms and toolchains, and retained cache entries, it is a precondition for trustworthy reuse. Get it wrong and the cache may return questionable outputs or lose reuse across a large subgraph. Get it right and equivalent actions can reliably share results across developers and CI. Determinism is not the whole cache system, but it is part of its correctness contract.&lt;/p&gt;
&lt;p&gt;Further reading: the &lt;a rel=&quot;nofollow&quot; href=&quot;https://reproducible-builds.org/docs/&quot;&gt;Reproducible Builds documentation&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23974/determinism-is-a-core-contract-in-build-caching</guid>
<pubDate>Tue, 04 Aug 2026 14:30:00 +0000</pubDate>
</item>
<item>
<title>Why Modern Engineering Teams Must Prioritize System Observability Over Raw Performance</title>
<link>https://coderlegion.com/22051/why-modern-engineering-teams-must-prioritize-system-observability-over-raw-performance</link>
<description>&lt;p&gt;Engineering teams often chase performance: faster APIs, lower latency, optimized queries, reduced CPU usage. But in 2026, performance alone is no longer the defining factor of a reliable system. Modern architectures — distributed, event‑driven, containerized, multi‑service — demand something far more fundamental: [system observability][1].&lt;/p&gt;
&lt;p&gt;Performance tells you how fast your system is.&lt;br&gt;
Observability tells you why it behaves the way it does.&lt;/p&gt;
&lt;p&gt;And for engineering teams, especially small ones, this difference determines whether a system scales or collapses under pressure.&lt;/p&gt;
&lt;p&gt;Performance Without Observability Is a Blindfold&lt;br&gt;
A fast system can still be fragile.&lt;br&gt;
A highly optimized service can still fail silently.&lt;br&gt;
A low‑latency API can still degrade unpredictably under load.&lt;/p&gt;
&lt;p&gt;When teams focus exclusively on performance metrics, they optimize symptoms instead of causes. They see spikes, drops, delays — but they don’t see the internal relationships that create them.&lt;/p&gt;
&lt;p&gt;Observability exposes:&lt;/p&gt;
&lt;p&gt;hidden dependencies&lt;/p&gt;
&lt;p&gt;cascading failures&lt;/p&gt;
&lt;p&gt;slow internal paths&lt;/p&gt;
&lt;p&gt;unexpected retry storms&lt;/p&gt;
&lt;p&gt;misconfigured backoff logic&lt;/p&gt;
&lt;p&gt;silent timeouts&lt;/p&gt;
&lt;p&gt;resource starvation&lt;/p&gt;
&lt;p&gt;cross‑service bottlenecks&lt;/p&gt;
&lt;p&gt;Without this visibility, engineers operate reactively. They fix what they see, not what actually breaks the system.&lt;/p&gt;
&lt;p&gt;The Three Pillars of Observability&lt;br&gt;
Modern observability is not just logs. It is a structured, multi‑layered system that allows engineers to understand why something happened.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Metrics — the heartbeat of the system&lt;br&gt;
Metrics show trends, anomalies, and thresholds.&lt;br&gt;
They reveal when something is off — but not why.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Key engineering metrics include:&lt;/p&gt;
&lt;p&gt;request latency distribution&lt;/p&gt;
&lt;p&gt;error rate spikes&lt;/p&gt;
&lt;p&gt;queue depth growth&lt;/p&gt;
&lt;p&gt;retry counts&lt;/p&gt;
&lt;p&gt;CPU throttling events&lt;/p&gt;
&lt;p&gt;memory pressure&lt;/p&gt;
&lt;p&gt;cache hit ratios&lt;/p&gt;
&lt;p&gt;Metrics are the first signal, not the full story.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Logs — the narrative&lt;br&gt;
Logs provide context.&lt;br&gt;
They explain the events behind the metrics.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;But logs alone are insufficient.&lt;br&gt;
Without structure, correlation, and context, logs become noise.&lt;/p&gt;
&lt;p&gt;Modern teams rely on:&lt;/p&gt;
&lt;p&gt;structured JSON logs&lt;/p&gt;
&lt;p&gt;correlation IDs&lt;/p&gt;
&lt;p&gt;distributed trace IDs&lt;/p&gt;
&lt;p&gt;log sampling&lt;/p&gt;
&lt;p&gt;log enrichment with metadata&lt;/p&gt;
&lt;p&gt;Logs transform raw events into actionable insights.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Traces — the map of the system&lt;br&gt;
Distributed tracing is the most powerful observability tool of 2026.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Traces show:&lt;/p&gt;
&lt;p&gt;how a request travels through the system&lt;/p&gt;
&lt;p&gt;where it slows down&lt;/p&gt;
&lt;p&gt;where it fails&lt;/p&gt;
&lt;p&gt;where retries accumulate&lt;/p&gt;
&lt;p&gt;where bottlenecks form&lt;/p&gt;
&lt;p&gt;where unexpected dependencies appear&lt;/p&gt;
&lt;p&gt;Traces reveal the architecture as it actually behaves — not as it was designed.&lt;/p&gt;
&lt;p&gt;Why Observability Matters More for Small Teams&lt;br&gt;
Large companies have SRE departments, dedicated monitoring teams, and 24/7 on‑call rotations.&lt;br&gt;
Small engineering teams do not.&lt;/p&gt;
&lt;p&gt;For small teams, observability is not a luxury — it is a multiplier.&lt;/p&gt;
&lt;p&gt;It allows them to:&lt;/p&gt;
&lt;p&gt;detect issues before users notice&lt;/p&gt;
&lt;p&gt;reduce on‑call stress&lt;/p&gt;
&lt;p&gt;shorten incident resolution time&lt;/p&gt;
&lt;p&gt;prevent cascading failures&lt;/p&gt;
&lt;p&gt;scale without rewriting the system&lt;/p&gt;
&lt;p&gt;understand real‑world behavior instead of assumptions&lt;/p&gt;
&lt;p&gt;Observability replaces guesswork with clarity.&lt;/p&gt;
&lt;p&gt;Observability Enables Predictable Engineering&lt;br&gt;
Predictability is the most valuable trait of a modern system.&lt;/p&gt;
&lt;p&gt;A predictable system:&lt;/p&gt;
&lt;p&gt;fails gracefully&lt;/p&gt;
&lt;p&gt;recovers automatically&lt;/p&gt;
&lt;p&gt;scales linearly&lt;/p&gt;
&lt;p&gt;behaves consistently under load&lt;/p&gt;
&lt;p&gt;provides clear signals when something is wrong&lt;/p&gt;
&lt;p&gt;Observability is the foundation of this predictability.&lt;br&gt;
It transforms engineering from reactive firefighting into proactive system design.&lt;/p&gt;
&lt;p&gt;The Future: Observability as a Core Engineering Skill&lt;br&gt;
In 2026, observability is no longer a DevOps specialty.&lt;br&gt;
It is a core engineering skill.&lt;/p&gt;
&lt;p&gt;Teams that master observability:&lt;/p&gt;
&lt;p&gt;ship faster&lt;/p&gt;
&lt;p&gt;debug faster&lt;/p&gt;
&lt;p&gt;scale safer&lt;/p&gt;
&lt;p&gt;maintain stability&lt;/p&gt;
&lt;p&gt;reduce operational cost&lt;/p&gt;
&lt;p&gt;build systems that survive real‑world conditions&lt;/p&gt;
&lt;p&gt;Performance matters.&lt;br&gt;
But system observability determines whether performance is sustainable.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22051/why-modern-engineering-teams-must-prioritize-system-observability-over-raw-performance</guid>
<pubDate>Wed, 15 Jul 2026 14:45:00 +0000</pubDate>
</item>
<item>
<title>The Art of Cloud Survival: The Day Monitoring Failed First</title>
<link>https://coderlegion.com/22753/the-art-of-cloud-survival-the-day-monitoring-failed-first</link>
<description>&lt;h3 id=&quot;when-everything-looked-fine&quot;&gt;When Everything Looked Fine&lt;/h3&gt;
&lt;p&gt;Modern cloud systems are built to survive failure.&lt;/p&gt;
&lt;p&gt;Applications run across multiple Availability Zones. Containers restart automatically. Serverless functions scale on demand. Databases replicate data continuously. Entire architectural patterns have emerged around the idea that failure is not an exception but an expected part of operating software at scale.&lt;/p&gt;
&lt;p&gt;Yet some of the most painful incidents begin long before a server crashes or a service becomes unavailable.&lt;/p&gt;
&lt;p&gt;They begin when nobody realizes something is already going wrong.&lt;/p&gt;
&lt;p&gt;Imagine a typical weekday morning. Dashboards are open on a large monitor. Every graph appears healthy. CPU utilization is well below critical thresholds. Memory consumption is stable. No alarms are firing. Error rates remain low.&lt;/p&gt;
&lt;p&gt;From an operational perspective, everything appears normal.&lt;/p&gt;
&lt;p&gt;Meanwhile, users are experiencing increasing delays. Transactions that usually complete in milliseconds now take several seconds. Some requests time out entirely. Support tickets begin arriving. Customer complaints appear in chat channels. Teams start investigating because users are reporting problems, not because monitoring systems detected them.&lt;/p&gt;
&lt;p&gt;The infrastructure is functioning. The monitoring strategy is not. And that distinction matters more than many teams realize.&lt;/p&gt;
&lt;h3 id=&quot;the-green-dashboard-problem&quot;&gt;The Green Dashboard Problem&lt;/h3&gt;
&lt;p&gt;One of the most dangerous moments in operations is staring at a dashboard filled with green indicators while customers are actively experiencing issues.&lt;/p&gt;
&lt;p&gt;Most engineering teams invest heavily in monitoring. They collect metrics, configure alarms, centralize logs, and build detailed dashboards. The intention is good. Visibility is essential in cloud environments.&lt;/p&gt;
&lt;p&gt;The challenge is that many dashboards focus on infrastructure health rather than service health.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A virtual machine can be healthy while an application is struggling.&lt;/li&gt;
&lt;li&gt;A database can be available while queries are becoming progressively slower.&lt;/li&gt;
&lt;li&gt;A queue can continue accepting messages while consumers fall further behind every minute.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In each case, the system is technically running. Yet the user experience is already deteriorating.&lt;/p&gt;
&lt;p&gt;Distributed systems make this problem even harder to detect because failures rarely appear as obvious outages. Instead, they emerge gradually, hidden inside latency, retries, timeouts, and backlogs.&lt;/p&gt;
&lt;p&gt;Even when the dashboard remains green,  the customer experience does not.&lt;/p&gt;
&lt;h3 id=&quot;why-distributed-systems-hide-failure&quot;&gt;Why Distributed Systems Hide Failure&lt;/h3&gt;
&lt;p&gt;In a monolithic application, a failure is often visible and immediate. If a critical component stops working, the impact is usually straightforward to identify.&lt;/p&gt;
&lt;p&gt;Distributed systems behave differently.&lt;/p&gt;
&lt;p&gt;A slowdown in one service can propagate through multiple dependencies. An overloaded database may increase response times. Those delays cause application servers to wait longer. Waiting requests consume resources. Retries begin accumulating. Message queues start growing. Additional traffic increases pressure on downstream services.&lt;/p&gt;
&lt;p&gt;Nothing has technically failed.&lt;/p&gt;
&lt;p&gt;Yet the system is moving steadily toward an incident.&lt;/p&gt;
&lt;p&gt;This is one reason distributed architectures are both powerful and challenging. Their resilience often comes from decoupling components, but that same decoupling can make problems difficult to observe.&lt;/p&gt;
&lt;p&gt;Symptoms appear far away from the original cause.&lt;/p&gt;
&lt;p&gt;By the time users notice, the underlying issue may have been developing for hours.&lt;/p&gt;
&lt;h3 id=&quot;monitoring-tells-you-what-you-expect&quot;&gt;Monitoring Tells You What You Expect&lt;/h3&gt;
&lt;p&gt;Many teams use the words &lt;strong&gt;monitoring&lt;/strong&gt; and &lt;strong&gt;observability&lt;/strong&gt; interchangeably.&lt;/p&gt;
&lt;p&gt;They are related, but they are not the same thing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Monitoring&lt;/strong&gt; helps answer questions we already know to ask. It tells us whether predefined conditions have been met.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Is CPU utilization above 80 percent?&lt;/li&gt;
&lt;li&gt;Is memory consumption increasing?&lt;/li&gt;
&lt;li&gt;Has an error threshold been exceeded?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are valuable questions. But real incidents rarely follow predefined scripts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Observability&lt;/strong&gt; focuses on understanding what is happening inside a system, especially when the problem is unexpected. Instead of simply collecting metrics, observability provides enough context to investigate unknown behaviors and discover why a system is acting differently than expected.&lt;/p&gt;
&lt;p&gt;This distinction becomes increasingly important as architectures grow more complex.&lt;/p&gt;
&lt;p&gt;A dashboard showing resource utilization may indicate that everything is healthy. Observability helps determine whether customers would agree.&lt;/p&gt;
&lt;h3 id=&quot;the-signals-that-actually-matter&quot;&gt;The Signals That Actually Matter&lt;/h3&gt;
&lt;p&gt;One of the most influential ideas in modern operations comes from Google's Site Reliability Engineering practices: the &lt;strong&gt;Four Golden Signals&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;These signals focus on the aspects of a system that most directly affect users.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=409537087628504462&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Together, these metrics provide a much more meaningful view of system health than infrastructure metrics alone.&lt;/p&gt;
&lt;p&gt;Consider a payment processing service.&lt;/p&gt;
&lt;p&gt;Suppose CPU utilization remains below 40 percent and memory usage appears stable. Traditional infrastructure monitoring would suggest the service is healthy.&lt;/p&gt;
&lt;p&gt;Now imagine database response times increase from 50 milliseconds to two seconds.&lt;/p&gt;
&lt;p&gt;Customers begin abandoning transactions. Requests start timing out. Revenue is affected.&lt;/p&gt;
&lt;p&gt;The infrastructure never appeared unhealthy. The customer experience did. And this is why effective monitoring begins with user-facing outcomes rather than resource consumption.&lt;/p&gt;
&lt;h3 id=&quot;the-danger-of-averages&quot;&gt;The Danger of Averages&lt;/h3&gt;
&lt;p&gt;Another common mistake is relying too heavily on averages. Average response times often create a comforting but incomplete picture. A service may report an average latency of 200 milliseconds while a small percentage of requests take several seconds to complete. Those slower requests may represent only a fraction of total traffic, yet they are often the first indication that something is wrong.&lt;/p&gt;
&lt;p&gt;For the affected users, the average is irrelevant.&lt;/p&gt;
&lt;p&gt;Their experience is defined by the request that took five seconds, not by the thousands that completed successfully.&lt;/p&gt;
&lt;p&gt;This is why mature operations teams pay close attention to &lt;strong&gt;percentile measurements&lt;/strong&gt; such as &lt;strong&gt;p95&lt;/strong&gt; and &lt;strong&gt;p99&lt;/strong&gt; latency.&lt;/p&gt;
&lt;p&gt;These metrics reveal what is happening at the edges of the system, where performance problems frequently emerge first.&lt;/p&gt;
&lt;p&gt;Many major incidents begin as small increases in tail latency long before average metrics show meaningful changes.&lt;/p&gt;
&lt;p&gt;If teams monitor only averages, they may discover problems far later than they should.&lt;/p&gt;
&lt;h3 id=&quot;failures-rarely-arrive-all-at-once&quot;&gt;Failures Rarely Arrive All at Once&lt;/h3&gt;
&lt;p&gt;Hollywood teaches us to expect disasters as sudden events. Real-world cloud incidents are usually much less dramatic.&lt;/p&gt;
&lt;p&gt;Most failures arrive gradually.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A dependency becomes slower.&lt;/li&gt;
&lt;li&gt;A third-party API starts returning intermittent errors.&lt;/li&gt;
&lt;li&gt;A queue begins accumulating messages.&lt;/li&gt;
&lt;li&gt;Consumers struggle to keep pace.&lt;/li&gt;
&lt;li&gt;Retries increase traffic.&lt;/li&gt;
&lt;li&gt;Latency spreads through the system.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What started as a minor degradation becomes a significant operational issue.&lt;/p&gt;
&lt;p&gt;This is one reason observability is so important. Detecting a problem early often means the difference between a brief investigation and a lengthy outage.&lt;/p&gt;
&lt;p&gt;The sooner a team can identify unusual behavior, the more options they have to contain the impact.&lt;/p&gt;
&lt;p&gt;Cloud survival is rarely about preventing every failure, it is about recognizing failures before they become incidents.&lt;/p&gt;
&lt;h3 id=&quot;building-visibility-on-aws&quot;&gt;Building Visibility on AWS&lt;/h3&gt;
&lt;p&gt;AWS provides a rich set of services that help teams understand what is happening inside distributed systems.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Amazon CloudWatch&lt;/strong&gt; serves as the foundation for metrics, logs, dashboards, and alarms. It enables teams to monitor operational signals across applications and infrastructure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS X-Ray&lt;/strong&gt; helps trace requests as they move through multiple services, making it easier to identify bottlenecks and understand where latency is introduced.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon EventBridge&lt;/strong&gt; can automate operational workflows, trigger responses to specific events, and help reduce the time between detection and action.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Used together, these services create visibility across layers of a distributed architecture.&lt;/p&gt;
&lt;p&gt;But tools alone do not guarantee observability. Organizations often spend months building dashboards while investing very little effort in defining what success or failure actually looks like from a user's perspective. Technology is only part of the solution. The real challenge is deciding what deserves attention.&lt;/p&gt;
&lt;h3 id=&quot;the-question-every-team-should-ask&quot;&gt;The Question Every Team Should Ask&lt;/h3&gt;
&lt;p&gt;There is a simple question that every engineering team should periodically ask:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;If a customer experiences a serious problem right now, would we know before they tell us?&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The answer is often uncomfortable.&lt;/p&gt;
&lt;p&gt;Many teams discover that their alerts focus on infrastructure while customer-impacting issues remain invisible until support tickets arrive.&lt;/p&gt;
&lt;p&gt;That realization can be frustrating, but it is also valuable.&lt;/p&gt;
&lt;p&gt;Because once teams understand the gap between system health and customer experience, they can begin designing monitoring strategies that reflect reality instead of assumptions.&lt;/p&gt;
&lt;p&gt;The goal is not to collect more metrics, the goal is to collect the right ones.&lt;/p&gt;
&lt;h3 id=&quot;cloud-survival-requires-visibility&quot;&gt;Cloud Survival Requires Visibility&lt;/h3&gt;
&lt;p&gt;The most resilient cloud architectures are not always the ones with the most automation, the most services, or the most sophisticated deployment pipelines.&lt;/p&gt;
&lt;p&gt;Often, they are the ones that provide the clearest visibility into what users are actually experiencing.&lt;br&gt;
Failures are inevitable. Latency will increase. Dependencies will degrade. Services will behave in unexpected ways. Distributed systems guarantee that complexity will eventually surface. What separates resilient teams from struggling ones is not their ability to avoid failure.&lt;/p&gt;
&lt;p&gt;It is their ability to &lt;strong&gt;see it&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The day monitoring fails first is usually the day a team learns an important lesson: collecting metrics is not the same as understanding a system. Cloud survival requires both.&lt;/p&gt;
&lt;p&gt;Because you cannot respond to a problem you cannot see.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22753/the-art-of-cloud-survival-the-day-monitoring-failed-first</guid>
<pubDate>Wed, 15 Jul 2026 12:13:04 +0000</pubDate>
</item>
<item>
<title>When &quot;Latest&quot; isn't Actually Latest</title>
<link>https://coderlegion.com/21755/when-latest-isnt-actually-latest</link>
<description>&lt;h2 id=&quot;when-latest-isn-t-actually-latest&quot;&gt;When &quot;Latest&quot; Isn't Actually Latest&lt;/h2&gt;
&lt;h3 id=&quot;tl-dr&quot;&gt;TL;DR&lt;/h3&gt;
&lt;p&gt;In distributed systems, the last record your system processes is not always the most recent event that happened in reality.&lt;/p&gt;
&lt;p&gt;Understanding the difference between &lt;strong&gt;Event Time&lt;/strong&gt; and &lt;strong&gt;Processing Time&lt;/strong&gt; is essential to avoid data quality issues in streaming pipelines, CDC architectures, and event-driven systems.&lt;/p&gt;
&lt;h3 id=&quot;the-assumption-that-works-until-it-doesn-t&quot;&gt;The Assumption That Works... Until It Doesn't&lt;/h3&gt;
&lt;p&gt;Most developers start their careers working with a single application and a single database.&lt;/p&gt;
&lt;p&gt;In that world, time feels simple.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A customer updates their profile.&lt;/li&gt;
&lt;li&gt;The database stores the change.&lt;/li&gt;
&lt;li&gt;Everyone sees the updated record.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The sequence appears obvious because all operations happen within the same system.&lt;/p&gt;
&lt;p&gt;Then distributed systems enter the picture.&lt;/p&gt;
&lt;h3 id=&quot;a-simple-example&quot;&gt;A Simple Example&lt;/h3&gt;
&lt;p&gt;Imagine a customer updates their address at 10:00:00.&lt;/p&gt;
&lt;p&gt;That change generates an event and begins traveling through a distributed architecture:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Application&lt;/li&gt;
&lt;li&gt;Database&lt;/li&gt;
&lt;li&gt;CDC pipeline&lt;/li&gt;
&lt;li&gt;Kafka&lt;/li&gt;
&lt;li&gt;Consumer service&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Everything looks normal.&lt;/p&gt;
&lt;p&gt;Now imagine an older address update, generated earlier, gets delayed because of a network issue or retry mechanism.&lt;/p&gt;
&lt;p&gt;The result?&lt;/p&gt;
&lt;p&gt;The older event arrives after the newer one.&lt;/p&gt;
&lt;p&gt;Your consumer processes the events in this order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;New address&lt;/li&gt;
&lt;li&gt;Old address&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If your logic assumes that the last processed record represents the latest truth, you've just overwritten correct information with stale data.&lt;/p&gt;
&lt;h3 id=&quot;the-real-problem-time-has-multiple-meanings&quot;&gt;The Real Problem: Time Has Multiple Meanings&lt;/h3&gt;
&lt;p&gt;This is why distributed systems distinguish between two important concepts:&lt;/p&gt;
&lt;h4 id=&quot;event-time&quot;&gt;Event Time&lt;/h4&gt;
&lt;p&gt;The moment something actually happened in the real world.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Customer changed address → 10:00:00&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;processing-time&quot;&gt;Processing Time&lt;/h4&gt;
&lt;p&gt;The moment a system receives or processes the event.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Consumer receives event → 10:00:15&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Most of the time, these timestamps are close enough that nobody notices.&lt;/p&gt;
&lt;p&gt;The interesting cases appear when they aren't.&lt;/p&gt;
&lt;h3 id=&quot;where-things-get-complicated&quot;&gt;Where Things Get Complicated&lt;/h3&gt;
&lt;p&gt;Several factors can cause events to arrive out of order:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Network latency&lt;/li&gt;
&lt;li&gt;Retries&lt;/li&gt;
&lt;li&gt;Consumer lag&lt;/li&gt;
&lt;li&gt;Parallel processing&lt;/li&gt;
&lt;li&gt;System failures&lt;/li&gt;
&lt;li&gt;Distributed transactions&lt;/li&gt;
&lt;li&gt;CDC replication delays&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As architectures become more distributed, these situations become normal rather than exceptional.&lt;/p&gt;
&lt;h3 id=&quot;what-about-kafka-&quot;&gt;What About Kafka?&lt;/h3&gt;
&lt;p&gt;Kafka helps preserve ordering within a partition through offsets.&lt;/p&gt;
&lt;p&gt;However, offsets tell us the order in which records were written to Kafka.&lt;/p&gt;
&lt;p&gt;They do not guarantee the order in which events actually occurred in the real world.&lt;/p&gt;
&lt;p&gt;That's an important distinction.&lt;/p&gt;
&lt;p&gt;A perfectly ordered Kafka partition can still contain events that represent out-of-order business actions.&lt;/p&gt;
&lt;h3 id=&quot;why-data-engineers-care&quot;&gt;Why Data Engineers Care&lt;/h3&gt;
&lt;p&gt;Many expensive data quality problems are not caused by broken infrastructure.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pipelines may be healthy.&lt;/li&gt;
&lt;li&gt;Kafka may be running perfectly.&lt;/li&gt;
&lt;li&gt;Consumers may be processing records successfully.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The issue is often more subtle.&lt;/p&gt;
&lt;p&gt;The system answers the right question using the wrong sequence of events.&lt;/p&gt;
&lt;p&gt;When that happens, dashboards become inaccurate, customer profiles become inconsistent, and downstream decisions become unreliable.&lt;/p&gt;
&lt;h3 id=&quot;final-thought&quot;&gt;Final Thought&lt;/h3&gt;
&lt;p&gt;Distributed systems force us to rethink a concept we usually take for granted: &lt;strong&gt;time&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The challenge isn't simply moving data from one system to another.&lt;/p&gt;
&lt;p&gt;The challenge is preserving the meaning of that data while it travels through a network of independent components.&lt;/p&gt;
&lt;p&gt;Because in distributed systems, the most recent record is not always the most recent event.&lt;/p&gt;
&lt;p&gt;And understanding that difference is often the first step toward building reliable data systems.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21755/when-latest-isnt-actually-latest</guid>
<pubDate>Thu, 02 Jul 2026 11:08:55 +0000</pubDate>
</item>
<item>
<title>The Miyake Event Problem: Anchoring Distributed Agents to Universal Time</title>
<link>https://coderlegion.com/21383/the-miyake-event-problem-anchoring-distributed-agents-to-universal-time</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://vibeagentmaking.com/blog/the-miyake-event-problem/&quot;&gt;vibeagentmaking.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;In 2021, a team led by Margot Kuitems pinned the Viking settlement at L'Anse aux Meadows to a single year: &lt;strong&gt;1021 CE&lt;/strong&gt;. Before that work, the best estimate was &quot;roughly 1000 CE&quot; with multi-decade uncertainty.&lt;/p&gt;
&lt;p&gt;They didn't build a better mass spectrometer. They found a cosmic-ray spike from 993 CE recorded in three pieces of wood, counted 28 rings to the bark, and arrived at a date no one can argue with. Precision improved 40–120x — not by better measurement, but by identifying the right external signal.&lt;/p&gt;
&lt;p&gt;If you've ever worked with distributed systems, this should sound familiar. Your system has the same problem those archaeologists had before 2012.&lt;/p&gt;
&lt;h3 id=&quot;the-floating-chronology-problem&quot;&gt;The floating chronology problem&lt;/h3&gt;
&lt;p&gt;Before Miyake events were discovered, dendrochronology had a &quot;floating chronology&quot; problem. You could count tree rings and measure relative widths, but without an external anchor, the entire sequence could be off by decades. The rings were &lt;em&gt;relatively&lt;/em&gt; dated but not &lt;em&gt;absolutely&lt;/em&gt; dated.&lt;/p&gt;
&lt;p&gt;Crossdating — matching ring patterns between trees across sites — solved this regionally. It works because climate is a shared signal: trees in the same region record the same drought years.&lt;/p&gt;
&lt;p&gt;But crossdating only works within a region. A Japanese cedar can't be crossdated against a California bristlecone pine by ring width alone. For global anchoring, you need a signal every tree on Earth records simultaneously.&lt;/p&gt;
&lt;p&gt;In 2012, Fusa Miyake found one: a 1.2% single-year spike in atmospheric carbon-14 from 774–775 CE — 20x the normal variation. Nine confirmed Miyake events have since been identified. These are extreme solar particle events imprinted in every growing tree, every ice core, globally, in the same calendar year.&lt;/p&gt;
&lt;p&gt;Five properties make these uniquely powerful as anchors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Global&lt;/strong&gt; — every tree, everywhere&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simultaneous&lt;/strong&gt; — same calendar year&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unambiguous&lt;/strong&gt; — 20x above noise&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Indelible&lt;/strong&gt; — physically imprinted, can't be altered&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Independently verifiable&lt;/strong&gt; — any lab can check&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;distributed-systems-have-the-same-problem&quot;&gt;Distributed systems have the same problem&lt;/h3&gt;
&lt;p&gt;Lamport formalized it in 1978: across multiple machines, &quot;now&quot; becomes ambiguous. You can't rely on physical clocks alone because they drift (commodity hardware: 10–200 ppm), networks have variable latency, and there's no single reference frame.&lt;/p&gt;
&lt;p&gt;This is exactly the floating chronology. A single node's log is internally consistent — Event A before Event B on &lt;em&gt;this&lt;/em&gt; machine. But across nodes, without an external anchor, you can't order events reliably. The logs float.&lt;/p&gt;
&lt;p&gt;Lamport timestamps track causality rather than wall-clock time — the distributed-systems equivalent of crossdating. But like regional crossdating, they break at the boundaries: two causally disconnected streams can't be ordered.&lt;/p&gt;
&lt;h3 id=&quot;ai-agents-face-it-in-a-harder-form&quot;&gt;AI agents face it in a harder form&lt;/h3&gt;
&lt;p&gt;LLM-based agents are stateless by design — every inference call starts fresh. Unlike a database node that persists its logical clock, a restarted agent loses temporal position entirely.&lt;/p&gt;
&lt;p&gt;Context windows make it worse: 30%+ accuracy reduction for information in the middle of the context. Older timestamps degrade. And in multi-agent coordination, 36.94% of system failures trace back to stale state propagation — one agent acts on outdated information because there's no shared temporal reference.&lt;/p&gt;
&lt;h3 id=&quot;better-clocks-vs-better-anchors&quot;&gt;Better clocks vs. better anchors&lt;/h3&gt;
&lt;p&gt;Google Spanner deploys GPS receivers and atomic clocks in every data center. TrueTime gets uncertainty down to ~7ms. Before committing a transaction, Spanner &lt;strong&gt;waits out the uncertainty&lt;/strong&gt; to guarantee ordering. This works brilliantly — if you have Google-scale infrastructure.&lt;/p&gt;
&lt;p&gt;CockroachDB, without atomic clocks, uses NTP (100–250ms offsets, 14–36x worse). When clock drift exceeds its configured maximum, a CockroachDB node &lt;strong&gt;self-terminates&lt;/strong&gt; rather than risk inconsistent timestamps.&lt;/p&gt;
&lt;p&gt;The system would rather die than lie about time. This maps to dendrochronology's practice of marking uncertain dates with &quot;?&quot; and refusing to report unreliable dates.&lt;/p&gt;
&lt;p&gt;But both approaches try to improve the &lt;em&gt;internal&lt;/em&gt; clock. Neither looks for external universal anchors. The L'Anse aux Meadows team didn't build a better mass spectrometer. They found the right signal.&lt;/p&gt;
&lt;h3 id=&quot;digital-miyake-events&quot;&gt;Digital Miyake events&lt;/h3&gt;
&lt;p&gt;Ethereum's Beacon Chain: 12-second slots, finalization via Casper FFG after 2/3 validator attestation. Reverting a finalized block costs $30B+ in staked ETH. Bitcoin block height: monotonically increasing, enforced by proof-of-work.&lt;/p&gt;
&lt;p&gt;Score these against the five-property framework and blockchain finalization hits 4.5/5 — the closest digital analog to a Miyake spike.&lt;/p&gt;
&lt;p&gt;An agent that records &quot;I observed state X as of Ethereum block 22,456,789&quot; has a timestamp claim that anyone can verify, forever, without trusting the agent's local clock.&lt;/p&gt;
&lt;p&gt;Tools like Chainpoint, OpenTimestamps, and OriginStamp already implement this pattern — aggregating hashes into Merkle trees, anchoring to Bitcoin transactions, generating self-contained proofs. This is crossdating for digital timestamps.&lt;/p&gt;
&lt;h3 id=&quot;the-ironies&quot;&gt;The ironies&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Trees are better archival substrates than blockchains.&lt;/strong&gt; A tree passively records the Miyake signal — no software, no network, no fees. We can date 7,000-year-old bristlecone pines. We can't verify a 15-year-old digital timestamp without the original chain.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Miyake events would destroy digital systems.&lt;/strong&gt; The 774 CE event was 10x stronger than the Carrington Event — roughly 660 billion Hiroshima bombs of energy. Nature's best timestamp generator is also a civilization-ending threat to the systems that most need time anchors.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dendrochronology solved this 50 years before CS.&lt;/strong&gt; Douglass's crossdating (1920s) predates Lamport's logical clocks (1978) by half a century. Convergent evolution: two unrelated fields facing the same constraint arrived at the same architecture.&lt;/p&gt;
&lt;h3 id=&quot;the-lesson&quot;&gt;The lesson&lt;/h3&gt;
&lt;p&gt;The fix is not better clocks. It is better anchors.&lt;/p&gt;
&lt;p&gt;The 993 CE spike had been sitting in every tree alive that year for a thousand years before anyone thought to look for it. The on-chain anchors are already there. The question is whether your system is looking.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Full essay with sources and data tables: &lt;a rel=&quot;nofollow&quot; href=&quot;https://vibeagentmaking.com/blog/the-miyake-event-problem/&quot;&gt;vibeagentmaking.com/blog/the-miyake-event-problem/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix is not better clocks. It is better anchors.&lt;/strong&gt; Chain of Consciousness applies the dendrochronologist's lesson to agent provenance — every action is anchored to a verifiable external record, building a chronology that doesn't float. Verify which events an agent can prove it witnessed, in what order, anchored to what: &lt;code&gt;pip install chain-of-consciousness&lt;/code&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21383/the-miyake-event-problem-anchoring-distributed-agents-to-universal-time</guid>
<pubDate>Fri, 26 Jun 2026 03:09:29 +0000</pubDate>
</item>
<item>
<title>The Art of Cloud Survival: When Retries Become the Outage</title>
<link>https://coderlegion.com/21369/the-art-of-cloud-survival-when-retries-become-the-outage</link>
<description>&lt;h2 id=&quot;when-retries-become-the-outage&quot;&gt;When Retries Become the Outage&lt;/h2&gt;
&lt;p&gt;At 2:17 AM, a payment service started returning errors. Not many. Just enough to attract attention. The dashboards didn't look alarming. CPU utilization was stable. Memory consumption was within normal limits. Infrastructure health checks were passing. If you had opened the monitoring console at that moment, you probably would have assumed the issue was temporary and would resolve itself within a few minutes.&lt;/p&gt;
&lt;p&gt;The engineering team began investigating. Meanwhile, the rest of the system was already reacting. API clients started resubmitting requests. Lambda functions retried failed executions. Messages that couldn't be processed returned to queues. Downstream services attempted the same operations again, hoping the next attempt would succeed.&lt;/p&gt;
&lt;p&gt;None of these actions were wrong. In fact, every component was behaving exactly as it had been designed to behave. And that was the problem.&lt;/p&gt;
&lt;p&gt;What began as a relatively small service degradation gradually evolved into a much larger incident. The original failure affected a limited number of requests. The recovery mechanisms multiplied that traffic until the struggling service was forced to handle far more load than it had seen before the outage began.&lt;/p&gt;
&lt;p&gt;The system wasn't collapsing because of the initial error anymore. It was collapsing because everybody was trying to help.&lt;/p&gt;
&lt;h3 id=&quot;why-retries-feel-like-the-right-answer&quot;&gt;Why Retries Feel Like the Right Answer&lt;/h3&gt;
&lt;p&gt;Retries are one of the first resilience patterns most engineers learn, and for good reason.&lt;/p&gt;
&lt;p&gt;Distributed systems operate in an imperfect world. Networks experience brief interruptions. Services become temporarily unavailable. Databases occasionally take longer than expected to respond. A request that fails now may succeed a few seconds later without any intervention.&lt;/p&gt;
&lt;p&gt;Because of this, retries often improve reliability. A customer never notices the timeout. The application recovers automatically. Everybody wins. The trouble starts when we focus on the success story and forget to think about scale. A single retry seems harmless. Ten thousand retries are something else entirely.&lt;/p&gt;
&lt;p&gt;Imagine a service receiving 10,000 requests during a busy period. Suddenly, a dependency starts responding slowly. Requests begin timing out. Each client retries three times before giving up.&lt;/p&gt;
&lt;p&gt;Without realizing it, the system has transformed 10,000 requests into 40,000. That additional traffic creates more pressure on the dependency. More pressure leads to higher latency. Higher latency generates more failures. More failures trigger more retries. What looked like a recovery mechanism has become a feedback loop. And feedback loops are where distributed systems become dangerous.&lt;/p&gt;
&lt;h3 id=&quot;the-real-problem-isn-t-failure&quot;&gt;The Real Problem Isn't Failure&lt;/h3&gt;
&lt;p&gt;One of the hardest lessons in cloud architecture is that systems rarely fail where the problem starts.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A database slows down.&lt;/li&gt;
&lt;li&gt;An API begins timing out.&lt;/li&gt;
&lt;li&gt;A message consumer falls behind.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those events are usually manageable.&lt;/p&gt;
&lt;p&gt;The real challenge comes from the chain reaction that follows. Every service makes a reasonable decision based on its local perspective. Every team implements protections intended to improve reliability. Every retry policy makes sense when viewed in isolation.&lt;/p&gt;
&lt;p&gt;Yet when an incident occurs, those independent decisions begin interacting with each other. The result is often a level of complexity that nobody anticipated during design reviews. I've seen incidents where the root cause was resolved within minutes, but the platform continued struggling for hours because retries, reprocessing, and recovery workflows kept generating load long after the original issue had disappeared.&lt;/p&gt;
&lt;p&gt;The failure wasn't the hardest part. Recovering from the recovery was.&lt;/p&gt;
&lt;h3 id=&quot;when-aws-does-exactly-what-you-asked-it-to-do&quot;&gt;When AWS Does Exactly What You Asked It To Do&lt;/h3&gt;
&lt;p&gt;AWS services are designed to help us build resilient systems, which is why many of them include retry mechanisms by default.&lt;/p&gt;
&lt;p&gt;Take Lambda and SQS. When a Lambda function cannot successfully process a message, that message doesn't simply disappear. It becomes available again and can be processed later. This behavior protects against transient failures and prevents data loss. Most of the time, that's exactly what we want.&lt;/p&gt;
&lt;p&gt;However, if the underlying problem is persistent—a broken dependency, an invalid payload, or a misconfigured service—the same message may be processed repeatedly while consuming compute resources and generating additional pressure on downstream systems. The same principle appears in event-driven architectures.&lt;/p&gt;
&lt;p&gt;Services such as EventBridge improve reliability by attempting redelivery when consumers fail. That's an incredibly valuable capability. However, it also means that consumers must be designed with duplicate events in mind. Retries improve resilience. But resilience is never free.&lt;/p&gt;
&lt;p&gt;Every retry consumes resources, generates traffic, and increases complexity. The question is not whether retries are good or bad. The question is whether the system can absorb the consequences when retries occur at scale.&lt;/p&gt;
&lt;h3 id=&quot;designing-retries-that-help&quot;&gt;Designing Retries That Help&lt;/h3&gt;
&lt;p&gt;Over the years, I've become less interested in whether a system retries and more interested in how it retries. The difference matters. One of the simplest improvements is &lt;strong&gt;exponential backoff&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Instead of retrying every second, the delay increases after each attempt. A request may wait one second, then two, then four, then eight. This gives dependencies time to recover and reduces the likelihood of overwhelming a struggling service.&lt;/p&gt;
&lt;p&gt;Even then, another challenge remains. Imagine a million clients all deciding to retry after exactly eight seconds. Eight seconds later, they arrive together. The system experiences another traffic spike.&lt;/p&gt;
&lt;p&gt;This is why mature systems often introduce &lt;strong&gt;jitter&lt;/strong&gt;, adding a small amount of randomness to retry timing. The overall volume remains similar, but the requests become distributed over time rather than arriving in synchronized waves.&lt;/p&gt;
&lt;p&gt;Another valuable pattern is the &lt;strong&gt;circuit breaker&lt;/strong&gt;. When a dependency is clearly unavailable, continuing to send requests often provides little benefit. A circuit breaker temporarily stops traffic, allowing the dependency to recover while protecting the rest of the system from unnecessary load.&lt;/p&gt;
&lt;p&gt;And sometimes, the most responsible decision is not to retry immediately at all.&lt;/p&gt;
&lt;p&gt;Sometimes the correct answer is to move the failed message to a &lt;strong&gt;Dead Letter Queue&lt;/strong&gt;, investigate the cause, and replay it later under controlled conditions. That approach may feel less aggressive than continuous retries, but it often prevents a localized issue from becoming a platform-wide incident.&lt;/p&gt;
&lt;h3 id=&quot;a-different-way-to-think-about-resilience&quot;&gt;A Different Way to Think About Resilience&lt;/h3&gt;
&lt;p&gt;Early in my career, I thought resilience was mostly about making systems persistent.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If something failed, try again.&lt;/li&gt;
&lt;li&gt;If it failed again, try harder.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Experience changed that perspective.&lt;/p&gt;
&lt;p&gt;Today, I think resilience is often about &lt;strong&gt;restraint&lt;/strong&gt;. It's about understanding that every recovery mechanism introduces new behavior into the system. It's about recognizing that retries are not free. It's about accepting that the healthiest response to failure is not always immediate action. Sometimes the best thing a system can do is wait. Sometimes it's stop. Sometimes it's fail gracefully.&lt;/p&gt;
&lt;p&gt;The most resilient cloud architectures aren't the ones that retry forever. They're the ones that understand the difference between recovering from a problem and amplifying it. Because in distributed systems, the outage doesn't always begin with the first failure. Sometimes it begins with the second attempt.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21369/the-art-of-cloud-survival-when-retries-become-the-outage</guid>
<pubDate>Thu, 25 Jun 2026 13:27:41 +0000</pubDate>
</item>
<item>
<title>The Hidden Cost of Distributed Systems</title>
<link>https://coderlegion.com/20293/the-hidden-cost-of-distributed-systems</link>
<description>&lt;p&gt;One of the most interesting contradictions in cloud architecture is that we spend a tremendous amount of effort trying to eliminate complexity, yet many of the systems we build seem to become more complex over time.&lt;/p&gt;
&lt;p&gt;A startup might begin with a single application, a single database, and a handful of users. Everything is relatively easy to understand. A request comes in, business logic is executed, data is stored, and a response is returned. The architecture is straightforward, deployments are simple, and troubleshooting usually involves looking in one place.&lt;/p&gt;
&lt;p&gt;Then growth happens.&lt;/p&gt;
&lt;p&gt;New customers arrive. New features are requested. More engineers join the team. Integrations are added. Reporting requirements expand. Mobile applications appear. Real-time processing becomes important. What was once a single application gradually evolves into a collection of services, databases, queues, and APIs.&lt;/p&gt;
&lt;p&gt;Without necessarily realizing it, the organization has started building a distributed system.&lt;/p&gt;
&lt;h3 id=&quot;what-exactly-is-a-distributed-system-&quot;&gt;What Exactly Is a Distributed System?&lt;/h3&gt;
&lt;p&gt;At its core, a distributed system is simply a group of independent components working together to provide what users perceive as a single application.&lt;/p&gt;
&lt;p&gt;Consider what happens when a customer places an order on an e-commerce platform. From the customer's perspective, the experience feels simple: click a button, receive a confirmation, and wait for the package to arrive.&lt;/p&gt;
&lt;p&gt;Behind the scenes, however, the request may travel through multiple services. One service records the order, another processes the payment, another updates inventory, while additional systems generate notifications, update dashboards, and trigger downstream workflows. Each component owns a specific responsibility, yet all of them collaborate to complete a single business transaction.&lt;/p&gt;
&lt;p&gt;This separation of responsibilities is one of the defining characteristics of distributed systems.&lt;/p&gt;
&lt;h3 id=&quot;why-do-we-build-them-&quot;&gt;Why Do We Build Them?&lt;/h3&gt;
&lt;p&gt;If distributed systems introduce additional complexity, a reasonable question follows:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why not keep everything inside a single application?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The answer is that distributed systems solve problems that become increasingly difficult to address as organizations grow.&lt;/p&gt;
&lt;p&gt;Imagine an online retailer preparing for Black Friday. The checkout process may suddenly experience a massive increase in traffic, while administrative reporting tools remain largely unchanged. In a monolithic architecture, scaling one component often means scaling the entire application. In a distributed architecture, services can scale independently based on their specific needs.&lt;/p&gt;
&lt;p&gt;The benefits extend beyond scalability. Distributed systems also help organizations isolate failures, allow teams to work more independently, and enable different technologies to be used for different workloads. As systems and engineering organizations grow, these advantages become increasingly valuable.&lt;/p&gt;
&lt;p&gt;This is why most modern cloud-native architectures eventually move in this direction.&lt;/p&gt;
&lt;p&gt;Not because distributed systems are fashionable.&lt;/p&gt;
&lt;p&gt;Because they solve real business and operational challenges.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-offs-begin-the-moment-you-split-the-system&quot;&gt;The Trade-Offs Begin the Moment You Split the System&lt;/h3&gt;
&lt;p&gt;The problem is that complexity does not disappear.&lt;/p&gt;
&lt;p&gt;It simply moves.&lt;/p&gt;
&lt;p&gt;Inside a monolithic application, communication often happens through function calls within the same process. In a distributed system, communication happens over a network. That seemingly small change introduces an entirely new category of problems.&lt;/p&gt;
&lt;p&gt;Networks introduce latency. Requests time out. Connections fail. Messages arrive late. Sometimes they arrive twice. Occasionally they never arrive at all.&lt;/p&gt;
&lt;p&gt;Engineers must now design systems that can tolerate these realities.&lt;/p&gt;
&lt;p&gt;The architecture becomes more flexible, but it also becomes less predictable.&lt;/p&gt;
&lt;h3 id=&quot;data-consistency-becomes-a-design-problem&quot;&gt;Data Consistency Becomes a Design Problem&lt;/h3&gt;
&lt;p&gt;One of the biggest mindset shifts occurs when data is no longer stored in a single place.&lt;/p&gt;
&lt;p&gt;Imagine a payment service successfully charges a customer. Immediately afterward, the inventory service fails before reserving the product. The customer has paid, but the order cannot be fulfilled.&lt;/p&gt;
&lt;p&gt;What should happen next?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Should the payment be refunded automatically?&lt;/li&gt;
&lt;li&gt;Should the inventory operation be retried?&lt;/li&gt;
&lt;li&gt;Should the order remain pending until a human intervenes?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These questions rarely emerge in simple applications, but they appear regularly in distributed systems.&lt;/p&gt;
&lt;p&gt;As a result, engineers begin adopting concepts such as &lt;strong&gt;eventual consistency&lt;/strong&gt;, &lt;strong&gt;retries&lt;/strong&gt;, &lt;strong&gt;idempotency&lt;/strong&gt;, &lt;strong&gt;dead-letter queues&lt;/strong&gt;, and &lt;strong&gt;event-driven communication patterns&lt;/strong&gt;. These are not merely technical buzzwords. They are practical responses to the challenges created by distributing responsibilities across multiple services.&lt;/p&gt;
&lt;h3 id=&quot;choosing-complexity-carefully&quot;&gt;Choosing Complexity Carefully&lt;/h3&gt;
&lt;p&gt;One of the most valuable lessons in software architecture is that every decision involves trade-offs.&lt;/p&gt;
&lt;p&gt;Distributed systems provide scalability, resilience, and organizational flexibility. They also introduce operational overhead, additional failure modes, and significantly more complexity.&lt;/p&gt;
&lt;p&gt;For that reason, building a distributed system should never be the objective.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Solving a business problem should be the objective.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Sometimes that solution is a distributed architecture. Sometimes it is a well-designed monolith. The right answer depends entirely on the requirements, constraints, and growth trajectory of the system.&lt;/p&gt;
&lt;h3 id=&quot;final-thoughts-1&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;Distributed systems power many of the applications we use every day. They enable organizations to scale beyond the limits of a single application and support levels of reliability that modern users have come to expect.&lt;/p&gt;
&lt;p&gt;At the same time, every service added to an architecture creates another relationship that must be understood, monitored, and maintained.&lt;/p&gt;
&lt;p&gt;That is the hidden cost of distributed systems.&lt;/p&gt;
&lt;p&gt;They give us the ability to scale, but they require us to manage a level of complexity that simply does not exist in smaller architectures.&lt;/p&gt;
&lt;p&gt;And that is why understanding distributed systems is not really about learning new technologies.&lt;/p&gt;
&lt;p&gt;It is about understanding the trade-offs that come with growth.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20293/the-hidden-cost-of-distributed-systems</guid>
<pubDate>Thu, 11 Jun 2026 12:12:07 +0000</pubDate>
</item>
<item>
<title>Why Your Tech Stack Doesn’t Matter</title>
<link>https://coderlegion.com/16905/why-your-tech-stack-doesnt-matter</link>
<description>&lt;h3 id=&quot;architecting-for-reliability-in-the-age-of-multi-agent-systems&quot;&gt;Architecting for Reliability in the Age of Multi-Agent Systems&lt;/h3&gt;
&lt;p&gt;We are currently over-indexing on &quot;Model Orchestration.&quot; &lt;/p&gt;
&lt;p&gt;Every week, a new library, a new vector database, or a new framework tops the GitHub trending charts. &lt;/p&gt;
&lt;p&gt;This week it might be &lt;a rel=&quot;nofollow&quot; href=&quot;https://info.langchain.com/&quot;&gt;LangGraph&lt;/a&gt;. The next &lt;a rel=&quot;nofollow&quot; href=&quot;https://crewai.com/&quot;&gt;CrewAI&lt;/a&gt;. Something else right behind it.&lt;/p&gt;
&lt;p&gt;Every week the same question shows up:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Which stack should I use to build a reliable multi-agent system?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It's the wrong question.&lt;/p&gt;
&lt;p&gt;Because I've yet to see a system fail because to the wrong framework, language, or database.&lt;/p&gt;
&lt;p&gt;I've seen them fail because they couldn't recover state, couldn't control context, and couldn't explain what they just did. &lt;/p&gt;
&lt;p&gt;There’s a persistent belief that the logo on the documentation is the secret sauce for a production-ready system.&lt;/p&gt;
&lt;p&gt;It isn’t. In fact, if you’re spending the majority of your time debating the stack, you’re missing the architectural patterns that actually determine whether your agents will succeed or hallucinate into oblivion.&lt;/p&gt;
&lt;h3 id=&quot;the-illusion-of-the-framework&quot;&gt;The Illusion of the Framework&lt;/h3&gt;
&lt;p&gt;A Multi-Agent System (MAS) is &lt;strong&gt;not&lt;/strong&gt; a library problem. It is a &lt;strong&gt;State Management&lt;/strong&gt; problem disguised as an AI problem. Whether you use a graph-based logic or a role-based queue, the fundamental challenges and failure modes remain identical:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;lost state&lt;/li&gt;
&lt;li&gt;bloated context&lt;/li&gt;
&lt;li&gt;untraceable decisions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The stack you choose is merely the syntax you use to solve universal engineering constraints.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;The Core Thesis:&lt;/strong&gt; Reliability in agentic workflows is derived from &lt;em&gt;patterns&lt;/em&gt;, not &lt;em&gt;packages&lt;/em&gt;. A secure, scalable system built in Python looks fundamentally the same as one built in Rust if the underlying system primitives are respected.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;the-three-constants-of-reliable-agents&quot;&gt;The Three Constants of Reliable Agents&lt;/h3&gt;
&lt;p&gt;Regardless of your tools, your architecture must solve for these three pillars to move from a &quot;cool demo&quot; to a production asset:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;State is Sovereign&lt;br&gt;
If an agentic loop fails at step 7 of 12, does your system restart from scratch? If so, your stack doesn't matter because your architecture is broken. A resilient system requires &lt;strong&gt;Deterministic Checkpointing&lt;/strong&gt;: &lt;ul&gt;
&lt;li&gt;Capture the full &lt;strong&gt;thread state&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Preserve &lt;strong&gt;intent&lt;/strong&gt;, not just data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resume&lt;/strong&gt; execution without replaying the entire workflow.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Without this, your system is just a loop with amnesia.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The Context Tax&lt;br&gt;
Context windows are not infinite. In reality, every token you give an agent is a tax on its reasoning. The &quot;how&quot; isn't about which LLM you use; it's about the &lt;strong&gt;Routing Layer&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Classify intent&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Expose&lt;/strong&gt; only relevant tools&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Minimize&lt;/strong&gt; context surface area&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Less context doesn’t limit the system—it sharpens it.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Governance as a First-Class Citizen&lt;br&gt;
An agent is a service principal. If it cannot be audited, revoked, or sandboxed at the identity level, it shouldn't have access to your data or exist in production. &lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A reliable system enforces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Least-Privilege Authorization&lt;/strong&gt;, ensuring agents operate within a cryptographic &quot;box&quot; regardless of whether they are running in a Docker container or a serverless function.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scoped&lt;/strong&gt; tool usage&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Traceable execution&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;example&quot;&gt;Example&lt;/h3&gt;
&lt;p&gt;Consider a simple multi-agent workflow:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.kenwalger.com/blog/wp-content/uploads/2026/05/mermaid-diagram-2026-05-06-153045.png&quot; alt=&quot;image&quot;&gt;&lt;/p&gt;
&lt;p&gt;If your system can't resume from that point with the same context and intent, you don't have a system.&lt;/p&gt;
&lt;p&gt;You have a demo.&lt;/p&gt;
&lt;p&gt;A reliable system looks different.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.kenwalger.com/blog/wp-content/uploads/2026/05/mermaid-diagram-2026-05-06-153110.png&quot; alt=&quot;image&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-framework-agnostic-checklist&quot;&gt;The Framework-Agnostic Checklist&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Pillar        &lt;/th&gt;&lt;th&gt;The Real Question&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Coordination  &lt;/td&gt;&lt;td&gt;How do agents hand off work without bloating context or losing intent?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Observability &lt;/td&gt;&lt;td&gt;Can we trace every decision back to inputs and reasoning steps?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Resilience    &lt;/td&gt;&lt;td&gt;What happens when a model fails mid-workflow? Can we resume without replaying?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Sovereignty   &lt;/td&gt;&lt;td&gt;Who owns the data and execution environment—us or the platform?&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;closing-thoughts&quot;&gt;Closing Thoughts&lt;/h3&gt;
&lt;p&gt;These are not new problems. They're just showing up in a new layer.&lt;/p&gt;
&lt;p&gt;Stop chasing the framework. A system built in Python and one built in Rust will fail in exactly the same ways if the architecture is wrong.&lt;/p&gt;
&lt;p&gt;The difference isn't the stack. It's whether you've designed for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;State&lt;/li&gt;
&lt;li&gt;Context&lt;/li&gt;
&lt;li&gt;Control&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The tools are interchangeable. The architecture is not.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;This is the foundation for the upcoming &lt;em&gt;Sovereign Synapse&lt;/em&gt; series—where we move from theory to a local-first system that treats memory, context, and ownership as first-class concerns.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16905/why-your-tech-stack-doesnt-matter</guid>
<pubDate>Wed, 06 May 2026 22:39:04 +0000</pubDate>
</item>
<item>
<title>PC-0003—Building Minimal Governance Layers</title>
<link>https://coderlegion.com/15710/pc-0003-building-minimal-governance-layers</link>
<description>&lt;h3 id=&quot;control-layer-deterministic-governance-in-structured-systems&quot;&gt;Control Layer: Deterministic Governance in Structured Systems&lt;/h3&gt;
&lt;hr&gt;
&lt;h3 id=&quot;executive-summary&quot;&gt;Executive Summary&lt;/h3&gt;
&lt;p&gt;PC-0003 defines the &lt;strong&gt;governance layer&lt;/strong&gt; as a formal, machine-verifiable boundary that determines which system states are admissible within a deterministic execution environment.&lt;/p&gt;
&lt;p&gt;Within the trilogy architecture:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/15526/pc-0001-the-architecture-of-deterministic-systems-execution-layer&quot;&gt;PC-0001&lt;/a&gt; (Execution)&lt;/strong&gt; ensures outputs are deterministic and reproducible&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/15590/pc-0002-the-architecture-of-causal-systems-data-layer&quot;&gt;PC-0002&lt;/a&gt; (Causality)&lt;/strong&gt; ensures system history is fully reconstructable&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PC-0003 (Governance)&lt;/strong&gt; ensures only structurally valid states are permitted to exist&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Together, these layers form a closed system of execution, history, and constraint enforcement.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A system is not defined by what it can produce, but by what it refuses to allow.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-governance-as-a-structural-boundary&quot;&gt;1. Governance as a Structural Boundary&lt;/h3&gt;
&lt;p&gt;Governance in PC-0003 is not policy, documentation, or interpretation.&lt;/p&gt;
&lt;p&gt;It is a &lt;strong&gt;runtime-adjacent constraint system&lt;/strong&gt; that enforces admissibility through explicit rules.&lt;/p&gt;
&lt;p&gt;It defines:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;valid inputs&lt;/li&gt;
&lt;li&gt;valid transformations&lt;/li&gt;
&lt;li&gt;valid resulting states&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This replaces subjective validation with deterministic evaluation over structured data.&lt;/p&gt;
&lt;p&gt;If a state violates governance constraints, it is not partially valid — it is &lt;strong&gt;excluded from the system entirely&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-deterministic-constraint-model&quot;&gt;2. Deterministic Constraint Model&lt;/h3&gt;
&lt;p&gt;PC-0003 operates on a constraint satisfaction model over a graph-based state space.&lt;/p&gt;
&lt;p&gt;Let:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;G&lt;/strong&gt; = system graph state&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;E&lt;/strong&gt; = event input&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;C&lt;/strong&gt; = constraint set&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Governance validity is defined as:&lt;/p&gt;
&lt;p&gt;V(G, E) = \bigwedge_{i=1}^{n} C_i(G, E)&lt;/p&gt;
&lt;p&gt;A state transition is only admissible if all constraints evaluate to true.&lt;/p&gt;
&lt;p&gt;Otherwise, the event is rejected and no mutation occurs.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-constraint-driven-system-integrity&quot;&gt;3. Constraint-Driven System Integrity&lt;/h3&gt;
&lt;p&gt;System integrity is enforced through three structural invariants:&lt;/p&gt;
&lt;h4 id=&quot;3-1-input-constraints&quot;&gt;3.1 Input Constraints&lt;/h4&gt;
&lt;p&gt;All events must conform to a strict schema:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;required identifiers&lt;/li&gt;
&lt;li&gt;typed payloads&lt;/li&gt;
&lt;li&gt;authenticated signatures&lt;/li&gt;
&lt;li&gt;timestamped provenance&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-2-transition-constraints&quot;&gt;3.2 Transition Constraints&lt;/h4&gt;
&lt;p&gt;State mutation is strictly append-only:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;no destructive edits&lt;/li&gt;
&lt;li&gt;no retroactive modification&lt;/li&gt;
&lt;li&gt;no implicit overwrites&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-3-structural-constraints&quot;&gt;3.3 Structural Constraints&lt;/h4&gt;
&lt;p&gt;The system graph must remain internally consistent:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;bounded core structure&lt;/li&gt;
&lt;li&gt;valid ontology relationships&lt;/li&gt;
&lt;li&gt;preserved causal ordering&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-graph-closure-model&quot;&gt;4. Graph Closure Model&lt;/h3&gt;
&lt;p&gt;PC-0003 operates over a dual-layer graph structure:&lt;/p&gt;
&lt;h4 id=&quot;core-graph&quot;&gt;Core Graph&lt;/h4&gt;
&lt;p&gt;A fixed structural kernel defining system ontology:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;immutable&lt;/li&gt;
&lt;li&gt;deterministic&lt;/li&gt;
&lt;li&gt;finite seed state&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;extension-graph&quot;&gt;Extension Graph&lt;/h4&gt;
&lt;p&gt;An append-only event-derived structure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;fully causal&lt;/li&gt;
&lt;li&gt;chronologically ordered&lt;/li&gt;
&lt;li&gt;replayable&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This yields:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;G_total = G_core ∪ G_extension&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This separation ensures stability without restricting evolution.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-cryptographic-authority-model&quot;&gt;5. Cryptographic Authority Model&lt;/h3&gt;
&lt;p&gt;Authority is not assumed — it is verified.&lt;/p&gt;
&lt;p&gt;All canonical events must be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;cryptographically signed&lt;/li&gt;
&lt;li&gt;validated against trusted keys&lt;/li&gt;
&lt;li&gt;rejected if unverifiable&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This ensures system state is both structurally valid and authentically produced.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-failure-model-and-system-safety&quot;&gt;6. Failure Model and System Safety&lt;/h3&gt;
&lt;p&gt;PC-0003 prevents system degradation by eliminating ambiguous states.&lt;/p&gt;
&lt;p&gt;Failure conditions include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;schema violations&lt;/li&gt;
&lt;li&gt;invalid transitions&lt;/li&gt;
&lt;li&gt;broken causal ordering&lt;/li&gt;
&lt;li&gt;unsigned or corrupted events&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In all cases:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;invalid events do not modify state — they are excluded entirely.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This guarantees deterministic system behavior under all inputs.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-trust-stratification&quot;&gt;7. Trust Stratification&lt;/h3&gt;
&lt;p&gt;PC-0003 defines a layered trust hierarchy:&lt;/p&gt;
&lt;h4 id=&quot;level-0-cryptographic-truth&quot;&gt;Level 0 — Cryptographic Truth&lt;/h4&gt;
&lt;p&gt;Immutable external anchor (archival-grade reference)&lt;/p&gt;
&lt;h4 id=&quot;level-1-canonical-repository-state&quot;&gt;Level 1 — Canonical Repository State&lt;/h4&gt;
&lt;p&gt;Versioned but traceable implementation state&lt;/p&gt;
&lt;h4 id=&quot;level-2-runtime-projection&quot;&gt;Level 2 — Runtime Projection&lt;/h4&gt;
&lt;p&gt;Live system view for execution and visualization&lt;/p&gt;
&lt;p&gt;Only Levels 0 and 1 define authoritative truth. Level 2 is derived.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-live-implementation-evidence-proof-layer-&quot;&gt;8. Live Implementation Evidence (Proof Layer)&lt;/h3&gt;
&lt;p&gt;The governance model is implemented and observable in live systems:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deterministic runtime and state projection layer:&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://library-catalog.streamlit.app/&quot;&gt;https://library-catalog.streamlit.app/&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Canonical source repository (ontology, runtime, and governance logic):&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/PeculiarLibrarian/the-peculiar-library/tree/main&quot;&gt;https://github.com/PeculiarLibrarian/the-peculiar-library/tree/main&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These systems demonstrate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;deterministic graph execution&lt;/li&gt;
&lt;li&gt;SHACL-based validation enforcement&lt;/li&gt;
&lt;li&gt;causal event reconstruction&lt;/li&gt;
&lt;li&gt;structured governance constraints (PC-0003 layer)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-ontological-registration-json-ld-&quot;&gt;9. Ontological Registration (JSON-LD)&lt;/h3&gt;
&lt;p&gt;PC-0003 is formally registered as a first-class node in the system ontology:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;@context&quot;: {
    &quot;pc&quot;: &quot;http://padi.system/pc#&quot;
  },
  &quot;@id&quot;: &quot;pc:PC0003&quot;,
  &quot;@type&quot;: &quot;pc:GovernanceLayer&quot;,
  &quot;pc:dependsOn&quot;: [
    &quot;pc:PC0001&quot;,
    &quot;pc:PC0002&quot;
  ],
  &quot;pc:role&quot;: &quot;governance_layer&quot;,
  &quot;pc:implementationProof&quot;: [
    &quot;https://library-catalog.streamlit.app/&quot;,
    &quot;https://github.com/PeculiarLibrarian/the-peculiar-library/tree/main&quot;
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;10-layered-system-architecture&quot;&gt;10. Layered System Architecture&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Layer   &lt;/th&gt;&lt;th&gt;Function   &lt;/th&gt;&lt;th&gt;Guarantee&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;PC-0001 &lt;/td&gt;&lt;td&gt;Execution  &lt;/td&gt;&lt;td&gt;Deterministic output&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;PC-0002 &lt;/td&gt;&lt;td&gt;Causality  &lt;/td&gt;&lt;td&gt;Full history reconstruction&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;PC-0003 &lt;/td&gt;&lt;td&gt;Governance &lt;/td&gt;&lt;td&gt;Structural admissibility&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each layer constrains the one above it, forming a closed deterministic stack.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;11-closing-principle&quot;&gt;11. Closing Principle&lt;/h3&gt;
&lt;p&gt;PC-0003 formalizes governance as a &lt;strong&gt;structural constraint system&lt;/strong&gt;, not an interpretive layer.&lt;/p&gt;
&lt;p&gt;It ensures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;invalid states cannot exist&lt;/li&gt;
&lt;li&gt;execution is deterministic&lt;/li&gt;
&lt;li&gt;history is reconstructable&lt;/li&gt;
&lt;li&gt;structure remains bounded and verifiable&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;A deterministic system is defined not by what it produces, but by the constraints that prevent invalid states from forming.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15710/pc-0003-building-minimal-governance-layers</guid>
<pubDate>Fri, 24 Apr 2026 07:46:44 +0000</pubDate>
</item>
<item>
<title>From AS/400s to AI Agent Swarms</title>
<link>https://coderlegion.com/13714/from-as-400s-to-ai-agent-swarms</link>
<description>&lt;p&gt;I've spent 16 years building software — starting on IBM WebSphere Commerce platforms for major retailers, eventually leading architecture at companies like Hertz, DaVita, Chick-fil-A, Wayfair, and Kohl's, and now running a fractional CTO practice out of Austin.&lt;/p&gt;
&lt;p&gt;I wanted to introduce myself to the CoderLegion community because I've been writing more this year, and the response has been eye-opening. My post about building a 35-agent AI coding swarm got more engagement in a week than anything I'd published in years. Turns out people want to read about what actually happens in production — not the sanitized conference-talk version, but the real decisions, trade-offs, and scars.&lt;/p&gt;
&lt;p&gt;So that's what I write about.&lt;/p&gt;
&lt;h3 id=&quot;what-i-ve-been-working-on&quot;&gt;What I've Been Working On&lt;/h3&gt;
&lt;p&gt;My most recent deep-dive was about the architecture behind a 6,000% throughput improvement at Hertz. The short version: their rate engine was doing 300 requests per second with p90 latency over a minute. We rebuilt it with CQRS, CDC-powered caching, and Kinesis streaming. The result was 3,000+ reads/sec at p95 under 30ms. The post walks through every architectural decision and why we made it — including the ones that didn't work the first time.&lt;/p&gt;
&lt;p&gt;Before that, I wrote about orchestrating a swarm of 35 AI coding agents that run overnight — spawning specialized agents for different tasks, coordinating them through a hierarchical topology, and waking up to pull requests that are actually useful. It's the kind of thing that sounds like science fiction until you see the git log.&lt;/p&gt;
&lt;h3 id=&quot;what-i-build-day-to-day&quot;&gt;What I Build Day to Day&lt;/h3&gt;
&lt;p&gt;Right now I split my time between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fractional CTO work&lt;/strong&gt; — leading platform rebuilds, defining architecture, running weekly executive checkins for clients who need senior technical leadership without a full-time hire&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enterprise commerce&lt;/strong&gt; — about a decade of IBM WebSphere Commerce, plus Magento, Shopify, and BigCommerce across major retailers and enterprise brands&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI/ML in production&lt;/strong&gt; — not just prototypes, but edge AI video pipelines feeding vision models on Raspberry Pis, agentic workflows that actually ship code, and real-time analysis at the edge&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Distributed systems&lt;/strong&gt; — the Hertz work, DaVita's 100+ microservices, Chick-fil-A's offline-first POS system across 3,000+ locations&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-i-ll-be-writing-about&quot;&gt;What I'll Be Writing About&lt;/h3&gt;
&lt;p&gt;I've got a backlog of posts in various stages of done:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&quot;Your Custom Cache Is a Bug Factory&quot;&lt;/strong&gt; — a follow-up to the Hertz piece about the caching anti-patterns I see in almost every codebase I audit&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&quot;Eventual Consistency Isn't Lazy — It's Architecture&quot;&lt;/strong&gt; — why most teams default to strong consistency out of fear, not necessity, and what it costs them&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&quot;Why You Probably Don't Need a Full-Time CTO&quot;&lt;/strong&gt; — the case for fractional technical leadership, from someone who does it&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you're interested in production architecture, real-world scaling problems, or what it actually looks like to lead engineering at companies that move millions of dollars through their software every day — I think you'll find something useful here.&lt;/p&gt;
&lt;p&gt;I'm Mathew. Good to meet you all.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Find me at &lt;a rel=&quot;nofollow&quot; href=&quot;https://mdostal.com&quot;&gt;mdostal.com&lt;/a&gt; or connect on &lt;a rel=&quot;nofollow&quot; href=&quot;https://linkedin.com/in/mdostal&quot;&gt;LinkedIn&lt;/a&gt;. If you're dealing with a scaling problem or making an architecture bet that could use a second opinion, I do &lt;a rel=&quot;nofollow&quot; href=&quot;https://cal.com/mdostal/meet&quot;&gt;free 30-minute calls&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13714/from-as-400s-to-ai-agent-swarms</guid>
<pubDate>Mon, 30 Mar 2026 20:54:15 +0000</pubDate>
</item>
<item>
<title>Reporting and Production Make Terrible Roommates</title>
<link>https://coderlegion.com/12783/reporting-and-production-make-terrible-roommates</link>
<description>&lt;p&gt;I tend to think of reporting and production as incompatible roommates. They need the same space, they optimize for completely different things, and every accommodation one makes for the other is a debt that gets called in later. The production schema is usually what accumulates the most debt and gets hit the hardest.&lt;/p&gt;
&lt;p&gt;Consider what happens when the analytics team asks for a denormalized &lt;code&gt;order_summary&lt;/code&gt; view on the production database so their dashboards load faster. The DBA obliges, adds a materialized view, and now every schema migration has to account for it. Six months later the application team wants to split the &lt;code&gt;orders&lt;/code&gt; table into &lt;code&gt;orders&lt;/code&gt; and &lt;code&gt;order_line_items&lt;/code&gt;. But the view is embedded in 10 dashboard queries and a nightly export job, and most of those queries also join it back to columns on &lt;code&gt;orders&lt;/code&gt; directly. Redefining the view over the new tables is the easy part. Rewriting and revalidating a dozen queries the application team doesn't own is not, so the refactor stalls, and the production schema fossilizes around a reporting concern.&lt;/p&gt;
&lt;h3 id=&quot;reporting-tends-to-win-every-schema-negotiation&quot;&gt;Reporting Tends to Win Every Schema Negotiation&lt;/h3&gt;
&lt;p&gt;The cause is structural. A transactional schema optimizes for write consistency, referential integrity, and the access patterns of the application that owns it. A reporting schema optimizes for read throughput, aggregation, and the access patterns of analysts and dashboards. When both share a schema, every design decision becomes a negotiation.&lt;/p&gt;
&lt;p&gt;Once dashboards reach executives or exports reach outside consumers, reporting tends to win that negotiation, because saying no gets more expensive with every dashboard that already depends on a yes. Its requests arrive one at a time as small additions (a column, an index, a view), each cheap to accept and expensive to remove. A refactor's payoff is deferred, while a broken dashboard is visible to leadership the same morning.&lt;/p&gt;
&lt;p&gt;Not every system needs a separation on day one. A small team with a single database, low reporting complexity, and a schema that's still fluid can query production directly without meaningful friction. But this fossilization is predictable. It emerges when reporting consumers multiply, when dashboards become load-bearing, and when schema changes require cross-team coordination.&lt;/p&gt;
&lt;p&gt;Architects who recognize this trajectory can keep the door open for separation without building the full pipeline prematurely. That means resisting the urge to denormalize production schemas for reporting convenience, and keeping reporting access patterns from becoming implicit contracts on the production schema. When the separation does happen, it can be reactive, tapping into what the database already captures, or intentional, making the application responsible for producing reporting-quality records in the write path.&lt;/p&gt;
&lt;h3 id=&quot;reactive-separation-reuses-what-the-database-already-captures&quot;&gt;Reactive Separation Reuses What the Database Already Captures&lt;/h3&gt;
&lt;h4 id=&quot;a-dedicated-reporting-replica-isolates-load-not-schema&quot;&gt;A Dedicated Reporting Replica Isolates Load, Not Schema&lt;/h4&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;/p&gt;
&lt;pre&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;p&gt;This is a feasible fit when reporting needs are straightforward and the production schema is close enough to what reporting consumers need. It also assumes data that's a few seconds stale is acceptable. &quot;A few seconds stale&quot; is the optimistic case, though. Heavy analytical queries on the replica can cause replication lag to spike well beyond that, especially during peak reporting windows.&lt;/p&gt;
&lt;p&gt;The isolation is partial, too. On a PostgreSQL standby, a long report can block the replica from applying changes. The replica then either cancels the report or, with &lt;code&gt;hot_standby_feedback&lt;/code&gt; on, stops the primary from cleaning up dead rows. Reporting indexes land on production too, because a replica can't hold indexes of its own.&lt;/p&gt;
&lt;p&gt;The replica breaks down when reporting needs diverge far enough from the production schema's shape. Reporting consumers write increasingly complex queries with multiple joins, or they start requesting schema changes to production to make their queries simpler, which is exactly the distortion this post is about.&lt;/p&gt;
&lt;p&gt;The replica can't capture intermediate transitions either. Reporting queries see whatever state the database holds when they run, so if a record changes twice between one query and the next, the intermediate state is invisible.&lt;/p&gt;
&lt;h4 id=&quot;change-data-capture-state-history-without-intent&quot;&gt;Change Data Capture: State History Without Intent&lt;/h4&gt;
&lt;p&gt;CDC tools like Debezium tap the database's transaction log and emit changes as events. The application writes normally to whatever schema makes sense, and CDC streams those changes to a separate store. Unlike the replica approach, CDC captures every intermediate state change because it reads from the transaction log rather than querying current state.&lt;/p&gt;
&lt;pre&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;p&gt;CDC's greatest strength is that it requires no application code changes and no new abstractions in the write path. That makes it often the only viable option for legacy systems where changing the write path is too risky, or for teams that need separation now and can't modify every service that writes data. It also tends to deliver complete payloads, because the log usually carries the full new row even when the application updated a single field.&lt;/p&gt;
&lt;p&gt;The first limitation is semantic. CDC events originate from the database layer, so they capture &lt;em&gt;what&lt;/em&gt; changed but not &lt;em&gt;why&lt;/em&gt; it changed. A row update that represents a customer canceling an order looks identical to a row update that represents a system correcting a data entry error.&lt;/p&gt;
&lt;p&gt;The obvious workaround is to add columns like &lt;code&gt;cancelled_by&lt;/code&gt; and &lt;code&gt;cancel_reason&lt;/code&gt;. They record the why only when the application itself needs them, because then they're domain state and CDC carries them. Adding them only for reporting is the distortion this post started with. And some intent has no natural row to live on at all, such as a correction or a change spanning several entities. For financial ledgers or audit-critical workflows, where intent is as important as state, the write path has to record the intent itself, which is what the outbox and event sourcing below do.&lt;/p&gt;
&lt;p&gt;The second limitation is the absence of a contract boundary. The table structure &lt;em&gt;is&lt;/em&gt; the contract, implicitly. When that schema changes, nothing fails at build time. The CDC pipeline either silently emits differently shaped events or breaks at runtime, and reporting consumers discover the problem in production rather than in development.&lt;/p&gt;
&lt;p&gt;Two mitigations narrow that gap without closing it. A transformation layer that maps raw tables onto a stable reporting model, such as warehouse staging models, shrinks the repair after a schema change to one place. But the reporting side owns that layer, so it learns of production changes only after they ship. A schema registry can partially close this gap by rejecting incompatible schema versions when the pipeline tries to register them, but that's added infrastructure catching incompatibility at runtime rather than at build time.&lt;/p&gt;
&lt;p&gt;The third limitation is database dependency. CDC is only as good as the log the database exposes. PostgreSQL's logical decoding, MySQL's binlog, and DynamoDB Streams are mature options. But a managed service that restricts log access, or a stream that retains changes only briefly (DynamoDB Streams keeps 24 hours), can push teams toward application-layer alternatives earlier than expected.&lt;/p&gt;
&lt;p&gt;Reading the log also loads the primary. Logical decoding adds write-ahead log volume to every write. A PostgreSQL replication slot also holds that log on the primary until the connector consumes it, so a stalled CDC pipeline can fill the production database's disk.&lt;/p&gt;
&lt;h3 id=&quot;intentional-separation-captures-intent-in-the-write-path&quot;&gt;Intentional Separation Captures Intent in the Write Path&lt;/h3&gt;
&lt;p&gt;Reactive approaches separate the workload but not the context. They capture &lt;em&gt;what&lt;/em&gt; changed, not &lt;em&gt;who&lt;/em&gt; changed it or &lt;em&gt;why&lt;/em&gt;. That context exists at the application layer when the write happens, and it's lost the moment the data hits the database unless someone deliberately captures it.&lt;/p&gt;
&lt;h4 id=&quot;the-outbox-pattern-makes-the-contract-explicit&quot;&gt;The Outbox Pattern Makes the Contract Explicit&lt;/h4&gt;
&lt;p&gt;The outbox pattern makes the application responsible for producing reporting-quality records. Instead of letting the database schema define the downstream contract implicitly, the application writes a versioned record to an outbox table within the same database transaction as the domain state change. Either both commit or neither does. A separate process reads from the outbox and projects into whatever reporting store analytics needs. That relay can crash after publishing and before marking a record published, so delivery is at least once, and consumers deduplicate on the event ID. The application controls the payload shape, the versioning, and the context included in each record.&lt;/p&gt;
&lt;pre&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;p&gt;&lt;strong&gt;Outbox table via a relational database:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE outbox (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    event_id        UUID          NOT NULL DEFAULT gen_random_uuid(),  -- globally unique, used downstream
    aggregate_type  VARCHAR(100)  NOT NULL,  -- e.g. 'Order', 'Customer'
    aggregate_id    VARCHAR(100)  NOT NULL,  -- e.g. order ID
    event_type      VARCHAR(100)  NOT NULL,  -- e.g. 'OrderCancelled'
    schema_version  INT           NOT NULL,  -- contract versioning
    occurred_at     TIMESTAMPTZ   NOT NULL DEFAULT now(),
    initiated_by    VARCHAR(200),            -- who: user ID, system name
    reason          VARCHAR(500),            -- why: 'customer_request', 'admin_override'
    payload         JSONB         NOT NULL,  -- full state snapshot + context
    published       BOOLEAN       NOT NULL DEFAULT FALSE
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Outbox record via a Kinesis/Kafka stream (JSON envelope):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;id&quot;: &quot;a1b2c3d4-e5f6-7890-abcd-ef1234567890&quot;,
  &quot;aggregateType&quot;: &quot;Order&quot;,
  &quot;aggregateId&quot;: &quot;ORD-20260218-4417&quot;,
  &quot;eventType&quot;: &quot;OrderCancelled&quot;,
  &quot;schemaVersion&quot;: 2,
  &quot;occurredAt&quot;: &quot;2026-02-18T14:32:08.771Z&quot;,
  &quot;initiatedBy&quot;: &quot;user:jsmith&quot;,
  &quot;reason&quot;: &quot;customer_request&quot;,
  &quot;payload&quot;: {
    &quot;orderId&quot;: &quot;ORD-20260218-4417&quot;,
    &quot;customerId&quot;: &quot;CUST-8821&quot;,
    &quot;previousStatus&quot;: &quot;Confirmed&quot;,
    &quot;newStatus&quot;: &quot;Cancelled&quot;,
    &quot;lineItems&quot;: 3,
    &quot;totalAmount&quot;: 284.50,
    &quot;currency&quot;: &quot;USD&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The outbox has an explicit, versionable contract boundary. A breaking change to the outbox record is a deliberate code change that goes through review. If a developer renames a column in the production schema, the outbox record doesn't change unless someone deliberately updates it. A contract test in CI fails the build when the record's shape changes, before any dashboard breaks. Unlike a test on CDC tables, it lives in code its owners already test. The outbox table itself is a reporting concern in the production database, but domain tables never read or join it. Changing a domain table changes only the code that maps it into the record.&lt;/p&gt;
&lt;p&gt;Because a polling relay doesn't rely on transaction log capabilities or vendor-specific change feed APIs, any database that supports transactions supports the pattern. A relay built on CDC, such as Debezium's outbox event router, inherits CDC's operational risks but not its implicit contract.&lt;/p&gt;
&lt;p&gt;The outbox does not require a record for every database write. It only fires when a specific entity type has a meaningful state change, and only for the types that reporting cares about. Background jobs updating internal timestamps produce nothing. A deletion does produce a record, because knowing that an entity was removed and who removed it is itself meaningful. This keeps the coupling concentrated in write paths that produce meaningful state transitions, not spread across every query and update in the codebase.&lt;/p&gt;
&lt;p&gt;That selectivity is also the outbox's main risk. A bulk admin script or hand-written SQL fix that changes an order's status without writing to the outbox leaves reporting silently incomplete. CDC can't miss a change that way, because it reads every committed change. Emitting the record from the domain model's state transition closes most of that gap, and out-of-band SQL against tracked tables has to write its own outbox record.&lt;/p&gt;
&lt;p&gt;The outbox is my recommendation for teams that have outgrown a read replica, need reporting to know who acted and why, and don't need full event sourcing.&lt;/p&gt;
&lt;h4 id=&quot;cqrs-and-event-sourcing-keep-every-transition-at-the-highest-cost&quot;&gt;CQRS and Event Sourcing Keep Every Transition at the Highest Cost&lt;/h4&gt;
&lt;p&gt;CQRS (Command Query Responsibility Segregation) formally separates the write model from the read model. The write side accepts commands and persists state. The read side maintains whatever views consumers need. The two sides don't have to share a schema, or even storage. What CQRS adds is the explicit acknowledgment that changing state and reading it are different jobs that can deserve different models. CQRS does not require event sourcing. Its write side can persist state normally in a traditional database.&lt;/p&gt;
&lt;p&gt;Event sourcing takes this further by changing what the write side stores. Instead of persisting current state and producing reporting records alongside it, every state mutation is recorded as an immutable event, and current state is derived by replaying those events. The event log becomes the source of truth, not the current snapshot. Every transition is preserved in the order it occurred. Production state is a projection of the event stream, and so is reporting state, and so is any other view you need. If the analytics team changes their requirements six months from now, you replay the same events through a new projection and the full history is there.&lt;/p&gt;
&lt;pre&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;p&gt;&lt;strong&gt;Event store document (append-only, NoSQL):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;streamId&quot;: &quot;Order-ORD-4417&quot;,
  &quot;position&quot;: 4,
  &quot;eventType&quot;: &quot;OrderCancelled&quot;,
  &quot;occurredAt&quot;: &quot;2026-02-18T14:32:07Z&quot;,
  &quot;payload&quot;: {
    &quot;initiatedBy&quot;: &quot;user:jsmith&quot;,
    &quot;reason&quot;: &quot;customer_request&quot;,
    &quot;previousStatus&quot;: &quot;Confirmed&quot;,
    &quot;newStatus&quot;: &quot;Cancelled&quot;,
    &quot;lineItems&quot;: 3,
    &quot;totalAmount&quot;: 284.50,
    &quot;currency&quot;: &quot;USD&quot;
  },
  &quot;metadata&quot;: {
    &quot;correlationId&quot;: &quot;req-88a1c&quot;,
    &quot;causationId&quot;: &quot;cmd-cancel-4417&quot;,
    &quot;userId&quot;: &quot;user:jsmith&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Projected state table (derived from events, used by reporting/ETL):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE order_projections (
    order_id         VARCHAR(100) PRIMARY KEY,
    customer_id      VARCHAR(100) NOT NULL,
    current_status   VARCHAR(50)  NOT NULL,
    item_count       INT          NOT NULL,
    total_amount     DECIMAL(12,2) NOT NULL,
    currency         VARCHAR(3)   NOT NULL,
    placed_at        TIMESTAMPTZ,
    shipped_at       TIMESTAMPTZ,
    cancelled_at     TIMESTAMPTZ,
    cancelled_by     VARCHAR(200),
    cancel_reason    VARCHAR(500),
    last_event_pos   INT          NOT NULL,  -- tracks replay position
    projected_at     TIMESTAMPTZ  NOT NULL DEFAULT now()
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The projected table above is what reporting reads in practice. Reporting consumers rarely subscribe to the event stream directly, which keeps the stream internal to the domain. A current-state projection like &lt;code&gt;order_projections&lt;/code&gt; keeps only the latest cancellation. When reporting needs history, the domain team that owns the projections builds a history-shaped one, such as a status-transition table.&lt;/p&gt;
&lt;p&gt;This is a good fit for domains where the complete history of state transitions is genuinely valuable, like financial ledgers, audit-critical workflows, or systems where &quot;undo&quot; and &quot;replay&quot; are first-class requirements.&lt;/p&gt;
&lt;p&gt;Most teams should not reach for this combination. Martin Fowler's CQRS article warns that most of the CQRS cases he has run into have not gone well. Many systems fit a CRUD mental model and should stay that way. CQRS should only apply to specific bounded contexts where the read and write access patterns are genuinely different, not across entire applications. Event sourcing compounds the cost. Events are immutable and permanent, so schema design requires careful thought. Aggregate replay gets expensive without snapshotting, and debugging production issues means reasoning about event sequences rather than inspecting current state.&lt;/p&gt;
&lt;h3 id=&quot;choosing-an-approach-by-what-reporting-needs-to-know&quot;&gt;Choosing an Approach by What Reporting Needs to Know&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Approach &lt;/th&gt;&lt;th&gt;Use when &lt;/th&gt;&lt;th&gt;Key limitation &lt;/th&gt;&lt;th&gt;Complexity&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Read replica &lt;/td&gt;&lt;td&gt;Schema shape is close to what reporting needs; slight staleness is acceptable &lt;/td&gt;&lt;td&gt;Replication lag under analytical load; no intermediate state history &lt;/td&gt;&lt;td&gt;Low&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;CDC (e.g. Debezium) &lt;/td&gt;&lt;td&gt;Write path can't be modified; full row history is needed &lt;/td&gt;&lt;td&gt;Captures state, not business intent; table structure is an implicit contract &lt;/td&gt;&lt;td&gt;Medium&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Outbox pattern &lt;/td&gt;&lt;td&gt;Business context (who/why) matters; write path can be modified &lt;/td&gt;&lt;td&gt;A write path that skips the outbox silently drops data; relay process and versioning discipline needed &lt;/td&gt;&lt;td&gt;Medium&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;CQRS + Event Sourcing &lt;/td&gt;&lt;td&gt;Audit-critical domain; &quot;undo&quot; and &quot;replay&quot; are first-class requirements &lt;/td&gt;&lt;td&gt;Events are permanent; aggregate replay is expensive without snapshotting &lt;/td&gt;&lt;td&gt;High&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;separate-early-or-pay-later&quot;&gt;Separate Early or Pay Later&lt;/h3&gt;
&lt;p&gt;Separating early means separating the access path before dashboards depend on base tables, not building a pipeline. The first request for a reporting-only column or view on production is the cue. Every shortcut that ties these workloads together makes the eventual separation harder. The analytics team and the application team end up negotiating every schema change, and the production schema accumulates the concerns of whoever complained most recently.&lt;/p&gt;
&lt;p&gt;A read replica is enough to start, but only if dashboards query plain views rather than base tables. Those views live in a reporting schema the application team owns and can redefine, with grants that reach nothing else. Migrations still update the views, but now the team running the migrations owns them. When those views get too slow or too costly to redefine, move past the replica, to CDC if reporting needs only what changed and to an outbox if it needs why.&lt;/p&gt;
&lt;p&gt;Whichever step comes next, reporting reads an owned surface rather than raw tables, whether that's views on the replica, a transformation layer on CDC, or an outbox record. That owned surface keeps the schema from freezing, and the separate store keeps reporting load off production.&lt;/p&gt;
&lt;p&gt;Separation doesn't remove the coupling, but it gives the coupling an owner. With an outbox, the application team owns the record and its versions, and the analytics team owns the projection built from it. New questions about who or why still reach the application team, but as a reviewed change to a versioned record, and older records won't carry the new context. A production schema change that leaves the record's version intact ships without asking the analytics team.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/12783/reporting-and-production-make-terrible-roommates</guid>
<pubDate>Wed, 11 Mar 2026 14:01:18 +0000</pubDate>
</item>
<item>
<title>Observability Is Authored, Not Installed</title>
<link>https://coderlegion.com/11786/observability-is-authored-not-installed</link>
<description>&lt;p&gt;I have been a part of a dev team where poor observability constantly brought us to a standstill. Not because the tooling was missing, but because the data it collected never carried meaningful context. Alerts fired constantly, so operations teams ignored them. Dashboards existed for every service, but none of them answered the questions that mattered during incidents. Investigations that should have taken minutes took hours. It got bad enough that slow diagnosis alone stretched incidents into significant SLA violations.&lt;/p&gt;
&lt;p&gt;We questioned the choice of platforms, dashboards, and alerting rules. Yet none of those could help, because no tool could report a distinction our code never recorded. The problem was upstream, since our code didn't know the difference between &quot;I handled this correctly&quot; and &quot;something is actually broken.&quot;&lt;/p&gt;
&lt;h3 id=&quot;the-classification-problem-handled-outcomes-logged-as-errors&quot;&gt;The Classification Problem: Handled Outcomes Logged as Errors&lt;/h3&gt;
&lt;p&gt;Consider a payment processing system. A customer's card gets declined for insufficient funds. The payment gateway returns a rejection, and the system logs it as an ERROR.&lt;/p&gt;
&lt;p&gt;But this is the system working correctly. Insufficient funds is a handled business case, not an exception. Because it's logged as an error, though, it shows up in error dashboards, triggers error-rate alerts, and adds to the ambient noise that operators learn to tune out.&lt;/p&gt;
&lt;p&gt;Over time, &quot;payment errors&quot; become background radiation. The team knows most of them are just declined cards, so they stop investigating. Then the gateway starts timing out, or a partner pushes a breaking change, and the actual problem gets buried. Nobody notices because &quot;payment errors are always high.&quot;&lt;/p&gt;
&lt;p&gt;The usual response is to blame the team for ignoring alerts. It is a discipline problem, yes, but the discipline that's missing is upstream, in the code that treats expected outcomes as errors.&lt;/p&gt;
&lt;p&gt;The fix belongs in that code, which should sort each outcome by what it means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Expected success&lt;/strong&gt;: The happy path. Logged at DEBUG if at all.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Expected failure&lt;/strong&gt;: Business logic correctly rejecting something, like declined payments, validation failures on input from outside callers, or rejecting a caller over its quota. This is INFO, not ERROR.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Degraded but functional&lt;/strong&gt;: The system recovered, but something is wearing thin. Retries running past their usual count, or a dependency throttling your calls. This is WARN. It isn't broken yet, but it needs attention before it becomes broken.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unexpected failure&lt;/strong&gt;: Something genuinely went wrong that demands investigation. This is the only category that should be ERROR. A call that fails past its retries belongs here even against a dependency known to be flaky, because no code path handled it. If that becomes routine, the fix is a handled path like a circuit breaker or fallback, which moves it to degraded.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Typed results make this distinction structural instead of incidental. When expected failures are returned as typed results rather than thrown as exceptions, the classification is baked into the code's structure. A declined payment returns a result; a gateway timeout that exhausts its retries throws an exception. A logging boundary still has to map each result type to a log level, but typed results put that decision in one visible, testable place instead of leaving it to guesswork at every call site.&lt;/p&gt;
&lt;p&gt;The log level is only one way to carry that decision. A structured outcome field or a counter works as well, as long as the code sets it. For conditions that only show in aggregate, like routine retry counts, response times approaching SLA thresholds, or connection pools running hot, a metric carries the decision better than a WARN line per request.&lt;/p&gt;
&lt;p&gt;When classification is right, every downstream tool benefits. Dashboards that track error rates become genuine health indicators because errors represent actual unexpected failures, not business logic working as designed. Log queries become surgical because structured errors with proper context let you filter to a specific tenant or operation in minutes. Once thresholds and routing are set well, alerts become actionable, because every error they count demands investigation.&lt;/p&gt;
&lt;p&gt;When classification is wrong, the opposite happens. Alerts fire for expected outcomes, so operators learn to ignore them. Dashboards become decoration because nobody trusts what the numbers represent. Every investigation becomes archaeology because the data that should answer your questions is buried under noise.&lt;/p&gt;
&lt;h4 id=&quot;only-the-code-can-make-the-call&quot;&gt;Only the Code Can Make the Call&lt;/h4&gt;
&lt;p&gt;The usual alternative is to classify downstream, but no downstream tool can classify outcomes the code didn't make distinguishable. Each option runs into that limit in its own way.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A collector rule&lt;/strong&gt; can change the level of decline lines in one central place. But it matches message text, so it breaks silently when the message changes, and it never sees lines that sampling already dropped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Filters by logger category or exception type&lt;/strong&gt; quiet framework and library noise without matching text. But they can't split a decline from a breakage inside the same operation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An SLO&lt;/strong&gt; holds the classification rule in one shared place. But it can only count a field the code emits.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Status codes look like a field the code emits, and auto-instrumentation can tell a 402 decline from a 504 timeout. But a status code carries the meaning only when the code puts it there. A decline wrapped in a 200 response body reaches the platform with the wrong label. A partner's breaking schema change gets the wrong label too, because it arrives as a 400 that looks just like a validation failure.&lt;/p&gt;
&lt;p&gt;Only the code knows the difference, and it knows because someone designed it to. A partner integration shows what that design looks like. If the code validates each request against its own rules before sending, any schema or contract 400 the partner returns afterward is unexpected by definition. Sometimes the 400 reveals a rule your validator missed rather than a partner change. ERROR is still right, because the gap is a bug in your code, and fixing it removes that noise. Rejections based on the partner's own state, like a closed account, are different. They're expected outcomes, so map the partner's error codes for them explicitly.&lt;/p&gt;
&lt;h4 id=&quot;business-judgment-belongs-in-counters-not-log-levels&quot;&gt;Business Judgment Belongs in Counters, Not Log Levels&lt;/h4&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 &quot;concerning&quot; is a business question that changes with strategy and context, so log levels shouldn't encode that judgment. Leave business interpretation to reports and dashboards where it can evolve, not to code where it gets baked in and forgotten.&lt;/p&gt;
&lt;p&gt;Declines still deserve watching, just not through the log level. Feed those reports from a decline counter rather than from INFO lines, because many teams sample INFO logs and a counter keeps every decline. The counter also catches breakage that looks like declines. A sudden spike in correctly handled declines can mean a bug sending wrong amounts or a card-testing attack, so the same counter can carry an anomaly alert routed to engineering, separate from the error rate.&lt;/p&gt;
&lt;h3 id=&quot;context-is-authored-not-accumulated&quot;&gt;Context Is Authored, Not Accumulated&lt;/h3&gt;
&lt;p&gt;The instinct is to compensate with volume: write verbose logs everywhere so you'll have context when you need it. But a trace-level log is not a dump file. Nearly every bug I've seen diagnosed from trace logs involved information the code already had at the point of failure and could have put in the error or warning itself. In those cases the problem wasn't insufficient logging volume; it was that nobody authored the context where it mattered.&lt;/p&gt;
&lt;p&gt;A stronger version of the volume argument comes from &lt;em&gt;Observability Engineering&lt;/em&gt;, by Charity Majors, Liz Fong-Jones, and George Miranda. It records one wide structured event per request with dozens of attributes, so you can ask questions afterward that nobody predicted. That approach handles unknowns far better than trace dumps, but it doesn't escape authoring. The event still needs an outcome field that says whether a decline was expected or broken, and domain fields like the tenant and the gateway's response code only exist if the code puts them there. Without them, a high-cardinality store is a faster way to search through the same unclassified noise.&lt;/p&gt;
&lt;p&gt;Failures should carry their own context. When an operation fails, the error log should include what was being attempted, what went wrong, and enough identifying information to correlate it. Consider a checkout failure. Instead of logging &quot;payment processing failed,&quot; a properly authored error captures the operation (&quot;charge_payment&quot;), the correlation ID, the gateway's response code, and the amount. That's structured enough to query and specific enough to reproduce the failure without re-running production traffic.&lt;/p&gt;
&lt;p&gt;What solves most domain-logic bugs is understanding what the user did and what they sent, not tracing the code's internal flow. Connecting what they did across services takes a correlation key, and carrying that key is a solved problem. The W3C Trace Context standard defines how it travels between services, and most structured logging libraries can stamp it on every entry. With that key and errors authored like the checkout one, you have what you need to reproduce the problem.&lt;/p&gt;
&lt;p&gt;Reproducing a failure from its input, rather than tracing it, borrows the idea behind event sourcing, where a state can be rebuilt by replaying the events that produced it. For deterministic domain logic, you don't need to trace every intermediate step if you can reconstruct the scenario from the input, the outcome, and the state the operation read.&lt;/p&gt;
&lt;p&gt;The state the operation read is the part the request can't supply. A race, or a record that changed after the failure, can't be replayed from the input alone. So the error should carry the versions of the records it loaded. A version only helps while the old record still exists. If the store overwrites records in place, carry the field values the decision used instead.&lt;/p&gt;
&lt;p&gt;Most of the context a failure needs can be chosen in advance, because the facts that decide an outcome are ones the code already handles. They are the operation, its input, the response from whatever it called, and the versions of the records it read. Environment facts like the build version, config, feature flags, and host belong there too, and the logging setup can attach those to every entry without per-call code. Some deciding facts won't be predictable. Those show up as incidents where someone had to add logging to find the cause.&lt;/p&gt;
&lt;p&gt;What gets logged must be intentional. You know the domain, so you know the potential inputs, what's valid, and what's sensitive. That knowledge lets you author a safe context, enough to reproduce the problem without exposing data that shouldn't be in a log. An allowlist of input fields, hashed identifiers, or a reference to a stored payload can carry the input safely. If you don't understand the domain well enough to make that distinction, that's the source of the problem, not the logging infrastructure.&lt;/p&gt;
&lt;p&gt;The difference between a useful error and a useless one is whether someone authored the context intentionally or hoped that raw volume would cover it.&lt;/p&gt;
&lt;h3 id=&quot;installed-tooling-reports-mechanics-not-meaning&quot;&gt;Installed Tooling Reports Mechanics, Not Meaning&lt;/h3&gt;
&lt;p&gt;Classification and context both come from the code, but some observability really is installed. A platform catches transport failures on its own, like a timeout on an outbound call, and span timings and profilers expose creeping latency without any per-call code. Trace logging, toggled on temporarily for one flow, still diagnoses ordering bugs where the sequence itself is the evidence.&lt;/p&gt;
&lt;p&gt;What they can't supply is meaning. They see that a call failed or slowed down, but not whether a rejection was the system working as designed, which fields an error needs, or whether an answer was wrong. Wrong output that never raises an error only surfaces in business-outcome metrics, and those are authored too, because someone has to decide which outcome to count. That's the sense in which observability is authored. The installed part tells you something failed, and only the authored part tells you whether it matters and why.&lt;/p&gt;
&lt;h3 id=&quot;the-black-box-test-diagnose-from-outputs-alone&quot;&gt;The Black Box Test: Diagnose From Outputs Alone&lt;/h3&gt;
&lt;p&gt;The word observability comes from control theory, where Rudolf Kálmán defined it in 1960. A system is observable when its internal state can be inferred from its external outputs. The practical test applies that definition. When something breaks, can you diagnose it from the system's outputs alone? Or do you need to add logging, redeploy, and wait for it to happen again? If the answer is the latter, your code doesn't explain itself yet.&lt;/p&gt;
&lt;p&gt;Software never passes completely, so run the test as a rate. For each recent incident, ask whether the first responder could identify the failing operation, its input, and the cause from existing telemetry. Score it from the timeline rather than memory. An incident fails if anyone added logging before the cause was found, whether by redeploy, a live logpoint, or a trace toggle. That holds even when toggling trace logging was the right move for an ordering bug. The failing share should fall over several quarters. With only a few incidents a quarter, read the trend over a year.&lt;/p&gt;
&lt;h4 id=&quot;attaching-a-debugger-hides-the-gaps&quot;&gt;Attaching a Debugger Hides the Gaps&lt;/h4&gt;
&lt;p&gt;Classification and context are design decisions, but many developers never test whether their logging actually answers the questions it needs to. One habit that contributes is the debugger. Stepping through code is the right tool for a logic bug in development, but the habit hurts when it becomes the only way surprises get explained. A developer who resolves every local surprise by stepping through code can get little practice diagnosing from outputs, so gaps in those outputs are easy to miss until production.&lt;/p&gt;
&lt;p&gt;Some organizations extend this habit into production with remote debugging, but that's a security liability. A debugger attached to a running container, or to any production process, can read its memory, including secrets and customer data. It can also change what it executes, so every open debug port is another way into production.&lt;/p&gt;
&lt;p&gt;Snapshot debuggers capture variables without pausing, so they're gentler, and redaction and audit can contain their exposure. But they still keep the habit of reaching into the process instead of fixing what it outputs. You should be observing system outputs, not reading a live process's memory.&lt;/p&gt;
&lt;p&gt;Production should be a black box. If your default instinct when something breaks in production is to attach to it rather than read the outputs, you'll rarely feel the pressure to make those outputs useful. The classification stays sloppy, the context stays thin, and the errors stay vague. Not because you don't know better, but because you've never needed better.&lt;/p&gt;
&lt;p&gt;Developers who diagnose from observable behavior, whether testing locally against containerized dependencies or against remote systems, tend to build the discipline. They feel the pain of vague errors and missing context firsthand, and they fix it at the source because they have no easier option.&lt;/p&gt;
&lt;h4 id=&quot;owners-who-get-paged-close-the-gaps&quot;&gt;Owners Who Get Paged Close the Gaps&lt;/h4&gt;
&lt;p&gt;That discipline holds best when builders own what they operate. You don't log payment declines as errors when you're the one who gets paged for &quot;high error rate on payment service.&quot; You don't dump verbose logs instead of authoring context when you're the one parsing them at 3 AM.&lt;/p&gt;
&lt;p&gt;Paging pressure only fixes code when it reaches the code. A paged team can just as easily raise thresholds or mute alerts. What can turn the pain into a source fix is bringing the authors into incident diagnosis and filing every misclassified outcome or context-free error found there as a bug against the owning team.&lt;/p&gt;
&lt;p&gt;Tracking open telemetry bugs per owning team, beside the black box test's failing share, gives each team a number that backlog triage can see. Filing against the owning team matters most when the author sits on another team, because that author would otherwise never feel what their classification and context choices cause downstream.&lt;/p&gt;
&lt;p&gt;Better tooling alone won't make your code explain itself. Running the black box test against it, and filing what it finds against the code that caused it, is how it starts to.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/11786/observability-is-authored-not-installed</guid>
<pubDate>Mon, 16 Feb 2026 19:01:55 +0000</pubDate>
</item>
<item>
<title>How Shared Libraries Become Shared Shackles</title>
<link>https://coderlegion.com/11002/how-shared-libraries-become-shared-shackles</link>
<description>&lt;p&gt;This is a highly opinionated take on shared libraries and the damage they do to team autonomy and development tempo. 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;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 &quot;extreme&quot; stance on the subject.&lt;/p&gt;
&lt;p&gt;This post focuses on distributed architectures, where the consequences are most severe.&lt;/p&gt;
&lt;h3 id=&quot;shared-libraries-violate-core-principles&quot;&gt;Shared Libraries Violate Core Principles&lt;/h3&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. The share-nothing principle makes this explicit. Services should be autonomous, independently deployable, and free from implementation coupling. DORA's research on loosely coupled teams links that independence to delivery performance, finding that teams who can change and deploy without depending on other teams deploy more often and recover faster.&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.&lt;/p&gt;
&lt;p&gt;Yet the pitch keeps coming: &quot;We have this code in five places. Let's consolidate it into a shared library. We'll save time, ensure consistency, and make everyone's life easier.&quot; It sounds reasonable, it really does, yet the decision only calculates the cost of duplication while ignoring the cost of sharing across teams, domains, and technical boundaries.&lt;/p&gt;
&lt;h3 id=&quot;the-costs-the-pitch-doesn-t-count&quot;&gt;The Costs the Pitch Doesn't Count&lt;/h3&gt;
&lt;h4 id=&quot;version-conflicts-and-upgrade-pain&quot;&gt;Version conflicts and upgrade pain&lt;/h4&gt;
&lt;p&gt;Five teams 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 &quot;one place to maintain&quot; becomes &quot;one place that blocks everyone.&quot;&lt;/p&gt;
&lt;h4 id=&quot;teams-blocked-waiting-for-changes&quot;&gt;Teams blocked waiting for changes&lt;/h4&gt;
&lt;p&gt;A team needs functionality the library doesn't have, and a wrapper in their own code can't supply it. 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. An afternoon's change in a team's own code can stretch into weeks of waiting.&lt;/p&gt;
&lt;h4 id=&quot;debugging-across-boundaries&quot;&gt;Debugging across boundaries&lt;/h4&gt;
&lt;p&gt;When something breaks, the investigation now spans your code and the library code. Your team doesn't own the library, yet it runs inside your service, so the investigation is still yours. The abstraction that was supposed to simplify your team's work has added a layer to dig through.&lt;/p&gt;
&lt;h4 id=&quot;bloat-or-fragmentation-pick-your-poison&quot;&gt;Bloat or fragmentation, pick your poison&lt;/h4&gt;
&lt;p&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 whenever they change.&lt;/p&gt;
&lt;h4 id=&quot;obscured-accountability&quot;&gt;Obscured accountability&lt;/h4&gt;
&lt;p&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. The library just adds a dependency you don't own and can't fix on your own schedule.&lt;/p&gt;
&lt;h4 id=&quot;polyglot-stacks-multiply-the-library&quot;&gt;Polyglot stacks multiply the library&lt;/h4&gt;
&lt;p&gt;The shared library pitch assumes a homogeneous technology landscape that many organizations don't have. If your organization has services in C#, Java, Python, and Go, do you keep four versions of every shared library in sync? In polyglot environments, the &quot;shared&quot; library becomes a second-class citizen in every language except the one the authoring team uses. Whatever consistency the library buys stops at the language boundary, so you need another way to stay consistent anyway.&lt;/p&gt;
&lt;h4 id=&quot;release-discipline-softens-these-costs-without-removing-them&quot;&gt;Release discipline softens these costs without removing them&lt;/h4&gt;
&lt;p&gt;The usual escapes each leave part of the cost in place:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pinning an old version&lt;/strong&gt; only delays the choice until a fix a team needs ships past a breaking change, and backporting that fix is the multiple-version maintenance again.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automated update tools&lt;/strong&gt; raise pull requests for breaking upgrades as well, but each consuming team still changes its own code to take one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Outside contributions&lt;/strong&gt; shorten the owners' queue, but the change still needs their review and a release.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Forking or patching locally&lt;/strong&gt; puts the team back to copying the code.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-cohesion-and-coupling-diagnosis&quot;&gt;The Cohesion and Coupling Diagnosis&lt;/h3&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 &quot;need&quot; the same code are 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, parse JSON, or validate email addresses, each a few lines of glue around the runtime. Copy the code. Move on. Each copy can evolve with its service.&lt;/p&gt;
&lt;h4 id=&quot;every-answer-has-a-price&quot;&gt;Every answer has a price&lt;/h4&gt;
&lt;p&gt;A service adds a network hop, an availability dependency, and an API to version, and new features still wait on its owner. In exchange, the owner changes the internals without any consumer moving, and only a breaking contract change asks callers to act.&lt;/p&gt;
&lt;p&gt;Copying has limits too. The copies can drift, and a copied bug gets fixed once per copy. For small code that rarely changes, that's still cheaper than the coordination. Copies also don't show up in a dependency scanner, so what you copy should be the glue around the runtime or a mature library, not a hand-rolled parser or validator.&lt;/p&gt;
&lt;p&gt;Some code can't be copied at all. A rule that must produce the same answer everywhere, like a tax calculation, would let two copies disagree in a way a user or the data could see. That's the cohesion case, and it needs one running implementation. A shared library deployed at different versions in different services disagrees just as two copies would.&lt;/p&gt;
&lt;h4 id=&quot;fixing-a-bug-once-is-a-boundary-signal&quot;&gt;Fixing a bug once is a boundary signal&lt;/h4&gt;
&lt;p&gt;The common rebuttal is &quot;but if there's a bug, I fix it once and it propagates everywhere.&quot; The answer depends on where the bug lives. Bugs in third-party libraries get fixed upstream on the vendor's cycle, and you take the fix by upgrading. Logging is another common candidate for sharing, but what a service logs and traces has no single fix to propagate. Each service authors its logs and telemetry out of its own domain knowledge, aligned to shared values about how behavior gets classified and what context gets captured.&lt;/p&gt;
&lt;p&gt;What remains is mostly business logic. 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. So the default is no, and the burden of proof sits with the library to show it will stay narrow and stable.&lt;/p&gt;
&lt;h3 id=&quot;don-t-reinvent-the-wheel-vs-don-t-share-internal-types&quot;&gt;Don't Reinvent the Wheel vs. Don't Share Internal Types&lt;/h3&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 &quot;standard&quot; 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 dividing line is stability, not who wrote the code. An internal library kept narrow, versioned, and slow to break behaves like an external one. Internal libraries that carry domain or team-specific abstractions tend not to stay that way, because those abstractions change whenever your domain does.&lt;/p&gt;
&lt;h3 id=&quot;sdks-are-different&quot;&gt;SDKs Are Different&lt;/h3&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.&lt;/p&gt;
&lt;p&gt;A shared library abstracts how you think internally, like your domain models, patterns, and &quot;standard way&quot; of doing things. 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: &quot;Here's how to use our thing.&quot;&lt;br&gt;
The shared library says: &quot;Here's how you should build your thing.&quot;&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;h3 id=&quot;your-runtime-already-solved-this&quot;&gt;Your Runtime Already Solved This&lt;/h3&gt;
&lt;p&gt;The shared library pitch often targets &quot;utility code&quot; 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;Your wrapper just adds coordination overhead on top of something that didn't need wrapping.&lt;/p&gt;
&lt;h3 id=&quot;the-principle-is-broader-than-distribution&quot;&gt;The Principle Is Broader Than Distribution&lt;/h3&gt;
&lt;p&gt;The same problem exists in a modular monolith wherever different teams own different domains. There, shared packages between domains still couple teams to the same change cycles. The blast radius is contained, though, because teams share a deployable and version conflicts surface as build errors.&lt;/p&gt;
&lt;p&gt;In a distributed system, a change that would have been a merge conflict becomes a multi-team coordination effort with blocked releases and stale dependencies. Topology changes the severity, not the coupling. If Domain A and Domain B in separate services share domain code, they're a distributed monolith with extra steps.&lt;/p&gt;
&lt;p&gt;Two practices look like exceptions to this coupling, but each accepts the cost rather than escaping it. The first is the monorepo that enforces a single version of every dependency, which Google's &lt;em&gt;Software Engineering at Google&lt;/em&gt; calls the One-Version Rule. Version skew can't happen, but a breaking change to the library has to update every consumer before it lands. The coordination moves to whoever changes the library. Google absorbs it with large-scale-change tooling most organizations lack.&lt;/p&gt;
&lt;p&gt;The second is domain-driven design's Shared Kernel. Eric Evans conditions it on neither team changing it without consulting the other, which accepts the coordination cost on purpose.&lt;/p&gt;
&lt;h3 id=&quot;the-api-client-library-obsession&quot;&gt;The API Client Library Obsession&lt;/h3&gt;
&lt;p&gt;The most common incarnation of shared library dysfunction is the API client package: a library of hand-written 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: &quot;We'll publish a client library so consumers don't have to write their own HTTP calls or define their own contracts.&quot; 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. The documentation is the contract, and a schema check in each consumer's CI catches drift from it. A machine-readable schema such as an OpenAPI document or a &lt;code&gt;.proto&lt;/code&gt; file is that documentation in another form. A stubs package generated from the schema, with no hand-written code, is too. The trouble starts when the package carries hand-written DTOs and runtime behavior, binding every consumer to the producer's model and policies.&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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Client libraries impose one team's operational policy on every consumer.&lt;/strong&gt; Teams building client libraries inevitably add caching strategies, retry policies, circuit breakers, and connection pooling configurations. Those decisions belong to the calling service, which is the only one that knows its own latency budget, its failure tolerance, and how much damage a stale read does in its domain. The producer knows none of that. A client library freezes those choices upstream, where changing them requires a coordinated release across every consumer.&lt;/p&gt;
&lt;p&gt;The producer still has a part to play, but on its own side of the boundary. It protects itself server-side with rate limits and publishes which operations are safe to retry, its backoff contract, and how its idempotency keys and pagination tokens work. The caller honors that contract through its own resilience library.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The absurdity becomes obvious with frontend consumers.&lt;/strong&gt; The iOS app doesn't need a backend team's Swift package to call its API. The iOS team reads the documentation, or generates a client from the published schema itself, and maps responses to whatever structures suit the application.&lt;/p&gt;
&lt;p&gt;This reflexive reach for client libraries has been conditioned by years of cargo-culting. Patterns that made sense in public cloud SDKs with complex auth flows got carried into internal services with straightforward REST endpoints, where they don't. When an internal API needs multi-step orchestration to call correctly, that logic belongs behind the API, where the producer controls it, not in a package every consumer has to upgrade.&lt;/p&gt;
&lt;h3 id=&quot;the-governance-theater-problem&quot;&gt;The Governance Theater Problem&lt;/h3&gt;
&lt;p&gt;Shared libraries often emerge from a governance impulse: &quot;Teams are doing things inconsistently. We need to standardize.&quot;&lt;/p&gt;
&lt;p&gt;The instinct isn't wrong, and consistency matters. But a mandated shared library is governance theater.&lt;/p&gt;
&lt;p&gt;If teams are building things inconsistently, ask why. Usually it's because they don't share the same understanding of what matters, what the tradeoffs are, and what &quot;good&quot; 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.&lt;/p&gt;
&lt;p&gt;Governance through values: &quot;Here's why we authenticate this way, here are the tradeoffs, here's what we're optimizing for. Align your implementation to these principles.&quot;&lt;/p&gt;
&lt;p&gt;Governance through code: &quot;Use this library or you're non-compliant.&quot;&lt;/p&gt;
&lt;p&gt;The first preserves autonomy. Teams understand the principles and can make good decisions in novel situations. The second creates coupling while providing the illusion of alignment. Teams can comply without understanding, and when they hit a situation the library doesn't cover, they have no principle to fall back on.&lt;/p&gt;
&lt;p&gt;Governing through values doesn't mean leaving them unenforced. Conformance tests, linters, and reviews against the stated principles keep alignment from decaying. They gate builds rather than ship inside services, so when a rule tightens, each team fixes its own code without waiting on another team's release, and the rule can start as a warning.&lt;/p&gt;
&lt;p&gt;An optional &quot;paved road&quot; library that teams can leave still fits, because the values come first and the library is one documented way to follow them. It turns into theater when using the library becomes the compliance check.&lt;/p&gt;
&lt;h3 id=&quot;the-exception-thin-stable-code-like-security-protocols&quot;&gt;The Exception: Thin, Stable Code Like Security Protocols&lt;/h3&gt;
&lt;p&gt;There's one domain where shared libraries routinely make sense: 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.&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.&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.&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;The four conditions earn the exception, not the fact that it's security. They are the burden of proof the default puts on any shared library. In-process code that meets them earns it too, such as redacting personal data before logs leave a service, or a thin package that configures OpenTelemetry identically everywhere without deciding what a service logs.&lt;/p&gt;
&lt;p&gt;Uniformity alone doesn't earn the exception. Code that must behave identically everywhere but fails the conditions belongs in an external library or a service instead. Trace-context propagation must match across every service, yet it needs no internal library, because OpenTelemetry's libraries already implement the W3C Trace Context standard.&lt;/p&gt;
&lt;p&gt;Even here, mature external libraries or a service mesh should do the protocol work, and the internal library should be the thinnest layer that applies your organization's policy. Thinness keeps its forced upgrades rare. When a vulnerability does force one, uniform and immediate rollout is the point. The moment it starts accumulating &quot;helpful&quot; utilities beyond its core purpose, it's sliding toward the problems that plague other shared libraries.&lt;/p&gt;
&lt;h3 id=&quot;what-to-do-instead&quot;&gt;What to Do Instead&lt;/h3&gt;
&lt;p&gt;When you feel the urge to create a shared library, pause and diagnose the problem:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If it's a capability multiple services need:&lt;/strong&gt; Build a service and expose an API, unless the logic is stable enough to version like an external library.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If the services need the same logic because they share a domain:&lt;/strong&gt; Redraw the boundary. The shared code is a symptom of a split in the wrong place.&lt;/p&gt;
&lt;/li&gt;
&lt;li&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;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If it's truly just duplicated code:&lt;/strong&gt; Let it be duplicated. For small code that rarely changes, the coordination cost of sharing exceeds the maintenance cost of duplication.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If it's a security primitive, or code that meets the same four conditions:&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;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The shared library is a solution to a problem that rarely exists in the form people imagine. Duplicating small, stable code isn't what slows teams down. Coordination overhead is.&lt;/p&gt;
&lt;p&gt;Share values, and the shared library more often becomes unnecessary.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/11002/how-shared-libraries-become-shared-shackles</guid>
<pubDate>Tue, 03 Feb 2026 16:35:47 +0000</pubDate>
</item>
<item>
<title>Package Updates Are Investments, Not Hygiene Tasks</title>
<link>https://coderlegion.com/9600/package-updates-are-investments-not-hygiene-tasks</link>
<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, many teams fall back on a reflex: always update immediately to &quot;stay current,&quot; ignore updates entirely until forced, or let a bot merge whatever passes CI.&lt;/p&gt;
&lt;p&gt;All three 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. An investment decision isn't always a big one. For most updates it is a few minutes of triage asking whether this update earns its place now and what it could break, and only major versions and hot-path dependencies deserve a full business case.&lt;/p&gt;
&lt;h3 id=&quot;the-distributed-systems-uniformity-trap&quot;&gt;The Distributed Systems Uniformity Trap&lt;/h3&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. If Service A runs a library on v2.1 and Service B runs it on v2.3 behind their own contracts, a failure in either traces back to that service. Its own logs and stack traces place it there, even when the symptoms spread to its callers.&lt;/p&gt;
&lt;p&gt;Spread does carry a cost when a CVE lands, because each version in the fleet may need its own remediation path. The answer is a floor, not a single version. Every service, shared platform tool, and internal template stays above a supported baseline, which bounds the CVE cost without making services move in lockstep.&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, the floor rises to the fixed version and every service has to move above it, so attackers can't exploit the weakest link&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-cutting libraries&lt;/strong&gt;: Tracing and context propagation, correlation-ID middleware, token validation, and retry policies act across service boundaries, so drift there breaks traces or changes retry behavior between callers&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. Knowing 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;h3 id=&quot;match-scrutiny-to-the-kind-of-update&quot;&gt;Match Scrutiny to the Kind of Update&lt;/h3&gt;
&lt;p&gt;Before updating any dependency, evaluate the change type and context. The Semantic Versioning specification 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; carry security fixes and bug patches, and most should go through with your normal test run. A patch to a hot-path dependency still gets that dependency's load test or canary, because a patch-level bump can change runtime behavior.&lt;/p&gt;
&lt;p&gt;A security fix that itself carries regression risk, such as a fix to a hot-path dependency or a release with fresh regression reports, is the one patch you might hold. Even then, the default is to patch. Hold it only when a reachability tool or a search shows the affected API isn't referenced directly or transitively, so the vulnerable code never runs. Then a few days' wait while the release proves itself may carry less risk than updating.&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 how long the release has been out and whether issues are being filed against that version. A release under a week old, or one with fresh regression reports, deserves skepticism. Let early adopters find the edge cases first, unless the release fixes an actively exploited vulnerability your code can reach, in which case the exposure outweighs the soak time.&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)? A routine patch needs no special reason, but if a minor or major update has no clear problem to solve, question it.&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;: Scope tests to what the update touches (see the testing section below), and 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 not every update needs every step. Canaries and staged rollouts are for hot-path dependencies and major versions. But it does at least encourage deliberate thinking and decisions instead of reflexive action.&lt;/p&gt;
&lt;h3 id=&quot;delay-has-its-own-costs&quot;&gt;Delay Has Its Own Costs&lt;/h3&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, and 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;&quot;No clear problem&quot; is a reason to wait, not a reason to stay put forever. Hold a regular review, quarterly or semi-annually. At each one, treat the gap itself as the problem once any of these holds:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The version you run is nearing the end of vendor support.&lt;/li&gt;
&lt;li&gt;The gap is large enough that the next security fix would force a multi-version jump.&lt;/li&gt;
&lt;li&gt;The dependency is blocking another upgrade you need.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Routine patches shouldn't wait for that review. They flow through as they arrive, in small batches that stay easy to attribute. The review catches whatever was held back, so the gap never grows large enough to need a migration project.&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. Chip away at it like technical debt rather than attempting a big-bang migration.&lt;/p&gt;
&lt;h3 id=&quot;test-based-on-what-changed&quot;&gt;Test Based on What Changed&lt;/h3&gt;
&lt;p&gt;Fear pushes some teams toward retesting everything by hand, and convenience pushes others toward merging whatever is green. The first wastes time and the second misses the risks a dependency change actually carries.&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. For a dependency on every path, such as an HTTP client, that is the whole automated suite, which is cheap to run. The waste is in hand-testing features the change never touches&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Load tests&lt;/strong&gt;: When the dependency sits on a hot path or manages connections, threads, or credentials, replicate production traffic patterns against the specific features that changed, then 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;Functional tests with serial requests can pass cleanly while hiding race conditions, deadlocks, or resource exhaustion that only manifest under production concurrency.&lt;/p&gt;
&lt;h3 id=&quot;trusted-vendors-still-ship-breaking-changes-the-aws-sdk-for-net-v4&quot;&gt;Trusted Vendors Still Ship Breaking Changes: The AWS SDK for .NET V4&lt;/h3&gt;
&lt;p&gt;Trusting a vendor doesn't make its updates safe.&lt;/p&gt;
&lt;p&gt;In April 2025, AWS released version 4 of the AWS SDK for .NET with a long list of breaking changes, documented in its V4 migration guide. Some of them compile cleanly and fail only at runtime. Collection properties on request and response objects now default to null instead of an empty collection, so a loop over a response list that worked in V3 can throw a NullReferenceException in V4. A team that treated AWS as a trusted source and updated without reading that guide would meet these errors only at runtime, and only on the code paths its tests happened to exercise.&lt;/p&gt;
&lt;p&gt;The harder change to catch wasn't a breaking API. In October 2025, the core package moved from 4.0.0.31 to 4.0.0.32. One team then reported in the SDK's GitHub issue 4053 that the upgrade would &quot;grind our service to a halt&quot; under concurrent load, with no exceptions in the logs. The reporter suspected locking around credential retrieval that let only one request at a time fetch signing credentials. (The issue uses the assembly numbering, 4.0.31.0 and 4.0.32.0.) The 4.0.0.32 changelog did say credential refresh during the expiry window was being reverted, but not that refreshes would now block callers.&lt;/p&gt;
&lt;p&gt;The maintainer's reply explains that the change was deliberate: AWS had reverted background credential refresh because other users were getting expired credentials back, which is worse. So the same update fixed a correctness bug for some teams and collapsed throughput for others. A team hitting expired credentials had a clear reason to take it, and a team without that problem had none. Either way, a service with this change looks healthy in development and early testing, where requests arrive one at a time, and stalls under production traffic.&lt;/p&gt;
&lt;div class=&quot;callout callout--warning&quot;&gt;
&lt;p class=&quot;callout__title&quot;&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;/div&gt;
&lt;h3 id=&quot;common-objections&quot;&gt;Common Objections&lt;/h3&gt;
&lt;h4 id=&quot;triage-is-cheap-and-it-moves-the-time-to-office-hours&quot;&gt;Triage Is Cheap, and It Moves the Time to Office Hours&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;&quot;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.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For most patch and minor updates, the framework comes down to reading a changelog, scanning the issue tracker, and running the tests that cover the changed paths. Load tests and staged rollouts are for the few dependencies on the hot path, not every update. Reading the V4 migration guide in the afternoon beats discovering a null collection in production at 2 AM. None of this argues for updating rarely. Small, frequent updates are easier to attribute when something breaks, and the framework works at that cadence. What it rules out is a change reaching production without anyone deciding it should.&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. Even if the hours came out equal, one approach happens during office hours with full context, while the other happens during outages with incomplete information. Reviewed updates still fail, as the AWS example above shows, but they fail with someone already watching the metrics they chose.&lt;/p&gt;
&lt;h4 id=&quot;blanket-patch-deadlines-treat-every-cve-as-critical&quot;&gt;Blanket Patch Deadlines Treat Every CVE as Critical&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;&quot;Our security team requires us to apply all patches within 48 hours of release. We don't have a choice.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Security policies that mandate blanket timelines without risk assessment can 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). Security teams tend to be more open to adjusting a policy when the request comes with risk-based reasoning rather than a complaint about the deadline. Many already tier deadlines by severity, or by whether a CVE appears in CISA's Known Exploited Vulnerabilities catalog. A tiered policy is this framework applied to exposure. The framework adds the other side, the patch's own regression risk, which decides how much validation fits inside the deadline.&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;h4 id=&quot;automation-can-t-make-the-value-call&quot;&gt;Automation Can't Make the Value Call&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;&quot;Automated tooling already handles this for us.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Automation can find updates and flag CVEs, but it can't decide whether an update makes sense for your context.&lt;/p&gt;
&lt;p&gt;Most security scanners tell you a vulnerability exists in a package you depend on. Even the ones that add reachability analysis can't tell you whether the risk outweighs the cost of the update for your system.&lt;/p&gt;
&lt;p&gt;The strongest version of this objection is a gated pipeline. Renovate's &lt;code&gt;minimumReleaseAge&lt;/code&gt; setting holds a new version back until it has been public for a set time, CI runs the suite, and a canary watches production metrics before the rollout widens. That pipeline encodes several steps of the framework above, and for low-risk dependencies it can be the whole process. What it can't encode is the value judgment. It will happily open the major-version PR that needs a business case, and it only catches the regressions its gates measure.&lt;/p&gt;
&lt;p&gt;The AWS SDK throughput regression arrived in a fourth-segment version bump, exactly the kind of update auto-merge rules tend to wave through, and a suite of serial functional tests passes it cleanly. Only a load test or a canary that measures throughput would have stopped it. A team that had tiered the SDK as hot-path would read that changelog line as a reason to load-test, and a team that hadn't would scroll past it.&lt;/p&gt;
&lt;p&gt;A person has to decide that a credential library on every request deserves a load test. That decision is made once, when you tier the dependency, and every later update to it inherits the gate. Most of the judgment this post asks for lives in that tiering and in the value call on majors. Within the low-risk tier, the gates can carry routine updates with no human minutes at all.&lt;/p&gt;
&lt;p&gt;But for hot-path dependencies, auto-merging without those gates is worse than no automation. It trades a known risk (the version you run and its published issues) for an unknown one (bugs you didn't test for, shipped without anyone deciding to ship them). For hot-path dependencies and major versions, automation should notify, not decide.&lt;/p&gt;
&lt;h4 id=&quot;concentrate-review-where-the-risk-is&quot;&gt;Concentrate Review Where the Risk Is&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;&quot;We have too many dependencies to evaluate each one individually. We'd spend all day reviewing changelogs.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Not all dependencies deserve equal attention. Apply the Pareto principle as a rule of thumb rather than a measured ratio. A small share of your dependencies carries most of your risk, such as authentication libraries, database drivers, core frameworks, and HTTP clients. They sit on every request, so a bug in one reaches all of your traffic. Focus your evaluation effort there.&lt;/p&gt;
&lt;p&gt;Risk also comes from exposure, so a markdown parser that renders user input belongs in the first group even if it runs on one page.&lt;/p&gt;
&lt;p&gt;For dependencies that are neither on the hot path nor handling untrusted input (date formatting helpers, color palette utilities, test fixture builders), batch review them on a short, regular cadence. Check for breaking changes and security issues in aggregate, test once across the batch, and apply together. A batch makes a regression harder to attribute, but bisecting a batch of formatting utilities is cheap, which is why hot-path dependencies stay out of it.&lt;/p&gt;
&lt;p&gt;One risk ignores these tiers. A compromised release can do damage from any dependency, because it runs with your build's or service's full permissions. A release-age hold like Renovate's is cheap enough to apply to every tier, though it only catches a compromise someone discovers before the hold ends. The backdoor planted in xz-utils in 2024 sat in public releases for about a month before anyone noticed.&lt;/p&gt;
&lt;h4 id=&quot;fast-teams-stay-fast-by-avoiding-self-inflicted-incidents&quot;&gt;Fast Teams Stay Fast by Avoiding Self-Inflicted Incidents&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;&quot;Our competitors ship faster because they don't overthink updates like this.&quot;&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. The review this post asks of most updates is minutes of triage, so the comparison turns on incidents, not review time. Shipping fast for a quarter is easy, and staying fast for years means avoiding the context-switching of constant firefighting. Every unforced error from an unreviewed update is time spent recovering from a self-inflicted wound instead of shipping.&lt;/p&gt;
&lt;h3 id=&quot;leadership-sets-the-tone&quot;&gt;Leadership Sets the Tone&lt;/h3&gt;
&lt;p&gt;Team leads determine how their teams approach updates. If leadership treats updates as chores to rush through, teams tend to cut corners. If leadership asks hard questions, prioritizes based on value, and accepts that &quot;not yet&quot; is sometimes the right answer, teams tend to follow their example.&lt;/p&gt;
&lt;p&gt;Shipping fast and thinking deliberately aren't opposites. Triage on most updates takes minutes, and the incidents it prevents arrive at the worst possible time, as mysterious production issues traced back to an unconsidered dependency change two sprints ago.&lt;/p&gt;
&lt;blockquote class=&quot;pull-quote&quot;&gt;
&lt;p&gt;Package updates are investment decisions, not hygiene tasks. Give each one the scrutiny its risk deserves, and decide on purpose.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/9600/package-updates-are-investments-not-hygiene-tasks</guid>
<pubDate>Mon, 12 Jan 2026 15:11:13 +0000</pubDate>
</item>
<item>
<title>Adaptability Over Cleverness: What Makes Code Actually Good</title>
<link>https://coderlegion.com/9276/adaptability-over-cleverness-what-makes-code-actually-good</link>
<description>&lt;p&gt;Systems that stay cheap to change for years aren't the ones built to fit today's requirements perfectly. 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.&lt;/p&gt;
&lt;p&gt;You will rarely 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 can 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. Adaptable code is code where a likely change touches few places, can be made safely by someone other than its author, and shows quickly when it breaks something. Readability and simplicity matter for the same reason, since a change starts with someone understanding the code.&lt;/p&gt;
&lt;p&gt;Correctness and speed aren't the only requirements code has to meet. Security, operability, and run cost are requirements too. Adaptability decides how much it costs to keep meeting all of them as the system changes, and that cost includes reading the code to diagnose an incident or pass an audit.&lt;/p&gt;
&lt;p&gt;The exception is code that will never change or be read again, like a one-off migration script. That code only has to be correct.&lt;/p&gt;
&lt;h3 id=&quot;cleverness-and-speed-make-change-expensive&quot;&gt;Cleverness and Speed Make Change Expensive&lt;/h3&gt;
&lt;p&gt;Brian Kernighan and P. J. Plauger's &lt;em&gt;The Elements of Programming Style&lt;/em&gt; put this in two maxims: &quot;Write clearly – don't be too clever&quot; and &quot;Write clearly – don't sacrifice clarity for efficiency.&quot; Both describe the same cost. Clever code packs several decisions into one construct, like one cached value that quietly serves both a pricing rule and a fraud check. To change one of those policies, the next developer has to unpack both and work out which behavior the author intended and which is incidental.&lt;/p&gt;
&lt;p&gt;Optimized code carries the same cost in a different form. An optimization usually rests on an assumption about today's workload, such as the data fitting in memory, records arriving sorted, or one query pattern dominating. The code around it comes to depend on that assumption too, so when the workload changes, the change has to reach every place that relied on it. Donald Knuth's 1974 paper &quot;Structured Programming with go to Statements&quot; argued that programmers' attempts at efficiency in noncritical code &quot;have a strong negative impact when debugging and maintenance are considered.&quot; It's the same paper that calls premature optimization the root of all evil.&lt;/p&gt;
&lt;p&gt;None of this argues against performance. When speed is a requirement, measure where it's needed and put the optimization behind a boundary, so the rest of the system doesn't depend on how it works. The fast path then becomes one more thing that can change in one place. Some optimizations can't hide, because they shape the contract itself, like a bulk API in place of per-item calls, a streaming response in place of a full list, or a denormalized table every report reads. Those are decided deliberately up front.&lt;/p&gt;
&lt;h3 id=&quot;adaptability-comes-from-keeping-change-local&quot;&gt;Adaptability Comes From Keeping Change Local&lt;/h3&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;h4 id=&quot;boundaries-keep-each-change-in-one-place&quot;&gt;Boundaries Keep Each Change in One Place&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Single Responsibility Principle&lt;/strong&gt; is about change, not tasks. Robert C. Martin states it as &quot;Each software module should have one and only one reason to change.&quot; Whether that module is a microservice owning a bounded context or a class handling a single concern, a shifted requirement lands in the piece responsible for it without cascading edits across the system. At service scale, a wrongly drawn boundary turns into a contract change between teams. That's why service boundaries deserve more care up front than class boundaries do.&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 placed where implementations are likely to change let you pivot 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 adjust the system's behavior without a code change.&lt;/p&gt;
&lt;p&gt;Externalize only what is likely to vary, because each setting is flexibility with a carrying cost. It adds combinations to test and can drift between environments, so it needs the same likely-to-change test. A bad value can also take a system down, so validate configuration at startup like any other input.&lt;/p&gt;
&lt;h4 id=&quot;fast-failures-and-tests-make-breakage-visible&quot;&gt;Fast Failures and Tests Make Breakage Visible&lt;/h4&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 without guessing what you broke. Unit tests verify component behavior. Integration tests catch interface mismatches. End-to-end tests confirm critical workflows still work. Tests help most when they check behavior through a component's public boundary, because a test coupled to internal details becomes one more place every change has to touch.&lt;/p&gt;
&lt;h4 id=&quot;consistency-shows-where-a-change-belongs&quot;&gt;Consistency Shows Where a Change Belongs&lt;/h4&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. Features share the same folder layout and the same error-handling convention. When the structure is predictable, a developer can tell where a change belongs and what else it touches before opening a file, which is what lets someone other than the original author make it safely.&lt;/p&gt;
&lt;p&gt;Trace one requirement through these habits. Suppose refunds above a threshold now need a manager's approval. If one module owns refunds, the rule lands there and nowhere else. If the same approval rule also governs discounts and credits, it changes for reasons refunds don't share. By the same one-reason-to-change test, it belongs in an approvals module that refunds call. A developer who didn't write that module finds it because every feature follows the same layout, and a test that issues a refund through the module's public interface fails if the approval check is missing.&lt;/p&gt;
&lt;p&gt;If refund logic were instead copied into three controllers, the same rule means three edits, and a fourth copy nobody remembered keeps approving refunds without a word.&lt;/p&gt;
&lt;h3 id=&quot;flexibility-belongs-where-change-is-likely&quot;&gt;Flexibility Belongs Where Change Is Likely&lt;/h3&gt;
&lt;p&gt;The goal isn't maximum flexibility; it's appropriate flexibility. Rarely getting the design right the first time doesn't rule out appropriate flexibility. You can't predict what a requirement will become, but you can often predict where requirements will move. David Parnas made this the criterion for drawing module boundaries in his 1972 paper &quot;On the Criteria To Be Used in Decomposing Systems into Modules.&quot; The designer starts from the design decisions that are difficult or likely to change and gives each module one of them to hide from the others.&lt;/p&gt;
&lt;h4 id=&quot;domain-knowledge-and-change-history-locate-the-seams&quot;&gt;Domain Knowledge and Change History Locate the Seams&lt;/h4&gt;
&lt;p&gt;Domain knowledge tells you where those decisions are, and version control history checks the guess. Files with high commit churn show where change has actually landed so far. Churn comes in two patterns that mean different things. A file that changes for many different tickets marks a place where requirements move, so an interface belongs there. A class and its tests changing together is expected, but files in separate modules that always change together for the same reason mark the opposite problem, a boundary drawn in the wrong place. Before the first release there is no history, and domain knowledge alone decides.&lt;/p&gt;
&lt;p&gt;A payments system will need to support new payment providers, so the provider belongs behind an interface from the first release. Shape that interface around the domain's operations, like authorize, capture, and refund, rather than around the first vendor's API. That domain-shaped interface is a seam. It will still change when the second provider arrives with redirects, asynchronous confirmations, or a single charge step. But callers already depend on payment operations the domain owns rather than on one vendor's types, so the reshape is one contract change instead of a hunt for vendor types across the codebase.&lt;/p&gt;
&lt;p&gt;Domain knowledge also shows where change is unlikely. An internal admin tool probably won't need a plugin architecture.&lt;/p&gt;
&lt;p&gt;Decisions that other systems or stored data depend on follow a different rule. A published API contract, a persisted data format, or an event schema can't be fixed with a refactor later, because changing it means migrating data and coordinating every consumer. These deserve the same up-front care as the payment provider even without a change history. Service boundaries and the optimizations that shape a contract, like the bulk API earlier, belong in this group.&lt;/p&gt;
&lt;h4 id=&quot;contain-a-dependency-until-its-shape-is-clear&quot;&gt;Contain a Dependency Until Its Shape Is Clear&lt;/h4&gt;
&lt;p&gt;Inside the codebase, a seam isn't the only option. When change is likely but its shape isn't clear, or when change arrives somewhere you kept simple, contain the dependency and add the abstraction later. Tests and consistent structure, the habits from the first half of this post, let you add the boundary when the second case actually shows up. That costs one refactor, instead of years maintaining an abstraction that nothing used.&lt;/p&gt;
&lt;p&gt;That price stays low only if the concrete dependency is used from one place. Containing it takes no abstraction layer, just a rule that every call goes through one module or wrapper you own, and that callers never see vendor types or vendor error codes. Make the rule mechanical, such as keeping the vendor package a dependency of that one project, so a hurried direct call fails the build.&lt;/p&gt;
&lt;p&gt;The wrapper differs from a seam in what it promises, not in whether it uses an interface. The wrapper offers only the operations today's callers use. A seam commits to the operations the domain needs from every implementation, and that commitment is the part that goes wrong when you guess. Either way, the later change replaces one file rather than forty call sites.&lt;/p&gt;
&lt;p&gt;The tiebreaker follows from this. Add the seam up front when change is likely and you know the operations it must support, the way authorize, capture, and refund are known for payments even when each provider's mechanics differ. When the shape isn't clear yet, contain the dependency and wait for the second case to show it. Before adding a layer of flexibility, check:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Has this part of the system changed before, or does the domain make change routine, the way new providers are routine for payments?&lt;/li&gt;
&lt;li&gt;If you wait, what would the change cost? When tests cover the code and the change would stay inside one module, adding the boundary later is cheap.&lt;/li&gt;
&lt;li&gt;Who pays for the abstraction in the meantime? Every developer who reads the code pays for each layer of indirection, whether or not the change ever comes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Perfect code written for yesterday's requirements fails when reality shifts. Over-engineered code taxes every change with layers nobody needed. Adaptable code finds the balance: flexible where change is likely, simple where it isn't.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/9276/adaptability-over-cleverness-what-makes-code-actually-good</guid>
<pubDate>Mon, 05 Jan 2026 15:54:14 +0000</pubDate>
</item>
<item>
<title>Why Configuration Files Don't Belong With Your Code</title>
<link>https://coderlegion.com/8526/why-configuration-files-dont-belong-with-your-code</link>
<description>&lt;p&gt;When you first create that new shiny code project, your configuration requirements seem so obvious and straightforward. You think: &quot;I have a single behavior or feature that just needs this one setting or I am just calling this one external resource which just needs this one API URL&quot;. Often, your needs are indeed simple, but simple does not translate directly to easy. And even before the future flood of settings and complex use cases arrive, that setting beside the code is already a problem. The local approach feels intuitive because config stays close to the code that uses it, but real business use cases, daily team dynamics, production deployment pipelines, and distributed architectures are just not that simple.&lt;/p&gt;
&lt;p&gt;This localized intuition leads to predictable problems: secrets leak into Git history and persist indefinitely, environment-specific values multiply into templating systems that nobody fully understands, and configuration changes require full application redeployments. These aren't edge cases or signs of poor discipline. They follow from where the config lives. A secret in a file beside the code is one mistaken commit away from Git history, and a value shipped with the code changes only when the code ships.&lt;/p&gt;
&lt;h3 id=&quot;the-hidden-costs-of-config-in-code&quot;&gt;The Hidden Costs of Config-in-Code&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Secrets leak despite good intentions.&lt;/strong&gt; Teams start with &lt;code&gt;.gitignore&lt;/code&gt; files and environment variables, but secrets find their way into version control anyway. A developer copies a working config to test something, a merge conflict gets resolved wrong, or someone commits from a branch that predates the &lt;code&gt;.gitignore&lt;/code&gt; update. GitGuardian's State of Secrets Sprawl 2025 report counted 23.8 million secrets leaked in public GitHub commits in 2024, a count that covers hardcoded source as well as config files. Once a secret hits Git history, every clone and fork carries it. GitHub's own guidance on removing sensitive data says to revoke or rotate the secret first, because rewriting history can't reach copies already pulled. Rotating means tracking down every service that uses it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Environment parity erodes.&lt;/strong&gt; Each environment needs different database hosts, API endpoints, feature flags, and connection pool sizes. Teams respond with templating systems that substitute values at build time, or multiple config files with naming conventions like &lt;code&gt;config.dev.json&lt;/code&gt; and &lt;code&gt;config.prod.json&lt;/code&gt;, or environment variables that multiply until nobody remembers what &lt;code&gt;SERVICE_ENDPOINT_OVERRIDE_V2&lt;/code&gt; was supposed to do. When values are substituted at build time, staging and production don't even run the same image. Debugging then requires understanding not just the code but the entire config transformation pipeline.&lt;/p&gt;
&lt;p&gt;Runtime selection, like ASP.NET Core's &lt;code&gt;appsettings.{Environment}.json&lt;/code&gt; with environment-variable overrides, fixes the image problem but not the tie to releases. It keeps one image, but production values still ship in every build and change only with a release. With several override layers, the running value depends on which layer applied last.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deployments couple to configuration.&lt;/strong&gt; When config lives with code, changing a timeout value means rebuilding and redeploying the application. When five services share a database connection string, each holds its own copy in its own repository. Changing it means five pull requests and five deployments, ordered so no service still uses the old credential when it's revoked.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Configuration fragments across components instead of cohering around domains.&lt;/strong&gt; In a multi-tenant system, a tenant's feature flags, rate limits, and credentials end up copied into every service that handles its requests, so one change for that tenant means editing each copy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Config drift becomes invisible.&lt;/strong&gt; A value gets changed manually in production to fix an urgent issue and never makes it back to staging, or a developer updates the dev config but not the template. Over time, debugging requires archaeology rather than analysis.&lt;/p&gt;
&lt;h3 id=&quot;homegrown-config-stores-recreate-the-bootstrap-problem&quot;&gt;Homegrown Config Stores Recreate the Bootstrap Problem&lt;/h3&gt;
&lt;p&gt;The natural engineering instinct is to solve this problem yourself by storing configs in a database or building a config API.&lt;/p&gt;
&lt;p&gt;Config kept in the application's own database creates a circular dependency: your service needs the database connection string to start, but the connection string is in the database. You end up hardcoding bootstrap credentials anyway, which defeats the purpose.&lt;/p&gt;
&lt;p&gt;A custom config API sounds cleaner until you realize the config service itself needs configuration, deployment sequencing, and high availability. If the config API validates values against schemas in its own code, a new payment setting means deploying the config API first, then the payment service. You've traded one problem for a more complex one.&lt;/p&gt;
&lt;p&gt;The fundamental issue is bootstrapping: how does the application know where to find its configuration before it has any configuration? Cloud-native config stores solve this by using identity that's already present in the runtime environment, such as IAM roles in AWS, managed identities in Azure, or service accounts in GCP. The application authenticates using credentials the platform provides automatically, which means no bootstrap secrets to manage. A homegrown store can authenticate the same way, but at that point you're rebuilding, operating, and keeping highly available what the managed store already provides.&lt;/p&gt;
&lt;h3 id=&quot;what-external-config-stores-provide&quot;&gt;What External Config Stores Provide&lt;/h3&gt;
&lt;p&gt;AWS has Parameter Store and Secrets Manager, Azure has Key Vault and App Configuration, GCP has Secret Manager, and HashiCorp Vault works across all of them.&lt;/p&gt;
&lt;p&gt;Sensitive data never touches the codebase because it never needs to. Credentials live in the config store, encrypted at rest, with access controlled through the same IAM policies that govern your other cloud resources. When someone leaves the team, you revoke their access in one place rather than hoping they don't have local copies of config files. Audit trails show who accessed what and when, which proves invaluable when security asks &quot;who has seen the production database password in the last 90 days?&quot;&lt;/p&gt;
&lt;p&gt;Applications become portable because the same artifact runs everywhere. The application asks &quot;what environment am I in?&quot; at startup and fetches the appropriate config, eliminating templating systems and environment-specific builds.&lt;/p&gt;
&lt;h3 id=&quot;organize-config-around-domains-not-components&quot;&gt;Organize Config Around Domains, Not Components&lt;/h3&gt;
&lt;p&gt;When configuration lives with code, it naturally organizes around components: the payment API has its config, the notification service has its config, and the reporting batch job has its config. This feels logical until you realize that configuration often cuts across components. A domain like &quot;payments&quot; might span three APIs, two background processors, and a gateway configuration.&lt;/p&gt;
&lt;p&gt;With 50 APIs and 100 tenants, changing one tenant's settings starts with working out which services handle that tenant. When the payment domain gets restructured into separate authorization and settlement services, all the tenant configs need redistribution.&lt;/p&gt;
&lt;p&gt;Domain-oriented config inverts this relationship. The same tenant configuration applies to whichever services handle that tenant's requests. When the payment domain splits, the new authorization and settlement services both read the same &lt;code&gt;/production/tenants/&amp;lt;tenant&amp;gt;/payments&lt;/code&gt; path, so no tenant config moves and only their read permissions change.&lt;/p&gt;
&lt;p&gt;The tradeoff is that shared domain keys become a contract across their consumers, so like a shared schema they evolve additively. Before changing a key, check the IAM read grants on its path, which list those consumers. Sharing a domain doesn't mean sharing every secret, though, because credentials can still sit in per-consumer sub-paths.&lt;/p&gt;
&lt;p&gt;Component-specific configuration still exists, but only for component-specific concerns: performance tuning, networking behavior, resource limits, internal timeouts. Domain config answers &quot;what does this tenant need?&quot; while component config answers &quot;how does this service behave?&quot;&lt;/p&gt;
&lt;h3 id=&quot;hierarchical-config-in-practice&quot;&gt;Hierarchical Config in Practice&lt;/h3&gt;
&lt;p&gt;External config stores enable domain-oriented organization through hierarchy. AWS Parameter Store shows the pattern, and other platforms work similarly.&lt;/p&gt;
&lt;p&gt;Parameters follow a path structure like &lt;code&gt;/production/tenants/acme-corp/feature-flags&lt;/code&gt; or &lt;code&gt;/production/domains/payments/integration-credentials&lt;/code&gt;. The hierarchy carries access and ownership. Developers can read &lt;code&gt;/dev/*&lt;/code&gt; while &lt;code&gt;/production/*&lt;/code&gt; stays restricted to deployment pipelines. A change to &lt;code&gt;/production/shared/*&lt;/code&gt; reaches every service in an environment on its next refresh or restart. Domain teams own their domain's subtree while platform teams manage cross-cutting infrastructure config.&lt;/p&gt;
&lt;p&gt;The cost is small in practice. According to AWS Systems Manager's pricing page, standard-tier Parameter Store parameters (up to 10,000 per account and region) carry no storage charge and no API charge at default throughput. Secrets Manager costs more, at $0.40 per secret per month plus API calls, so a few hundred secrets run about $100 a month.&lt;/p&gt;
&lt;p&gt;Integration with compute services removes friction. ECS task definitions can reference parameters directly, and the platform injects the values at startup. Lambda functions can read them through AWS's Parameters and Secrets Lambda Extension. Kubernetes pods can receive them as native secrets through the External Secrets Operator.&lt;/p&gt;
&lt;h3 id=&quot;config-changes-get-heavier-and-they-should&quot;&gt;Config Changes Get Heavier, and They Should&lt;/h3&gt;
&lt;p&gt;A distributed config store removes the friction that adds nothing, like rebuilding artifacts and redeploying every consumer of a shared value. It keeps the friction that protects production. Configuration updates feel heavier because they are heavier, more like database migrations than editing a text file. You'll write change requests, get approvals, coordinate timing with deployments, and verify rollback procedures work.&lt;/p&gt;
&lt;p&gt;Configuration changes in production systems &lt;em&gt;should&lt;/em&gt; be intentional and controlled because a misconfigured timeout can cascade into an outage and a wrong feature flag can expose unfinished functionality to customers. Google's SRE book reports that roughly 70% of outages are due to changes in a live system, and a config change is exactly that kind of change. The ceremony forces you to think about backwards compatibility (will existing instances handle this new format?), rollback procedures (how do we revert if this breaks something?), and dependency ordering (which services need to restart first?).&lt;/p&gt;
&lt;p&gt;Backwards compatibility matters more than it did, because config and code now roll back independently. A new key has to be additive, and the code has to tolerate its absence. It's the same expand-then-contract discipline a schema migration uses.&lt;/p&gt;
&lt;p&gt;Approval alone isn't enough, because a change to &lt;code&gt;/production/shared/*&lt;/code&gt; reaches every service. Changes with that blast radius should roll out in stages the way code does. AWS AppConfig, for example, first checks a change with validators against a JSON schema or a Lambda function. Its deployment strategies then release the change to a growing percentage of targets over a bake time and roll it back automatically when a CloudWatch alarm fires.&lt;/p&gt;
&lt;p&gt;The one exception is the kill switch, because an incident can't wait for a change request. One can live under its own path, writable by on-call through a narrow IAM role.&lt;/p&gt;
&lt;h3 id=&quot;what-still-belongs-in-local-config&quot;&gt;What Still Belongs in Local Config&lt;/h3&gt;
&lt;p&gt;The goal isn't to externalize &lt;em&gt;everything&lt;/em&gt; since that would create unnecessary dependencies and slow down development.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Application defaults&lt;/strong&gt; like log formats, default retry counts, or internal timeout values that don't change between environments MIGHT belong in code. These define how the application behaves, not how it connects to external systems. I would still hesitate before coupling these to your code, however, because a constant default can become the value someone must change during an incident.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Framework configuration&lt;/strong&gt; for HTTP server settings, thread pool sizes, or middleware ordering rarely varies by environment. When it does, you can override specific values from the external store while keeping sensible defaults local.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Development shortcuts&lt;/strong&gt; that let engineers run the application locally without network dependencies make sense for rapid iteration. The key is ensuring the code path to fetch external config exists and works.&lt;/p&gt;
&lt;p&gt;The decision rule is straightforward:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Externalize it&lt;/strong&gt; if it's sensitive, varies by environment, or is shared across services.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep it local&lt;/strong&gt; if it describes internal application behavior that stays constant everywhere.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This rule sits close to the line The Twelve-Factor App draws, where config is &quot;everything that is likely to vary between deploys&quot; and internal config that doesn't vary &quot;is best done in the code.&quot; The difference is where the values come from. Environment variables can stay the interface, as when ECS injects parameters into them. But a flat set of variables managed per deployment has no hierarchy to assign ownership by path and no version history, so a versioned store makes the better source.&lt;/p&gt;
&lt;p&gt;For prototypes and throwaway code, do whatever gets you moving fastest, but establish the external config discipline before real users or sensitive data enter the picture.&lt;/p&gt;
&lt;h3 id=&quot;where-a-store-beats-a-config-repository&quot;&gt;Where a Store Beats a Config Repository&lt;/h3&gt;
&lt;p&gt;An external store versions the values themselves, per environment, and its audit trail records who changed each one.&lt;/p&gt;
&lt;p&gt;A separate config repository is the other way to keep config out of the code, and it versions config too. A GitOps pipeline injects it at deploy time as mounted files or a ConfigMap. That also keeps one image, and it adds reviewed diffs and atomic multi-key commits. For non-secret values that one service owns and changes only on release, it is a sound home. The store wins where the repository can't follow:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Secrets&lt;/strong&gt;, where even encrypted-in-repo tools like SOPS leave old ciphertext in history for anyone who once held the key, while the store audits every read and rotates without a commit&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shared values&lt;/strong&gt;, where one write replaces a config deploy to every consumer&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Incident-time changes&lt;/strong&gt; like a kill switch, which can't wait for a deploy&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-path permissions&lt;/strong&gt;, where a repository exposes everything in it to anyone who can read it&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Versioning in the store also changes rollback. Parameter Store versions each parameter separately, so you roll back one value at a time. When several values must revert together, AWS AppConfig versions them as one configuration profile. Bad deploy? Roll back the config without touching application code. Services that poll for changes pick up the old value within their refresh interval, and services that read config at startup need a restart, not a rebuild and redeploy.&lt;/p&gt;
&lt;p&gt;A writable store moves drift risk rather than removing it. The drift described earlier becomes detectable, because parallel paths like &lt;code&gt;/staging/...&lt;/code&gt; and &lt;code&gt;/production/...&lt;/code&gt; can be compared for missing or extra keys. It becomes preventable once write access to &lt;code&gt;/production/*&lt;/code&gt; is limited to the deployment pipeline. Where a path must stay writable, like the kill switch, drift becomes attributable, because every write lands in the audit trail with the caller's identity.&lt;/p&gt;
&lt;h3 id=&quot;preventing-configs-from-creeping-back-into-code&quot;&gt;Preventing Configs from Creeping Back into Code&lt;/h3&gt;
&lt;p&gt;Moving to external config stores doesn't automatically stop developers from committing secrets. Without active governance, you'll find credentials in code reviews months after &quot;completing&quot; the migration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Code review culture&lt;/strong&gt; matters more than tooling once the store exists. With no config file left for a secret to slip into, what remains takes judgment. Team values over process! Scanners catch values that look like secrets, while a reviewer catches the hardcoded endpoint or tenant flag no pattern recognizes. After the migration the rule is simple: any credential or environment-specific value in the repository is wrong. Make it part of your definition of done: &quot;configuration fetched from external store, no hardcoded environment-specific values.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pre-commit hooks&lt;/strong&gt; can run secret scanners that check each commit for patterns that look like secrets, including API keys, connection strings, and private keys. Teams should be able to govern themselves, but for high risk products this might be needed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Repository templates&lt;/strong&gt; ensure new projects start with proper &lt;code&gt;.gitignore&lt;/code&gt; files excluding &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;config.local.*&lt;/code&gt;, and &lt;code&gt;secrets.*&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CI/CD validation&lt;/strong&gt; catches anything that slips past local checks. Pipelines can scan for hardcoded values, verify that config references resolve to external stores, and fail builds that contain suspicious patterns. GitHub's push protection goes further, rejecting a recognized secret before it reaches the remote.&lt;/p&gt;
&lt;h3 id=&quot;practical-concerns-and-how-to-handle-them&quot;&gt;Practical Concerns and How to Handle Them&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&quot;What happens when the config store is unavailable?&quot;&lt;/strong&gt; A managed config store is still a dependency, and the more likely problem is throttling, not an outage. AWS documents Parameter Store's default throughput as 40 reads per second shared across an account and region, so a fleet scaling out or an ECS deployment replacing many tasks at once can hit &lt;code&gt;ThrottlingException&lt;/code&gt; at startup. Cache config after the first fetch, spread reads across startup, enable the paid higher-throughput setting when the fleet needs it, and serve stale cached values during brief outages.&lt;/p&gt;
&lt;p&gt;A new instance has no cache, though, so a throttle or outage during scale-out blocks exactly the instances you need. A local fallback copy could cover that gap, but a copy holding secrets must be encrypted, and getting its key to the instance is the bootstrap problem again. So most services should accept the startup dependency and size throughput for scale-out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;Startup is slower now.&quot;&lt;/strong&gt; True. Each fetch is a network round trip, usually small next to connection pool and dependency injection setup. Fetch at startup and cache with a refresh interval rather than forever, so a rotated credential still reaches running services.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;Local development gets harder.&quot;&lt;/strong&gt; It gets &lt;em&gt;different&lt;/em&gt;, not harder. SDKs and credential chains make fetching config from external stores transparent once each developer (and CI) has a scoped cloud role. The default is reading from a dev path, with each developer's writes confined to a personal sub-path. Local overrides are the shortcut for offline or isolated work. The one-time setup pays for itself in fewer &quot;works locally, fails in production&quot; bugs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;We can't work offline.&quot;&lt;/strong&gt; How often do you actually develop offline? Pulling dependencies, accessing Jira, talking to teammates, running CI/CD, and pushing code all require connectivity. When it does matter, keep a local-only setup that needs no store, such as a .NET Aspire app host that defines local resources and configuration in source control.&lt;/p&gt;
&lt;h3 id=&quot;the-daily-experience-changes&quot;&gt;The Daily Experience Changes&lt;/h3&gt;
&lt;p&gt;Testing becomes more realistic because engineers can run local code against the non-secret values in remote staging or dev configs, with read access scoped to those paths, rather than maintaining local approximations that drift from reality.&lt;/p&gt;
&lt;p&gt;Committed config files stop being a path for leaked credentials, and when developers never get read access to &lt;code&gt;/production&lt;/code&gt;, production secrets stop passing through people at all. When you need to rotate a credential, you update one place. With a dual-credential scheme like Secrets Manager's alternating-users rotation, the old credential stays valid while every service picks up the new one on its next fetch or restart. The question &quot;did we rotate that key in all environments?&quot; finally has a definitive answer.&lt;/p&gt;
&lt;h3 id=&quot;making-the-shift&quot;&gt;Making the Shift&lt;/h3&gt;
&lt;p&gt;Local config files feel simple and convenient right up until they aren't. External config stores trade familiar convenience for operational discipline, and that trade-off is sound.&lt;/p&gt;
&lt;p&gt;Start with secrets by moving credentials out of the codebase and into a config store. Once that discipline is established, expand to environment-specific values, and eventually the pattern becomes natural: config comes from the environment, not from files.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/8526/why-configuration-files-dont-belong-with-your-code</guid>
<pubDate>Tue, 16 Dec 2025 17:25:52 +0000</pubDate>
</item>
<item>
<title>Auth Sessions Should Never Cross Internal Boundaries</title>
<link>https://coderlegion.com/8087/auth-sessions-should-never-cross-internal-boundaries</link>
<description>&lt;p&gt;It is tempting to treat user authentication sessions as ambient context. You pass them through API layers, into service calls, across event processors, and through background jobs. If a component needs to know who the user is, it needs the user's session, right?&lt;/p&gt;
&lt;blockquote class=&quot;pull-quote&quot;&gt;
&lt;p&gt;This conflates authentication with context. You're treating an external trust artifact as internal state.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The result is internal services coupled to the external auth mechanism, a replayable credential spread across them, and systems that break when work runs after the session ends or without one.&lt;/p&gt;
&lt;h3 id=&quot;sessions-validate-at-boundaries-context-flows-internally&quot;&gt;Sessions Validate at Boundaries, Context Flows Internally&lt;/h3&gt;
&lt;h4 id=&quot;what-crosses-the-boundary&quot;&gt;What Crosses the Boundary&lt;/h4&gt;
&lt;p&gt;&quot;Session&quot; here means whatever the client presents to prove who the user is, whether that's a cookie bound to a server-side session or a bearer access token such as a JWT. The arguments below apply to both, and where they differ, the post says which one it means.&lt;/p&gt;
&lt;p&gt;Authentication sessions serve one purpose: validating identity at a security boundary. Once validated, the session should be consumed and replaced with explicit context and service credentials.&lt;/p&gt;
&lt;p&gt;A user session arrives at the boundary: an API endpoint, gateway, or edge function. The boundary validates the session and confirms identity. The session is then consumed. You extract only the necessary context: user_id, tenant_id, correlation_id, a non-secret session id, and, when a client acts for the user, the client's identity and the scopes the user granted it. From that point forward, internal operations use service roles. Components operate with service credentials like database connections, API keys, and queue permissions. User context is passed explicitly as structured data, not as the original session token.&lt;/p&gt;
&lt;p&gt;The granted scopes in that context matter. A third-party app that holds only read access to a user's orders must not gain everything the user can do once its token is converted at the edge. So downstream authorization allows an action only when the user's current permissions and the client's granted scopes both permit it.&lt;/p&gt;
&lt;h4 id=&quot;the-boundary-signs-the-context&quot;&gt;The Boundary Signs the Context&lt;/h4&gt;
&lt;p&gt;Passing context explicitly doesn't mean leaving it unprotected. A downstream service can't accept a bare user_id from anyone who sends one, or any caller could claim to be any user. The context has to arrive in a form the receiver can trust, which means a small object the boundary signs. An authenticated service-to-service channel isn't enough on its own. It proves which service is calling but not which user that service is acting for, so a compromised caller could assert any user_id it liked.&lt;/p&gt;
&lt;p&gt;Netflix describes this design in its &quot;Edge Authentication and Token-Agnostic Identity Propagation&quot; post. Its edge services validate the many token types clients present and mint an integrity-protected identity object, called a Passport, which is what downstream services receive.&lt;/p&gt;
&lt;p&gt;A signed context object is still a credential, so it needs limits of its own:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It's issued and accepted only inside your trust domain, never by an external API.&lt;/li&gt;
&lt;li&gt;It's bound to one request or one message rather than to the user's login, through a short expiry, an audience claim that limits it to your internal services, and a request or message id.&lt;/li&gt;
&lt;li&gt;It carries only the fields the work needs.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The boundary signs with a private key that only it holds, so a compromised internal service can read the context it was given but can't mint one for a different user. Like Netflix's Passport, the same context is passed along the whole call chain rather than re-signed at each hop, so no hop has to call a signing service mid-request.&lt;/p&gt;
&lt;p&gt;The context can still be replayed inside your system. Within its short validity window, a service in the call path could replay it to another internal service, which is the same exposure a forwarded token has. What's gone is the external replay. No API outside your system accepts it, and it doesn't outlive the request.&lt;/p&gt;
&lt;h4 id=&quot;token-exchange-meets-the-line-forwarding-doesn-t&quot;&gt;Token Exchange Meets the Line, Forwarding Doesn't&lt;/h4&gt;
&lt;p&gt;This post draws its line at the first two limits above. Whatever travels inward is issued inside your trust domain and bound to one request or message. A gateway that exchanges the user's token for an internally issued, internally audienced one, using OAuth 2.0 Token Exchange (RFC 8693), meets that line. Forwarding the token the client presented does not.&lt;/p&gt;
&lt;p&gt;Another alternative, per-hop delegation from the external identity provider, has each service trade for a new token audienced to the next one. It answers the replay concern but still crosses the line. Every hop then calls the provider at runtime and parses its token format, while a boundary-signed context is verified locally with a key you own.&lt;/p&gt;
&lt;p&gt;Meeting that line at the gateway doesn't have to be heavy. For a system with two or three services and one issuer, the gateway signing a short-lived internal token with a standard JWT library keeps its cost to one key to manage.&lt;/p&gt;
&lt;h3 id=&quot;why-sessions-don-t-belong-in-internal-flows&quot;&gt;Why Sessions Don't Belong in Internal Flows&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Sessions are external trust artifacts.&lt;/strong&gt; A user session token represents authentication with an external identity provider or your auth system. It's proof that someone outside your system is who they claim to be. Once verified at your boundary, propagating it further treats authentication as global state rather than a specific validation event.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Repeated validation adds a runtime dependency on the auth server.&lt;/strong&gt; Every component that receives a user session has to validate it. For a self-contained token like a JWT, that's a local signature and expiry check. Verifying a boundary-signed context costs about the same, so the saving there is small. For opaque tokens and server-side sessions, validation means a call to the auth server, such as the introspection endpoint defined in RFC 7662. Each hop then adds a network round trip and makes the auth server's availability a condition of every internal call for a single user action.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Coupling to external authentication.&lt;/strong&gt; When internal components accept user sessions, they become coupled to the token format, the issuer's validation requirements, token refresh logic, and external auth provider availability. With one standard OIDC issuer and a shared validation library, switching issuers can be mostly configuration. The coupling shows when the estate isn't that tidy, with several client token types, a proprietary session format, or a migration between them. Then every component that parses sessions has to change with it. Netflix's Passport work was a response to exactly that variety. A boundary-signed context still needs a shared format and key distribution, but that format is yours and doesn't change when the external issuer does.&lt;/p&gt;
&lt;h3 id=&quot;session-propagation-breaks-when-work-outlives-the-request&quot;&gt;Session Propagation Breaks When Work Outlives the Request&lt;/h3&gt;
&lt;p&gt;Propagating user sessions through internal components creates predictable failures whenever work outlives the request or has no user behind it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Session expiration mid-flow&lt;/strong&gt; is the first to appear. Event processors run minutes later and the session has expired. Background jobs run hours later and the session is gone. Async processors retry and the session is invalid. You build workarounds: token refresh in queues, session persistence in metadata, special &quot;system sessions&quot; for background work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Missing sessions entirely&lt;/strong&gt; breaks your logic. Scheduled reports run at 3 AM with no user logged in. Webhooks arrive from external systems with no user session. System maintenance tasks have no user context. You duplicate logic: one path for user requests and one for non-user operations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Context across time and actors&lt;/strong&gt; fragments workflows. Multi-step workflows span hours. The original user's session expires. Different actors have different sessions. Background processors have none. The workflow breaks without explicit context that persists beyond the session lifetime.&lt;/p&gt;
&lt;p&gt;Storing context as data, which the boundary pattern makes the default, removes all three. The service that owns a workflow stores the original context with the workflow's own record, as data about who started it. Each later step or retry works from that record rather than from a credential that expires. When a later step calls another service, the workflow service calls as itself and passes the initiating user_id as data. The receiver checks that this service is allowed to request this action for this tenant, then checks the user's current permissions. A scheduled job or a webhook handler works the same way, with its own service identity and no user at all.&lt;/p&gt;
&lt;p&gt;Pair the pattern with one rule. Every step authorizes against the user's current permissions and account status when it runs, so a user whose access was removed, or whose session was revoked after a compromise, is refused even hours later. The rule is independent of the token question, and a design that forwards tokens could adopt it too, but this pattern can't work without it. The context carries identity and the client's scope ceiling, not the user's roles, so a step has to look up permissions to decide anything. That lookup reads your own permission data, which the service already depends on to do the work, rather than making the external auth server a condition of every hop.&lt;/p&gt;
&lt;p&gt;Async work has a limit here. Nothing in this path ties the user_id to a workflow that user started. A compromised workflow service can therefore act for any user in the tenants it serves, through the operations its own service identity is allowed. That is the same trust any background job carries, and it's why each service's identity should be allowed only the operations that service performs.&lt;/p&gt;
&lt;h3 id=&quot;common-objections-1&quot;&gt;Common Objections&lt;/h3&gt;
&lt;h4 id=&quot;-we-can-forward-tokens-on-synchronous-calls-only-&quot;&gt;&quot;We Can Forward Tokens on Synchronous Calls Only&quot;&lt;/h4&gt;
&lt;p&gt;The strongest version of this view forwards a short-lived, audience-restricted token down synchronous call chains and uses explicit context only for async work. That view's async half is this post's design. Its sync half still carries the external artifact, so it keeps the coupling to the issuer's format described earlier. Widening the token's audience to cover the internal services, which RFC 9700 permits for a small set of resource servers, keeps the external replay out. It still leaves a credential in the issuer's format that lasts the token's whole lifetime rather than one request.&lt;/p&gt;
&lt;h4 id=&quot;-we-lose-user-context-for-auditing-&quot;&gt;&quot;We Lose User Context for Auditing&quot;&lt;/h4&gt;
&lt;p&gt;You lose the session token, not the user context. Proper auditing requires intentional context capture: user_id, tenant_id, correlation_id, source_ip, user_agent, and timestamp.&lt;/p&gt;
&lt;p&gt;The identity fields travel in the signed context through every component. Request details like source_ip and user_agent are logged once at the boundary and joined to downstream records by correlation_id, so no internal service can forge them. Relying on session tokens for auditing is worse because sessions don't persist for audit trails, you're logging authentication artifacts instead of business context, and different components might parse tokens differently.&lt;/p&gt;
&lt;p&gt;Intentional data flow is the point. If you need user_id in audit logs, require it as an explicit parameter. This forces you to think about what context crosses boundaries and prevents accidental omissions.&lt;/p&gt;
&lt;h4 id=&quot;-compliance-requires-user-identity-throughout-the-system-&quot;&gt;&quot;Compliance Requires User Identity Throughout the System&quot;&lt;/h4&gt;
&lt;p&gt;Compliance rules ask you to authenticate who is acting and to record what they did. The HIPAA Security Rule, for example, requires verifying that a person seeking access is the one claimed (45 CFR 164.312(d)) and mechanisms that record and examine activity in systems holding health data (164.312(b)). Authentication happens at the boundary. Recording happens wherever the action happens, and a trusted user_id in explicit context is what that record needs. Passing the session token does not improve compliance. It couples your compliance logging to your auth mechanism.&lt;/p&gt;
&lt;h4 id=&quot;-service-roles-create-privilege-escalation-risks-&quot;&gt;&quot;Service Roles Create Privilege Escalation Risks&quot;&lt;/h4&gt;
&lt;p&gt;The risk is that a service with broad credentials can be tricked or compromised into acting outside the current user's rights. A forwarded token limits that risk only on calls to other services. On those calls, within one request, a boundary-signed context limits it just as well, since a compromised service can't mint context for another user. Async steps rely on the calling service's own identity, with the limit described earlier.&lt;/p&gt;
&lt;p&gt;For the service's own resources, the token changes nothing. A compromised service with a database connection that reads every tenant's rows can read them whether or not it holds a user token, unless the database itself enforces the user's identity.&lt;/p&gt;
&lt;p&gt;Service roles provide technical capability like database access and API credentials. Business logic enforces authorization through grant checks, tenant isolation, and permission verification. Where a breach would be severe, the data layer can enforce tenant isolation too, for example with row-level security keyed to the tenant in the context. These checks are necessary whether you use service roles or propagate user sessions. Scope each service's credentials to what that service does, so a compromise is limited to one service's capability.&lt;/p&gt;
&lt;p&gt;A forwarded session adds its own risk. A bearer token works for anyone who holds it, so every internal component that receives the user's token can replay it as that user anywhere the token is accepted. That's why RFC 9700, the OAuth 2.0 security best practice, says access tokens should be audience-restricted to a specific resource server. A token restricted to your API shouldn't be accepted by every service behind it, and one that is has become a credential your whole system leaks.&lt;/p&gt;
&lt;p&gt;Service roles also make the split clearer. The service has technical capability but requires explicit business authorization. User sessions blur this distinction by suggesting the session itself grants permission.&lt;/p&gt;
&lt;h4 id=&quot;-zero-trust-means-every-service-verifies-the-user-&quot;&gt;&quot;Zero Trust Means Every Service Verifies the User&quot;&lt;/h4&gt;
&lt;p&gt;Zero trust doesn't ask for the user's original token at every hop. NIST SP 800-207 requires that authentication and authorization are enforced before every access to a resource, rather than inherited from network location or an earlier step. A downstream service that verifies its caller's service identity and the signature on the user context, then authorizes the action against that context, meets the requirement. Forwarding the session meets it no better and spreads a replayable credential.&lt;/p&gt;
&lt;p&gt;The legitimate exception is a component that must call another system as the user, such as a third-party API that only accepts the user's delegated token. Even then the original session doesn't travel. The boundary, the one place that holds the user's token, uses OAuth 2.0 Token Exchange (RFC 8693) to trade it for a new token issued for that one audience and scoped to that call. Work that calls the third party hours later relies on a grant the owning service stores as data, the way a workflow stores its context, not on a token carried through queues.&lt;/p&gt;
&lt;h4 id=&quot;-we-need-sessions-for-distributed-tracing-&quot;&gt;&quot;We Need Sessions for Distributed Tracing&quot;&lt;/h4&gt;
&lt;p&gt;You need correlation IDs for distributed tracing, not session tokens.&lt;/p&gt;
&lt;p&gt;Distributed systems require explicit tracing infrastructure: correlation_id, trace_id, span_id, and timestamps. This works for all request types including user requests, background jobs, webhooks, internal system tasks, lambda invocations, and event processors.&lt;/p&gt;
&lt;p&gt;If you rely on session tokens for tracing, non-user-initiated requests fail. When you need to join traces to a login session, the non-secret session id in the context does that without carrying the token.&lt;/p&gt;
&lt;h4 id=&quot;-we-re-using-a-monolith-this-doesn-t-apply-&quot;&gt;&quot;We're Using a Monolith, This Doesn't Apply&quot;&lt;/h4&gt;
&lt;p&gt;Inside one process there's no token crossing a network and nothing to replay, so the security arguments above weigh less. Most web frameworks already apply the boundary rule for you, since their request principal holds a validated identity rather than the raw token. What remains is where business logic gets that identity. You still must avoid global user session context and pass user context within structures appropriate to the needs of each module.&lt;/p&gt;
&lt;p&gt;Business logic that reads the current user from the web request couples your service layer to your web layer. A background job, CLI tool, or internal script that calls the same logic has no web request, so it either fails the way a missing session fails or has to fake a request principal first. Logic that takes the user as a parameter can be called from any of them as it stands.&lt;/p&gt;
&lt;p&gt;The same principles apply: modules accept explicit context and perform explicit authorization. Whether those modules are in separate processes or the same codebase is irrelevant to that design.&lt;/p&gt;
&lt;h3 id=&quot;respect-the-boundary&quot;&gt;Respect the Boundary&lt;/h3&gt;
&lt;p&gt;User authentication sessions serve one purpose: validating identity at a security boundary. Once validated, the session has done its job.&lt;/p&gt;
&lt;p&gt;In practice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Validate the session once, and let no component behind it parse the token&lt;/li&gt;
&lt;li&gt;Extract only the context the work needs, such as user_id, tenant_id, correlation_id, and the calling client's granted scopes&lt;/li&gt;
&lt;li&gt;Sign that context at the boundary, bind it to one request or message, and send it over authenticated service calls&lt;/li&gt;
&lt;li&gt;Give each service its own narrowly scoped credentials&lt;/li&gt;
&lt;li&gt;Authorize every action against current permissions, account status, and the client's granted scopes, whether a user, a webhook, or a scheduled job started it&lt;/li&gt;
&lt;li&gt;When a component must act as the user elsewhere, exchange for a new token scoped to that call&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote class=&quot;pull-quote&quot;&gt;
&lt;p&gt;This isn't about microservices versus monoliths. It's about recognizing architectural boundaries and respecting them. Authentication artifacts belong at boundaries, not in internal flows.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/8087/auth-sessions-should-never-cross-internal-boundaries</guid>
<pubDate>Tue, 02 Dec 2025 15:31:54 +0000</pubDate>
</item>
<item>
<title>Why I Changed My Mind About Exceptions</title>
<link>https://coderlegion.com/7856/why-i-changed-my-mind-about-exceptions</link>
<description>&lt;p&gt;I prefer the clean code that is more often produced by throwing exceptions. With the happy path uncluttered by error handling, the implicit propagation of errors to appropriate orchestration layers, and the clean separation of concerns. It's aesthetically cleaner and moves the complexity of error handling to one or just a few decision points.&lt;/p&gt;
&lt;p&gt;But when Rust's approach to error handling gained cultural influence and C# developers (among many others of course) began exploring Result types through community libraries, I re-examined my perspective more honestly. Do the arguments for Results hold up under scrutiny for expected failures in modern systems?&lt;/p&gt;
&lt;p&gt;This isn't just blind advocacy. It's an honest conceptual analysis of the arguments from both camps, examining which ones hold up and which are subjective preference.&lt;/p&gt;
&lt;p&gt;Before I dive into this, let's be clear about what we are not talking about. We are not talking about programming errors like null references, array index violations, or machine resource errors. Those are bugs and special occurrences which should cause a fatal response since there is no safe way to default the outcome.&lt;/p&gt;
&lt;h3 id=&quot;choosing-between-results-and-exceptions&quot;&gt;Choosing Between Results and Exceptions&lt;/h3&gt;
&lt;p&gt;When validating a payment request that might fail for multiple reasons (insufficient funds, expired card, fraud detection, network timeout), should your code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Option 1: Throw exceptions
public PaymentConfirmation ProcessPayment(PaymentRequest request)
{
    ValidateRequest(request); // Throws ValidationException
    var charge = _paymentService.Charge(request); // Throws PaymentDeclinedException
    var confirmation = _repository.SaveTransaction(charge); // Throws DatabaseException
    return confirmation;
}

// Option 2: Return Results
public Result&amp;lt;PaymentConfirmation&amp;gt; ProcessPayment(PaymentRequest request)
{
    var validationResult = ValidateRequest(request);
    if (validationResult.IsFailure)
        return Result.Failure&amp;lt;PaymentConfirmation&amp;gt;(validationResult.Error);

    var chargeResult = _paymentService.Charge(request);
    if (chargeResult.IsFailure)
        return Result.Failure&amp;lt;PaymentConfirmation&amp;gt;(chargeResult.Error);

    var saveResult = _repository.SaveTransaction(chargeResult.Value);
    return saveResult; // Already Result&amp;lt;PaymentConfirmation&amp;gt;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the first option, a higher layer makes the decision. Or we simply let the failure propagate and use middleware to convert it to what we assume is a safe response, often an HTTP status code.&lt;/p&gt;
&lt;p&gt;In the second option, which can vary greatly depending on the language or library being used, we can choose or are forced to handle each possible outcome. Often we need to handle a success, a failure, or a partial failure. This makes the expected outcomes visible at every layer. It also avoids throwing deep in a workflow only to catch and translate at the top, paying an exception's cost for an outcome everyone expected.&lt;/p&gt;
&lt;h3 id=&quot;why-expected-failures-matter-more-now&quot;&gt;Why Expected Failures Matter More Now&lt;/h3&gt;
&lt;blockquote class=&quot;pull-quote&quot;&gt;
&lt;p&gt;In modern distributed systems, expected failures happen constantly at scale: circuit breaker fallbacks, timeout retries, validation of user input, partial batch results.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Distributed systems made expected failures more frequent&lt;/strong&gt;. A monolith already calls databases and third-party APIs that time out, but most of its calls stay in-process and can't. Decomposing it turns more of those in-process calls into network hops, and each hop adds timeouts and transient faults. Business answers that used to be method calls, such as an out-of-stock or credit check, now arrive as a peer's 404, 409, or decline, and treating those frequent outcomes as exceptions creates friction.&lt;/p&gt;
&lt;p&gt;That friction shows up in the client making the call. At a service boundary, Results and exceptions both serialize a failure into a status and body, but common clients default to throwing. Refit throws an &lt;code&gt;ApiException&lt;/code&gt; for every non-success status on a method returning &lt;code&gt;Task&amp;lt;T&amp;gt;&lt;/code&gt;, so an expected 404 or 409 becomes an exception the caller must catch and inspect. Its &lt;code&gt;ApiResponse&amp;lt;T&amp;gt;&lt;/code&gt; wrapper records the error instead, a Result in all but name.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Offline-first architecture became common&lt;/strong&gt;. For progressive web apps and mobile and desktop clients, &quot;no network&quot; and sync conflicts are normal operating conditions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Functional architecture became common&lt;/strong&gt;. Even OOP codebases now use stateless services, immutable data pipelines, and event-driven patterns. These designs reward treating a failure as data, and a message handler shows the mechanism. A handler that returns a Result can acknowledge the message and publish the rejection as an event. A handler that throws on a business rejection makes the broker treat the message as failed, redeliver it, and after enough retries dead-letter a message that was never broken. A top-level catch that lists the rejection exception types fixes that, until a new rejection it doesn't list dead-letters. A Result puts every rejection in the handler's signature.&lt;/p&gt;
&lt;h3 id=&quot;rust-enforces-the-split-c-leaves-it-to-discipline&quot;&gt;Rust Enforces the Split, C# Leaves It to Discipline&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Rust&lt;/strong&gt; emerged with strong functional influences. It enforces a clear split: expected failures return &lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;/code&gt; types, while unexpected failures trigger panics that unwind the stack like exceptions. The standard library marks &lt;code&gt;Result&lt;/code&gt; as &lt;code&gt;#[must_use]&lt;/code&gt;, so ignoring one draws a compiler warning by default (a team can deny that lint to make it an error).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;C#&lt;/strong&gt; started heavily OOP-dominant and progressively adopted functional features (LINQ, pattern matching, immutability). It historically used exceptions for all failures, both expected and unexpected.&lt;/p&gt;
&lt;p&gt;In C#, the same split is now possible through pattern matching (C# 7+) and community Result libraries, but it relies on discipline rather than compiler enforcement. C# 15, shipping with .NET 11 in November 2026, adds union types whose &lt;code&gt;switch&lt;/code&gt; expressions the compiler checks for exhaustiveness. A union of specific error cases gets checked handling once the caller matches on it.&lt;/p&gt;
&lt;p&gt;Microsoft's own guidance stays exception-first. It hasn't shipped a general Result type, and its Framework Design Guidelines say not to use error codes over performance concerns. The guidelines also call slow exceptions in code that routinely fails &quot;a valid concern&quot; and note that a throwing member &quot;can be orders of magnitude slower.&quot; For members that fail in common scenarios, they recommend the Try-Parse pattern (&lt;code&gt;int.TryParse&lt;/code&gt; returning &lt;code&gt;false&lt;/code&gt;), paired with a throwing version. Microsoft presents that as a pattern within the exception model, applied member by member, not a different default.&lt;/p&gt;
&lt;p&gt;The general Result pattern is &lt;strong&gt;community-driven&lt;/strong&gt;. FluentResults and ErrorOr have about 37 and 12 million NuGet downloads (October 2026), small next to Newtonsoft.Json's 9 billion, and LanguageExt's 49 million cover a whole functional library, not just Results. Still, several independent libraries building the same pattern shows a need, in some codebases, that the base library leaves unmet.&lt;/p&gt;
&lt;h3 id=&quot;arguments-for-result-types&quot;&gt;Arguments for Result Types&lt;/h3&gt;
&lt;h4 id=&quot;performance-for-frequent-expected-failures&quot;&gt;Performance for frequent expected failures&lt;/h4&gt;
&lt;p&gt;When validation failures happen thousands of times per second on a CPU-bound hot path, throwing costs more than branching, because an exception captures a stack trace and unwinds the stack. Inside a request that waits on a database or a timeout, the cost is noise, so this is the narrowest of the four arguments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strength&lt;/strong&gt;: A gain on CPU-bound hot paths, not a reason on its own.&lt;/p&gt;
&lt;h4 id=&quot;information-disclosure-prevention-by-default&quot;&gt;Information disclosure prevention by default&lt;/h4&gt;
&lt;p&gt;An exception carries its stack trace and message everywhere it goes, and following Microsoft's guidance puts a provider's details inside it. Microsoft's best practices for exceptions tell you to rethrow the original or wrap it as the inner exception. So a domain exception built by the book carries the provider exception from a database driver or library you don't control, and that message can include constraint names, SQL fragments, or file paths. ASP.NET Core's production default returns a bare 500, so those details leak through handlers teams write themselves, such as an error response that includes &lt;code&gt;ex.Message&lt;/code&gt; or &lt;code&gt;ex.ToString()&lt;/code&gt;. An adapter could throw a fresh exception with no inner one, but that goes against the guidance.&lt;/p&gt;
&lt;p&gt;Results change this when the error type can't hold an exception, such as a C# 15 union of domain cases or an error record with only a code and message. An adapter that translates the provider exception into one of those logs it at that point. The diagnostics stay in your logs, and the error carries only the fields your domain code put in it. FluentResults' &lt;code&gt;CausedBy&lt;/code&gt; and ErrorOr's metadata dictionary can both attach the exception, which reopens the leak. Unanticipated provider exceptions still depend on the boundary handler in either design.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strength&lt;/strong&gt;: With an error type that can't carry exceptions, modeled failures are safe by construction.&lt;/p&gt;
&lt;h4 id=&quot;type-signatures-as-reliable-documentation&quot;&gt;Type signatures as reliable documentation&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Result&amp;lt;Order&amp;gt;&lt;/code&gt; tells you immediately that getting an order can fail. &lt;code&gt;Order&lt;/code&gt; tells you nothing without reading implementation or relying on potentially outdated XML comments. Refactoring tools update type signatures automatically; they don't update documentation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strength&lt;/strong&gt;: Fallibility in the signature can't silently go stale the way a comment can.&lt;/p&gt;
&lt;h4 id=&quot;natural-composition-for-partial-success-and-iteration&quot;&gt;Natural composition for partial success and iteration&lt;/h4&gt;
&lt;p&gt;Batch operations, parallel workflows, and offline sync scenarios often have partial success. Results compose naturally through filtering and mapping.&lt;/p&gt;
&lt;p&gt;More critically, &lt;strong&gt;exceptions pull iteration toward wherever they are caught&lt;/strong&gt;. When some items in a collection might fail, iteration tends to land in the orchestration layer where the catch is, even if it logically belongs in the service layer.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Exception approach - iteration pushed into the controller
public async Task ProcessOrders(List&amp;lt;string&amp;gt; orderIds)
{
    foreach (var id in orderIds) // Iterate where you can catch
    {
        try { await _orderService.Process(id); }
        catch (Exception ex) { /* handle */ }
    }
}

// Result approach - iteration lives in service layer
public async Task&amp;lt;Result&amp;lt;Order&amp;gt;[]&amp;gt; ProcessAll(List&amp;lt;string&amp;gt; orderIds)
{
    var tasks = orderIds.Select(id =&amp;gt; Process(id));
    return await Task.WhenAll(tasks); // Parallel, every outcome kept
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The service layer could iterate with exceptions too, but only by catching each item's exception and converting it into a per-item success or failure, which is a Result by another name.&lt;/p&gt;
&lt;p&gt;Parallelism adds a second cost. &lt;code&gt;Task.WhenAll&lt;/code&gt; waits for every task, but if any of them faults, the combined task faults with all the exceptions aggregated. &lt;code&gt;await&lt;/code&gt; rethrows only the first, and no array of results comes back, so the caller goes back to each task to sort successes from faults. &lt;code&gt;Task.WhenAll&lt;/code&gt; over Results returns every outcome as an ordinary array.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strength&lt;/strong&gt;: Results allow iteration and parallelism at appropriate abstraction levels.&lt;/p&gt;
&lt;h3 id=&quot;arguments-for-exceptions&quot;&gt;Arguments for Exceptions&lt;/h3&gt;
&lt;h4 id=&quot;implicit-propagation-to-appropriate-handlers&quot;&gt;Implicit propagation to appropriate handlers&lt;/h4&gt;
&lt;p&gt;Exceptions bubble to orchestration layers without code at each level. The happy path stays clean; error handling lives at boundaries.&lt;/p&gt;
&lt;p&gt;Results require explicit propagation. Return &lt;code&gt;Result&amp;lt;T&amp;gt;&lt;/code&gt;, check it, propagate it. This threads error handling through intermediate functions that don't care about the specific error.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strength&lt;/strong&gt;: Separation of concerns. Domain logic stays focused on domain, not error threading.&lt;/p&gt;
&lt;h4 id=&quot;framework-integration-without-friction&quot;&gt;Framework integration without friction&lt;/h4&gt;
&lt;p&gt;The .NET ecosystem uses exceptions. Entity Framework throws &lt;code&gt;DbUpdateException&lt;/code&gt;. HttpClient throws &lt;code&gt;HttpRequestException&lt;/code&gt;. Wrapping every framework call in try-catch to convert to Results creates boilerplate at every boundary.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strength&lt;/strong&gt;: Working with the ecosystem, not against it.&lt;/p&gt;
&lt;h4 id=&quot;c-doesn-t-enforce-result-handling&quot;&gt;C# doesn't enforce Result handling&lt;/h4&gt;
&lt;p&gt;Unlike Rust, where ignoring a &lt;code&gt;Result&lt;/code&gt; draws a compiler warning, C# lets you completely ignore returned Results. You can access &lt;code&gt;.Value&lt;/code&gt; without checking &lt;code&gt;.IsSuccess&lt;/code&gt; and get runtime exceptions anyway.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public Result&amp;lt;Order&amp;gt; GetOrder(string id) { /* ... */ }

GetOrder(&quot;123&quot;); // Completely ignored, no compiler error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &quot;compiler safety&quot; argument assumes static analyzers and discipline, the same discipline proper exception handling requires. The way each one fails differs too. A forgotten exception propagates and fails the request loudly, while a forgotten Result with no value, such as a failed save, lets execution continue as if it succeeded.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strength&lt;/strong&gt;: Results in C# provide discoverability, not enforcement.&lt;/p&gt;
&lt;h4 id=&quot;orchestration-layers-solve-the-same-problems&quot;&gt;Orchestration layers solve the same problems&lt;/h4&gt;
&lt;p&gt;Proper architecture already requires orchestration layers that catch domain exceptions, translate them to appropriate responses, and control what information crosses boundaries. Results don't eliminate the need for this architecture; they just change what propagates upward.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strength&lt;/strong&gt;: Architecture matters more than mechanism.&lt;/p&gt;
&lt;h4 id=&quot;exception-documentation-is-sufficient-with-discipline&quot;&gt;Exception documentation is sufficient with discipline&lt;/h4&gt;
&lt;p&gt;XML comments document exceptions, appear in IntelliSense, and provide discoverability at call sites:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;/// &amp;lt;exception cref=&quot;OrderNotFoundException&quot;&amp;gt;When the order doesn't exist&amp;lt;/exception&amp;gt;
public Order GetOrder(string id)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Well-maintained codebases keep documentation current through code reviews. If you have discipline to maintain exception documentation, you have discipline to handle Results properly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strength&lt;/strong&gt;: Documentation works if teams maintain it.&lt;/p&gt;
&lt;h4 id=&quot;results-encourage-scattered-error-handling&quot;&gt;Results encourage scattered error handling&lt;/h4&gt;
&lt;p&gt;Making it syntactically easy to handle errors inline encourages developers to scatter error-handling logic across call sites instead of centralizing it in orchestration layers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strength&lt;/strong&gt;: Results can create worse maintainability if misused.&lt;/p&gt;
&lt;h3 id=&quot;which-arguments-hold-up-&quot;&gt;Which Arguments Hold Up?&lt;/h3&gt;
&lt;div class=&quot;comparison&quot;&gt;
&lt;div class=&quot;content-card content-card--accent&quot;&gt;
&lt;h4&gt;Result Arguments That Stand&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Throwing costs more than branching, which matters on CPU-bound hot paths&lt;/li&gt;
&lt;li&gt;A modeled failure's error carries no stack trace or provider message&lt;/li&gt;
&lt;li&gt;Type signatures show that an operation can fail more reliably than documentation&lt;/li&gt;
&lt;li&gt;Iteration and parallelism can live at appropriate abstraction levels&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class=&quot;content-card content-card--accent-warning&quot;&gt;
&lt;h4&gt;Exception Arguments That Stand&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Implicit propagation reduces boilerplate in intermediate layers&lt;/li&gt;
&lt;li&gt;Framework integration is smoother without constant translation&lt;/li&gt;
&lt;li&gt;Orchestration layers remain valuable for consolidating error handling decisions&lt;/li&gt;
&lt;li&gt;C# doesn't enforce Result handling, so Results give discoverability, not enforcement&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Exception arguments that weaken:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Documentation is sufficient with discipline (documentation rots in practice)&lt;/li&gt;
&lt;li&gt;A clean happy path is clearer (it also hides what can fail)&lt;/li&gt;
&lt;li&gt;Exceptions are for rare cases, so their cost doesn't matter (expected failures aren't rare)&lt;/li&gt;
&lt;li&gt;Results scatter error handling (orchestration layers can still own the decisions)&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 id=&quot;where-the-structural-arguments-point&quot;&gt;Where the Structural Arguments Point&lt;/h3&gt;
&lt;p&gt;Some arguments on both lists are structural, meaning they follow from what the mechanism is, like propagation or signatures, rather than how carefully a team uses it. What separates them is whether each side's structural weakness has a workaround. Results' weakness is the exception camp's strongest argument, implicit propagation, since C# has no &lt;code&gt;?&lt;/code&gt; operator to hide the explicit threading Results need. But for a linear flow, library combinators recover most of the clean happy path, as in the earlier payment flow written with ErrorOr's &lt;code&gt;Then&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public ErrorOr&amp;lt;PaymentConfirmation&amp;gt; ProcessPayment(PaymentRequest request) =&amp;gt;
    ValidateRequest(request)
        .Then(_ =&amp;gt; _paymentService.Charge(request))
        .Then(charge =&amp;gt; _repository.SaveTransaction(charge));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Async steps and steps that need several earlier values push combinators toward nested lambdas, so the recovery is partial.&lt;/p&gt;
&lt;p&gt;Exceptions have no workaround for per-item outcomes, typed fallibility, or cheap hot-path failures. Catching and converting, or adding a Try-Parse member, each produces a Result by another name. Resilience pipelines such as Polly help less than they seem to. They keep retries and simple substitute-value fallbacks out of the intermediate layers, but per-item outcomes and fallbacks that need domain context still land there as try-catch blocks.&lt;/p&gt;
&lt;p&gt;What an exception default gives up is the signature. Implicit propagation is genuine convenience, but it comes at a cost of invisible failures. A &lt;code&gt;Result&amp;lt;Order&amp;gt;&lt;/code&gt; with an open error list, rather than a closed set like a C# 15 union, doesn't say every way it can fail either. But a partial, typed channel for the expected outcomes still beats no channel.&lt;/p&gt;
&lt;p&gt;If Results win where the workarounds run out, the obvious compromise is to use them only there, a hybrid of exceptions by default and Results at the seams. In a distributed system the seams aren't few. Every downstream call, message handler, and batch is one. And a hybrid decides each method's failure contract case by case, so a caller can't tell from a signature which convention it follows. A default makes the signature answer that question the same way for every domain outcome. Infrastructure faults follow one test. The adapter returns a Result when the caller has a decision to make about the fault, and lets it throw otherwise. Its signature shows which faults come back.&lt;/p&gt;
&lt;p&gt;A default still leaves callers two channels, a Result to check and exceptions that can still propagate. The exception channel is the boundary catch an exception-only design already has, so the added work is the Result branch, where the caller's decisions were going to live anyway.&lt;/p&gt;
&lt;p&gt;Both sides depend on discipline, but a discarded Result can be caught by a linter and a stale exception comment can't. A forgotten Result that carries a value still fails loudly, since FluentResults throws when you read &lt;code&gt;.Value&lt;/code&gt; from a failure. The silent case is a discarded Result with no value. The SDK's own CA1806 rule flags it once you raise its severity from suggestion. You list your Result-returning methods in its &lt;code&gt;additional_use_results_methods&lt;/code&gt; option, an upkeep cost but a checkable one. Keeping &lt;code&gt;&amp;lt;exception&amp;gt;&lt;/code&gt; comments current means tracing every throw through the call graph, which third-party checked-exception analyzers attempt and the SDK doesn't ship.&lt;/p&gt;
&lt;p&gt;That reasoning comes down to a short set of rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Return Results for expected domain outcomes, such as validation, business rules, and declined or unavailable downstream services&lt;/li&gt;
&lt;li&gt;Throw exceptions for programming errors, such as null references and contract violations&lt;/li&gt;
&lt;li&gt;Translate a framework exception into a Result at the adapter only when the caller has a decision to make about it, such as a timeout that triggers a fallback or a unique-key violation that means &quot;already exists,&quot; and let the rest propagate&lt;/li&gt;
&lt;li&gt;Run parallel work as &lt;code&gt;Task.WhenAll&lt;/code&gt; over Results, converting any exception an item throws into a failed Result at the item boundary, so one fault can't discard the other outcomes&lt;/li&gt;
&lt;li&gt;Centralize error handling decisions in orchestration layers that handle both Results and the framework exceptions that propagate&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;why-the-resistance-&quot;&gt;Why the Resistance?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Paradigm friction&lt;/strong&gt; explains part of the resistance. C# grew up with exceptions as its only failure channel, while the functional tradition that shaped Rust treats expected failures as data. Preferring exceptions in C# follows the language's history as much as any weighing of the arguments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hard-won expertise&lt;/strong&gt; also contributes: &quot;Exceptions work if done correctly, and I've learned how to do them correctly.&quot; This solves yesterday's problem (poor exception handling) rather than today's problem. When a large portion of operations return expected failures, exceptions require working around their design, not just using them correctly.&lt;/p&gt;
&lt;h3 id=&quot;what-i-learned&quot;&gt;What I Learned&lt;/h3&gt;
&lt;blockquote class=&quot;pull-quote&quot;&gt;
&lt;p&gt;Result types should be the default for domain operations, not because they're perfect, but because the structural advantages outweigh the execution risks.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Despite my preferences, the evidence (at least conceptually) has led me to that conclusion. Where expected failures are frequent and callers act on them, visible failures, safe errors for the failures you model, and natural iteration patterns matter more than implicit propagation convenience. In a codebase of linear operations with few caller decisions, the balance is closer. The default is the pattern, not a library, so a team can move from today's libraries to C# 15 unions when it wants closed error sets.&lt;/p&gt;
&lt;p&gt;When an increasing number of use cases can and will return expected failures, you need mechanisms designed for common outcomes, not rare anomalies.&lt;/p&gt;
&lt;p&gt;Moving forward I will endeavor to use Results for expected failures, reserve exceptions for bugs, and build the discipline and tooling to use them both well.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/7856/why-i-changed-my-mind-about-exceptions</guid>
<pubDate>Wed, 26 Nov 2025 16:55:50 +0000</pubDate>
</item>
<item>
<title>Monoliths for Discovery, Microservices for Optimization</title>
<link>https://coderlegion.com/7589/monoliths-for-discovery-microservices-for-optimization</link>
<description>&lt;p&gt;I know, I know. It is another post about microservices versus monoliths. The debate feels exhausted at this point. Yet every time I start a new project, I find myself weighing the same questions. Not because the answer is unclear, but because the answer genuinely depends on where you are and what you're trying to learn.&lt;/p&gt;
&lt;blockquote class=&quot;pull-quote&quot;&gt;
&lt;p&gt;The choice isn't about finding the &quot;right&quot; architecture in the abstract. It's about choosing what fits your context, your constraints, and most importantly, what you need to discover.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;principles-that-decide-the-choice&quot;&gt;Principles That Decide the Choice&lt;/h3&gt;
&lt;h4 id=&quot;microservices-are-optimizations-monoliths-enable-discovery&quot;&gt;Microservices Are Optimizations, Monoliths Enable Discovery&lt;/h4&gt;
&lt;p&gt;Microservices are optimizations for specific problems: team scaling, independent deployment, technology diversity. They're solutions to constraints you've already identified. Monoliths, on the other hand, accelerate discovery. They let you learn where boundaries should be, understand domains as they reveal themselves, and validate assumptions quickly without the overhead of distributed systems.&lt;/p&gt;
&lt;p&gt;That overhead is concrete. Once two parts of a system talk over the network, every call can time out, fail halfway, or arrive twice, and data that used to change in one transaction now needs a consistency strategy. Each service also needs its own deployment pipeline, monitoring, and on-call coverage. Martin Fowler calls this the microservice premium, and in his post of that name he advises against even considering microservices &quot;unless you have a system that's too complex to manage as a monolith.&quot;&lt;/p&gt;
&lt;p&gt;The premium grows with every service added. In &quot;Goodbye Microservices,&quot; Alexandra Noonan describes how Segment split its event delivery into one service per destination, for a sound reason. A slow destination's retries had flooded a shared queue and delayed all the others. The split ended at more than 140 services. Shared libraries drifted apart, and three full-time engineers spent most of their time keeping the system alive, until the team merged the services back into one. The signal justified separating slow destinations from the rest, and it didn't justify a service for every destination.&lt;/p&gt;
&lt;p&gt;The premium hurts most while the domain is still unclear, because early boundaries are mostly guesses. Inside one process, a wrong guess is fixed with a refactoring. You move a class or change a method signature, and the compiler finds every caller. Across services, the same correction means changing two APIs, coordinating two deployments, and migrating data between two databases. Fowler's companion post, &quot;MonolithFirst,&quot; makes this argument. It also reports that almost all the successful microservice stories he had heard of started with a monolith that grew too big and was broken up. He offers that as an informal observation, not a study.&lt;/p&gt;
&lt;p&gt;Start with what helps you learn fastest, then optimize when you understand what actually needs optimizing.&lt;/p&gt;
&lt;h4 id=&quot;monoliths-don-t-excuse-poor-code-quality&quot;&gt;Monoliths Don't Excuse Poor Code Quality&lt;/h4&gt;
&lt;p&gt;The stigma around monoliths comes from poor practices, not the architecture itself. Terrible monoliths exist, but so do terrible microservices. A monolith whose modules call each other's internals and write each other's tables is hard to change and harder to split. A set of microservices that share tables and must deploy together has the same coupling, with network calls added. What decides whether either system stays changeable is good design discipline, meaning SOLID principles applied at the module level and clear boundaries. Each module depends on the others' public interfaces and never on their internals.&lt;/p&gt;
&lt;p&gt;The deployment model does change one thing. A process boundary makes reaching into another module's internals impossible, while inside a monolith it takes one import. That enforcement is part of what the microservice premium buys, but during discovery it isn't worth the price, because the same process boundary makes every module boundary expensive to move.&lt;/p&gt;
&lt;p&gt;A monolith can get most of that enforcement more cheaply. It needs it, because good intentions lose to deadlines. Give each module its own database schema, and have CI load and test each module with only the modules it declares as dependencies. A shortcut then fails a build instead of slipping through review. Moving a boundary still means editing a dependency list and a schema in one repository rather than redeploying two services.&lt;/p&gt;
&lt;p&gt;Neither the per-module schemas nor the CI checks add a runtime component, a network hop, or a second deployment. Both need upkeep, such as test setups for each module, and they are far harder to retrofit than to start with.&lt;/p&gt;
&lt;h4 id=&quot;ship-before-the-architecture-is-perfect&quot;&gt;Ship Before the Architecture Is Perfect&lt;/h4&gt;
&lt;p&gt;Ship and learn. Every week spent settling the architecture before launch is a week without users to show which parts of the design matter. The better approach is to make reversible decisions, establish clear boundaries, and iterate based on what you learn from real usage. Perfection is a moving target. Momentum matters more.&lt;/p&gt;
&lt;p&gt;Shipping before the design settles works only if wrong guesses are cheap to undo, and the starting choice decides how often you pay for distribution. Extracting a module from a monolith still means moving its data and taking on partial failure for every call across the new boundary. A team that starts with a modular monolith pays that distributed cost once per boundary it has already validated. A team that starts with services pays it for every guess, and again for each guess it has to correct. Either way, a decision stays reversible when undoing it touches one module instead of every caller, which is why the next two principles are about boundaries.&lt;/p&gt;
&lt;h4 id=&quot;data-boundaries-matter-from-day-one&quot;&gt;Data Boundaries Matter From Day One&lt;/h4&gt;
&lt;p&gt;Define data ownership early, even if you're starting with a monolith. Sharing one database between modules, or even between early services, isn't inherently wrong when you're just getting started. It works as long as each table has one owning module, and everything else goes through that module's code instead of querying its tables directly.&lt;/p&gt;
&lt;p&gt;Without that rule, extraction gets expensive. When three modules write to the same orders table, pulling the order module out means finding and rewriting every one of those queries. Each cross-module join also becomes a network call or a copy of the data. Tangled data models trap teams in legacy architectures.&lt;/p&gt;
&lt;p&gt;The ownership rule keeps the cheap in-process refactoring from the first principle. When a table changes owner in a modular monolith, the compiler and the test suite find the code to change, and there are no dual writes between two databases. The rule makes an ownership change a schema migration inside one database and one deployment, rather than a data move between two services.&lt;/p&gt;
&lt;h4 id=&quot;abstract-infrastructure-early&quot;&gt;Abstract Infrastructure Early&lt;/h4&gt;
&lt;p&gt;Put one routing layer you control in front of the monolith early. An API gateway works, and so does the reverse proxy or ingress it already runs behind. Either gives you flexibility before you know what your final infrastructure will look like. When clients call that layer instead of the monolith's own address, extracting a service later can keep the address clients already use, because the route moves there. That holds when the extracted module owns its own URL paths and the new service keeps the old contract.&lt;/p&gt;
&lt;p&gt;The routing layer doesn't help with the harder server-side work of partial failure and consistency. A dedicated gateway is also one more component to run and one more network hop. It pays off most when you have clients you can't update all at once, such as mobile apps or partner integrations. For those systems, the routing layer keeps an extraction from turning into a coordinated client release.&lt;/p&gt;
&lt;h3 id=&quot;the-strongest-case-against-starting-with-a-monolith&quot;&gt;The Strongest Case Against Starting With a Monolith&lt;/h3&gt;
&lt;p&gt;Stefan Tilkov answered Fowler with &quot;Don't start with a monolith.&quot; He argues that a monolith's parts &quot;will become extremely tightly coupled to each other&quot; through shared libraries, in-process calls, and shared domain objects and persistence models, and that splitting one up afterward is extremely hard. He is right about what happens to a monolith built without discipline. That coupling is the reason the principles above exist.&lt;/p&gt;
&lt;p&gt;Module boundaries, one owner per table, and a routing layer between clients and the deployment don't make a later split cheap, but they make it a matter of cutting along seams that already exist. The queries to rewrite are the ones the owning module already exposes, instead of a search through every module for whoever touches the data. Public accounts that follow one system from monolith discovery through extraction along settled seams are rare, so this rebuttal rests on the cost mechanism above more than on a body of case studies.&lt;/p&gt;
&lt;p&gt;His argument also marks where this approach stops applying. When the boundaries are already known, as when a team rebuilds a system whose domain it has run for years, there is less left to discover. Building separately deployable parts from the start then gives up little. Discovery is the reason to start with a monolith, so the case for one weakens as the amount left to discover shrinks.&lt;/p&gt;
&lt;p&gt;Some constraints are known on day one even when the domain is new. An organization that starts a new product with several teams already has the coordination constraint. So does a component that must sit apart for compliance or security, such as card handling kept out of the rest of the system's PCI scope. A known constraint is what the optimization exists for, so a few coarse services along those lines, each a modular monolith inside, fit that case better than one shared deployment. Discovery then happens within each service, where moving a boundary is still cheap.&lt;/p&gt;
&lt;h3 id=&quot;my-approach-domain-based-modular-monoliths&quot;&gt;My Approach: Domain-Based Modular Monoliths&lt;/h3&gt;
&lt;p&gt;Start with &lt;strong&gt;domain-based modules&lt;/strong&gt;: one modular monolith organized along natural business boundaries, which Eric Evans's domain-driven design calls bounded contexts. This gives you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Clear separation of concerns from the beginning&lt;/li&gt;
&lt;li&gt;Teams that own their domain's code, even while they share a deployment&lt;/li&gt;
&lt;li&gt;A migration path to microservices that follows existing module boundaries instead of cutting new ones&lt;/li&gt;
&lt;li&gt;Faster initial development than building distributed systems from day one&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Shopify took this route with its main codebase. Kirsten Westeinde's account of the work describes reorganizing the code by business domain, such as orders, shipping, and billing, and building tooling to check that components talk only through their public interfaces. The goal, in her words, was more modularity &quot;without increasing the number of deployment units.&quot;&lt;/p&gt;
&lt;p&gt;Five years later, Gannon McGibbon and Chris Salzberg's &quot;A Packwerk Retrospective&quot; reported that static checks from Packwerk, the boundary-checking tool Shopify later built and open-sourced, hadn't been enough. A package with all its recorded violations resolved still failed to boot when loaded on its own, because of dependencies the static analysis couldn't see. What they found trustworthy was running code, so once they got that package to boot with only its own code loaded, they added a CI step that runs its tests in isolation. Packwerk 3.0 also dropped its privacy checks, the static rule meant to keep other packages out of a package's internals.&lt;/p&gt;
&lt;p&gt;Boundaries hold when something executes them, and a folder structure or a lint rule alone tends to erode one convenient shortcut at a time. Where only static checks guard the boundaries, Tilkov's coupling warning holds. Shopify's answer was a stronger check inside the monolith, not a move to services.&lt;/p&gt;
&lt;h3 id=&quot;split-when-the-evidence-shows-up&quot;&gt;Split When the Evidence Shows Up&lt;/h3&gt;
&lt;blockquote class=&quot;pull-quote&quot;&gt;
&lt;p&gt;Split into microservices when there's actual evidence it's needed: independent scaling requirements, team coordination becoming a bottleneck, or specific technology needs that justify the operational complexity.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Each of those signals, plus one more, can be checked instead of argued:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Independent scaling&lt;/strong&gt;: one module's load differs so much from the rest that scaling the whole deployment to meet it mostly buys capacity nothing else uses&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Team coordination&lt;/strong&gt;: teams routinely wait on each other to release, or a shared test suite and deploy queue hold every change to the pace of the slowest one&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Technology needs&lt;/strong&gt;: a module needs a runtime or data store the rest of the system can't host, and the gain outweighs operating a second stack&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fault isolation&lt;/strong&gt;: one part's failures or slowdowns keep spreading to unrelated parts, and timeouts, bulkheads, or separate queues inside the deployment haven't contained them&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These signals arrive late, once the pain is present. The monolith also gives an earlier one. Track the share of merged changes that touch more than one module, and how often a change moves code or tables between modules. Watch them over several releases while feature work continues. The boundaries have probably settled when three things hold: the first number is falling, the second has dropped to near zero, and no duplicated logic or swelling module is absorbing the work. That says the seams are stable enough to plan an extraction around. It isn't a reason to extract, which still waits for one of the signals above.&lt;/p&gt;
&lt;p&gt;A signal tells you that something needs to be separated, and the module boundaries tell you where the seam already runs. Until a signal shows up, the monolith is still doing the job it was chosen for, which is teaching you where the boundaries belong.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/7589/monoliths-for-discovery-microservices-for-optimization</guid>
<pubDate>Thu, 20 Nov 2025 23:41:52 +0000</pubDate>
</item>
<item>
<title>Core Attributes of Distributed Systems: Reliability, Availability, Scalability, and More</title>
<link>https://coderlegion.com/3681/core-attributes-of-distributed-systems-reliability-availability-scalability-and-more</link>
<description>&lt;p&gt;Whether you’re building a simple web app or a large distributed system, users don’t just expect it to work → they want it to be fast, always available, secure, and to run smoothly without unexpected interruptions.&lt;/p&gt;
&lt;p&gt;These expectations are captured in what we call &lt;strong&gt;system quality attributes&lt;/strong&gt; or &lt;strong&gt;non-functional requirements&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In this article, we’ll explore the most critical attributes that any serious system should aim to deliver, especially in distributed environments. We’ll cover why each attribute matters for the users, how to measure it, and how to achieve it both proactively and reactively.&lt;/p&gt;
&lt;h3 id=&quot;reliability&quot;&gt;Reliability&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Definition: Reliability is the ability of a system to operate correctly and continuously over time, delivering accurate results without unexpected interruptions or failures.&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters&quot;&gt;Why It Matters&lt;/h4&gt;
&lt;p&gt;Users rely on your system to behave predictably. If your banking app transfers money to the wrong account or your flight booking app glitches, it erodes customer trust instantly.&lt;/p&gt;
&lt;h4 id=&quot;how-to-measure-&quot;&gt;How to Measure:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mean Time Between Failures (MTBF)&lt;/strong&gt;: Average time system runs before failing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Error rate&lt;/strong&gt;: Frequency of incorrect results (e.g., data corruption or logic bugs).&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;proactive-techniques-making-the-system-reliable-in-advance-&quot;&gt;Proactive Techniques (making the system reliable in advance)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fault prevention (Stop mistakes before they happen)&lt;/strong&gt; → Write clean code, perform code reviews, use static analysis tools.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fault removal (Find and fix mistakes early)&lt;/strong&gt;: Use automated testing, debugging, and formal verification.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;reactive-techniques-handling-faults-when-they-occur-&quot;&gt;Reactive Techniques (handling faults when they occur)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fault tolerance (Keep working despite faults)&lt;/strong&gt; → Use retries, replication/redundancy, graceful degradation, and error correction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fault detection (Spot problems quickly)&lt;/strong&gt; → Monitor logs, set up alerts, use health checks and diagnostics.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fault recovery (Fix issues promptly)&lt;/strong&gt; → Restart services, failover to backups, roll back to safe states.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;availability&quot;&gt;Availability&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Definition: Availability is the ability of a system to be up and responsive when needed, ensuring users can access it at any time. It focuses on being ready to serve, not on whether the response is correct (which is covered by reliability).&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-1&quot;&gt;Why It Matters&lt;/h4&gt;
&lt;p&gt;If your system crashes or is down during peak hours, users will leave. For mission-critical systems like trading, even seconds of downtime can be disastrous.&lt;/p&gt;
&lt;h4 id=&quot;how-to-measure&quot;&gt;How to Measure&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Uptime percentage&lt;/strong&gt; → e.g., 99.9% uptime = ~8.7 hours of downtime/year.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mean Time to Recovery (MTTR)&lt;/strong&gt;: How fast you recover from failure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;High availability (HA)&lt;/strong&gt; typically refers to uptime of 99.9% or more, achieved through redundancy and failover strategies.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;proactive-techniques&quot;&gt;Proactive Techniques&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Capacity planning&lt;/strong&gt;: Predict demand and provision enough resources.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redundant infrastructure&lt;/strong&gt;: Extra hardware or cloud zones ready to take over.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;reactive-techniques&quot;&gt;Reactive Techniques&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Failover mechanisms&lt;/strong&gt;: Automatically switch to backup nodes or servers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auto-healing&lt;/strong&gt;: Restart crashed services or containers automatically.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;scalability&quot;&gt;Scalability&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Definition: Scalability is the ability of a system to handle more users or more data by adding more resources, without significantly slowing down or crashing.&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-2&quot;&gt;Why It Matters&lt;/h4&gt;
&lt;p&gt;What works smoothly for 10 users might completely break when 10,000 people show up. If your product becomes popular, you want it to grow without falling apart.&lt;/p&gt;
&lt;h4 id=&quot;how-to-measure-1&quot;&gt;How to Measure&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Throughput&lt;/strong&gt; → How many requests per second your system can handle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Latency under load&lt;/strong&gt; → How fast your system responds when many users are active at once.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;proactive-techniques-preparing-for-growth-in-advance-&quot;&gt;Proactive Techniques (preparing for growth in advance)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Design for scalability (Build with growth in mind)&lt;/strong&gt; → Use stateless designs, modular components, and databases that can be partitioned or scaled out.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Capacity planning (Plan ahead for future load)&lt;/strong&gt; → Estimate how much traffic or data you’ll have later and make sure your system can handle it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;reactive-techniques-handling-growth-when-it-happens-&quot;&gt;Reactive Techniques (handling growth when it happens)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Auto-scaling (Add resources on the fly)&lt;/strong&gt; → Automatically spin up more servers when traffic spikes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Load balancing (Distribute work evenly)&lt;/strong&gt; → Spread incoming requests across multiple servers so no single one gets overloaded.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;maintainability&quot;&gt;Maintainability&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Definition: Maintainability is the ability of a system to be easily changed, updated, fixed, or improved over time without introducing new problems.&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-3&quot;&gt;Why It Matters&lt;/h4&gt;
&lt;p&gt;Requirements always change. Bugs appear. New features need to be added. If your system is messy or overly complex, even small changes become risky and time-consuming. A maintainable system is easy to understand, modify, and operate day to day, letting teams respond quickly and confidently to new needs.&lt;/p&gt;
&lt;h4 id=&quot;how-to-measure-2&quot;&gt;How to Measure&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mean Time to Modify (MTTM)&lt;/strong&gt; → How long it takes to make a change or add a new feature.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Code churn&lt;/strong&gt; → How frequently the code is updated or changed, which can indicate areas that are difficult to maintain or keep stable.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;proactive-techniques-making-the-system-easier-to-change-in-advance-&quot;&gt;Proactive Techniques (making the system easier to change in advance)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Modular design (Break it into manageable parts)&lt;/strong&gt; → Structure your system as small, independent components that are easier to understand, test, and replace.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simplicity (Avoid unnecessary complexity)&lt;/strong&gt; → Keep designs and code clear and straightforward to reduce errors and make it easier for new developers to pick up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clear documentation and standards (Help everyone stay aligned)&lt;/strong&gt; → Write understandable docs and follow consistent coding styles so others can safely make changes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operability considerations (Design for smooth running in production)&lt;/strong&gt; → Build clear configuration, easy deployment processes, and good monitoring hooks to simplify day-to-day management.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;reactive-techniques-improving-it-over-time-&quot;&gt;Reactive Techniques (improving it over time)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Refactoring (Clean up continuously)&lt;/strong&gt; → Regularly improve the structure of code without changing its behavior to keep it healthy and easy to work with.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automated regression tests (Prevent breaking existing features)&lt;/strong&gt; → Run tests that ensure changes don’t accidentally introduce new bugs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Incremental improvements (Make small, safe changes)&lt;/strong&gt; → Tackle technical debt gradually without big risky rewrites.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;security&quot;&gt;Security&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Definition: Security is the ability of a system to protect itself from unauthorized access, misuse, or attacks.&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-4&quot;&gt;Why It Matters&lt;/h4&gt;
&lt;p&gt;A single security breach can damage your reputation, leak sensitive data, or cause big financial losses. Attackers don’t wait for you to be ready → you have to plan ahead.&lt;/p&gt;
&lt;h4 id=&quot;how-to-measure-3&quot;&gt;How to Measure&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Time to detect and respond&lt;/strong&gt; → How quickly you can find and fix security issues.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Number of vulnerabilities over time&lt;/strong&gt; → Track how many security flaws are open and how quickly they’re closed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compliance scores&lt;/strong&gt; → Certifications like SOC2 or ISO 27001 that show your security practices meet industry standards.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;proactive-techniques-protecting-the-system-in-advance-&quot;&gt;Proactive Techniques (protecting the system in advance)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Threat modeling (Think like an attacker)&lt;/strong&gt; → Identify and fix weak points before someone exploits them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secure defaults (Build security in by default)&lt;/strong&gt; → Use encryption, strong passwords, and access controls.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security scans (Catch issues early)&lt;/strong&gt; → Run automated tools to find known vulnerabilities in your code.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;reactive-techniques-responding-when-something-goes-wrong-&quot;&gt;Reactive Techniques (responding when something goes wrong)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Intrusion detection (Spot attacks fast)&lt;/strong&gt; → Use systems that alert you to suspicious activity in real time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Incident response (Limit the damage)&lt;/strong&gt; → Apply security patches quickly and have a plan to contain and fix breaches.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;If you have any feedback on the content, suggestions for improving the organization, or topics you’d like to see covered next, feel free to share → I’d love to hear your thoughts!&lt;/p&gt;
</description>
<category>Intro to DBs</category>
<guid isPermaLink="true">https://coderlegion.com/3681/core-attributes-of-distributed-systems-reliability-availability-scalability-and-more</guid>
<pubDate>Mon, 30 Jun 2025 21:28:45 +0000</pubDate>
</item>
</channel>
</rss>