<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Simform Engineering - Medium]]></title>
        <description><![CDATA[Our Engineering blog gives an inside look at our technologies from the perspective of our engineers. - Medium]]></description>
        <link>https://medium.com/simform-engineering?source=rss----ce67e0b67c0d---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>Simform Engineering - Medium</title>
            <link>https://medium.com/simform-engineering?source=rss----ce67e0b67c0d---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 02 Jul 2026 20:54:45 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/simform-engineering" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Event-Driven Architecture with Kafka in .NET: A Modern Approach to Building Scalable Systems]]></title>
            <link>https://medium.com/simform-engineering/event-driven-architecture-with-kafka-in-net-a-modern-approach-to-building-scalable-systems-c7d56bfc0b03?source=rss----ce67e0b67c0d---4</link>
            <guid isPermaLink="false">https://medium.com/p/c7d56bfc0b03</guid>
            <category><![CDATA[event-drivenarchitecture]]></category>
            <dc:creator><![CDATA[Dharmesh Khakhkhar]]></dc:creator>
            <pubDate>Wed, 01 Jul 2026 08:58:29 GMT</pubDate>
            <atom:updated>2026-07-01T08:58:28.242Z</atom:updated>
            <content:encoded><![CDATA[<h4>Designing loosely coupled, highly scalable, and resilient distributed systems using Apache Kafka and .NET</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bQo8jAcAs-j119ASf4g3eA.png" /></figure><h3>Topic Overview — Introduction</h3><p>Modern applications often need to process <strong>millions of events in real time</strong>.<br>Traditional request-response architectures can become bottlenecks as systems grow and scale.</p><p><strong>Event-Driven Architecture</strong> addresses this challenge by allowing services to communicate <strong>asynchronously through events</strong>, enabling better scalability, flexibility, and loose coupling between components.</p><p>In this article, we will explore how to build an <strong>Event-Driven system using Apache Kafka with .NET</strong> and understand how this architecture helps create <strong>scalable and resilient applications</strong>.</p><h3>Key Features of Event-Driven Architecture / Kafka</h3><ul><li><strong>Asynchronous Communication</strong>: Services communicate via events without waiting for immediate responses.</li><li><strong>Loose Coupling</strong>: Producers and consumers do not need to know about each other.</li><li><strong>Scalability</strong>: Kafka supports horizontal scaling through partitions.</li><li><strong>High Throughput:</strong> Kafka is optimized for handling millions of messages per second.</li><li><strong>Fault Tolerance:</strong> Data is replicated across brokers to prevent data loss.</li><li><strong>Event Replay: </strong>Consumers can replay past events when needed.</li></ul><h3>Advantages of Event-Driven Systems</h3><ul><li><strong>Improved System Scalability:</strong> Services can scale independently.</li><li><strong>Better Resilience:</strong> If one service fails, others continue to operate.</li><li><strong>Real-Time Processing:</strong> Kafka enables near real-time data streaming.</li><li><strong>Flexibility in Integration:</strong> Easy to integrate new services without impacting existing ones.</li><li><strong>Better Observability:</strong> Event logs provide traceability and auditability.</li></ul><h3>Real-World Use Cases</h3><p>Event-Driven Architecture with Kafka is ideal for:</p><ul><li><strong>Order Processing Systems</strong> (E-commerce)</li><li><strong>Payment Processing Pipelines</strong></li><li><strong>Real-Time Notifications</strong></li><li><strong>Inventory Management</strong></li><li><strong>Log Aggregation Systems</strong></li><li><strong>IoT Data Streaming</strong></li><li><strong>Microservices Communication</strong></li></ul><h3><strong>What is Apache Kafka?</strong></h3><p>Apache Kafka is a distributed event streaming platform used to publish, store, and process large streams of real-time data.</p><p>It was originally developed at LinkedIn and later open-sourced under the Apache Software Foundation.</p><h3><strong>Kafka Architecture Overview</strong></h3><p>The architecture of <strong>Apache Kafka</strong> is designed to handle <strong>high-throughput, real-time data streaming</strong> with strong <strong>scalability and fault tolerance</strong>. Kafka works on a <strong>distributed publish–subscribe model</strong>, where producers send events and consumers process them asynchronously.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0tUnXpdvzUIDjkwuhXpzmQ.png" /></figure><blockquote><em>Explain : Kafka acts as the central event broker. Producers publish events to topics. Topics are split into partitions, enabling parallel processing. Consumers within the same consumer group share partitions and scale message processing horizontally.</em></blockquote><h3><strong>Topics and Partitions</strong></h3><p>Kafka organizes messages into <strong>topics</strong>, which are logical streams of events.</p><p>Each topic is divided into <strong>partitions</strong>, which allow Kafka to scale horizontally.</p><p>Benefits of partitions:</p><ul><li>Enable parallel processing</li><li>Allow consumers to process messages independently</li><li>Preserve ordering within a partition</li></ul><p>Example:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8XUq2xmaAWK2qJVAR09SXw.jpeg" /></figure><p>Kafka guarantees <strong>message order only within a partition</strong>.</p><h3><strong>Producer</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hXFXMgUNGEG0rGd6EqDtCw.jpeg" /></figure><p><strong>Kafka Producer — How It Works</strong></p><p>When your application sends an order event to Kafka, it goes through 5 simple steps:</p><ol><li><strong>Order request</strong> — your app calls producer.send() with the order data.</li><li><strong>Serialize</strong> — the order object is converted to bytes (JSON, Avro, etc.) before sending.</li><li><strong>Connect to broker</strong> — the producer routes the message to the correct partition leader using a persistent connection.</li><li><strong>Publish to topic</strong> — records are batched and sent to the Kafka topic for efficiency.</li><li><strong>Store in partition</strong> — the broker appends the record to the partition log and assigns it a unique offset.</li></ol><h3><strong>Consumer</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CFOIfrQsJD2FH9SgVpWDIg.jpeg" /></figure><p><strong>Kafka Consumer — How It Works</strong></p><p>A Kafka consumer reads events from a topic in 5 steps:</p><ol><li><strong>Join group</strong> — the consumer registers with the group coordinator and announces itself as part of a named consumer group.</li><li><strong>Partition assigned</strong> — the broker assigns specific partitions to this consumer. Each partition is owned by exactly one consumer in the group at a time.</li><li><strong>Poll broker</strong> — the consumer enters a continuous poll loop, fetching a batch of records starting from its last committed offset.</li><li><strong>Deserialize records</strong> — raw bytes are converted back into usable objects (JSON, Avro, etc.) using the configured deserializer.</li><li><strong>Process message</strong> — your business logic runs: save to a database, trigger a downstream service, update a cache, and so on.</li></ol><p>After successful processing, the consumer <strong>commits its offset</strong> — telling Kafka how far it has read. On the next poll, it picks up from there.</p><h3>Consumer Groups</h3><p>Kafka consumers read messages as part of a consumer group, allowing multiple consumer instances to share the processing workload efficiently.</p><p>Key characteristics of consumer groups include:</p><ul><li>Each partition is assigned to only one consumer within a consumer group at any given time.</li><li>Multiple consumers in the same group enable parallel processing across partitions.</li><li>This partition-to-consumer assignment ensures message ordering within a partition and prevents duplicate processing within the same group.</li><li>If a consumer fails, Kafka automatically rebalances the group and reassigns its partitions to other active consumers.</li></ul><h4>Example</h4><p>Consider a topic with four partitions and a consumer group containing two consumers. Kafka may assign partitions 0 and 1 to Consumer A, and partitions 2 and 3 to Consumer B. If Consumer A crashes, Kafka automatically reassigns its partitions to Consumer B during the rebalance process.</p><p>In our code:</p><pre>GroupId = &quot;order-consumer-group&quot;;</pre><p>All consumers using the same GroupId become members of the same consumer group and cooperate to process messages from the topic.</p><h4>Consumer Group vs GroupId</h4><p>These two terms are closely related but often confused.</p><ul><li><strong>Consumer Group</strong>: A logical group of consumers that work together to consume messages from a topic</li><li><strong>GroupId</strong>: A configuration property used to uniquely identify that consumer group in Kafka</li></ul><p>Example:</p><pre>var config = new ConsumerConfig<br>{<br> BootstrapServers = “localhost:29092”,<br> GroupId = “order-consumer-group”,<br> AutoOffsetReset = AutoOffsetReset.Earliest<br>};</pre><p>All consumers using the same GroupId belong to the <strong>same consumer group</strong>.</p><h3>Production Architecture Overview</h3><p>In a modern Event-Driven Architecture (EDA), services communicate through events rather than direct synchronous API calls. Kafka acts as the central event backbone that enables asynchronous communication between independent services.</p><p>For example, when an Order Service creates a new order, it publishes an OrderCreated event to a Kafka topic. Multiple downstream services such as Inventory, Payment, Notification, and Analytics can consume the same event independently without impacting the producer.</p><p>Benefits of this architecture include:</p><ul><li>Loose coupling between services</li><li>Independent scaling of producers and consumers</li><li>Improved fault tolerance and resilience</li><li>Better deployment flexibility</li><li>Support for real-time event processing</li></ul><p>A typical flow looks like:</p><pre>Order API<br>    |<br>    v<br>Kafka Producer<br>    |<br>    v<br>+------------------+<br>|  Order Topic     |<br>+------------------+<br>   |      |      |<br>   v      v      v<br>Inventory Payment Notification<br> Service   Service    Service</pre><p>This approach allows organizations to build scalable distributed systems where services evolve independently while maintaining reliable communication.</p><h3>Message Keys for Ordered Processing</h3><p>This is <strong>important for real-world systems</strong>.</p><p>Add to Producer Code</p><pre>await _producer.ProduceAsync(&quot;orders&quot;, new Message&lt;string, string&gt;<br>{<br>    Key = order.OrderId.ToString(),<br>    Value = JsonSerializer.Serialize(order)<br>});</pre><p>Explain:</p><blockquote><em>By using </em><em>OrderId as the message key, Kafka ensures that all events for the same order are routed to the same partition, preserving order.</em></blockquote><h3><strong>Offset Behavior Section</strong></h3><p>Kafka tracks message position using <strong>offsets</strong>.</p><p>Each message within a partition has a unique offset number.</p><p>Example:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MOvg2BJSBoz9Vsjp6_3zCw.jpeg" /></figure><p>Consumers store offsets to know where to resume processing.</p><p>Offsets represent positions within a partition log. They are not globally unique message identifiers and are only meaningful within a specific partition.</p><p>Consumer Configuration:</p><pre>AutoOffsetReset = AutoOffsetReset.Earliest</pre><p>Use cases:</p><p>• <strong>Earliest</strong> → replay events<br>• <strong>Latest</strong> → real-time processing</p><h3><strong>Graceful Shutdown</strong></h3><p>Add to consumer code:</p><pre>Console.CancelKeyPress += (_, e) =&gt;<br>{<br>    e.Cancel = true;<br>    consumer.Close();<br>};</pre><p>Explain:</p><blockquote><em>Graceful shutdown ensures that the consumer commits offsets before stopping, preventing duplicate message processing.</em></blockquote><h3><strong>Dead Letter Queue (DLQ)</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FYZTxnIDrjZR-lKEifMpww.jpeg" /></figure><p><strong>Dead Letter Queue (DLQ) — Your Kafka Safety Net</strong></p><p>A Dead Letter Queue is a special Kafka topic that catches messages your consumer failed to process — even after retrying. Instead of losing the message silently or blocking the entire partition, you route it to a DLQ and keep moving.</p><p>DLQs should be actively monitored. Failed messages should be reviewed, replayed, or resolved through operational processes to prevent silent message loss.</p><p><strong>How it works:</strong></p><ol><li>Consumer receives a message from orders topic and tries to process it.</li><li>Processing fails (bad data, downstream service down, schema mismatch, etc.).</li><li>Consumer retries up to a configured limit (e.g. 3 attempts).</li><li>Retries exhausted → message is published to orders.DLQ along with error metadata (original topic, partition, offset, exception, retry count).</li><li>Your team can then <strong>inspect</strong> the failure, <strong>replay</strong> the message once the bug is fixed, <strong>alert</strong> on-call, or <strong>discard</strong> if the message is genuinely invalid.</li></ol><p><strong>Why it matters:</strong></p><ul><li>No message is silently dropped — every failure is observable.</li><li>The main consumer keeps moving — one bad message doesn’t block thousands of good ones.</li><li>Replay is safe — fix the bug, re-publish from the DLQ, done.</li></ul><p><strong>Naming convention:</strong> orders.DLQ or orders.dead-letter — one DLQ per source topic is the common pattern.</p><p><strong>Monitor it:</strong> Set an alert if DLQ message count grows — it means something in your pipeline is broken.</p><h3><strong>Idempotent producer</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HQeST-C5lR5WaW5CBXhoJQ.jpeg" /></figure><p><strong>Idempotent Producer — Exactly-Once, No Duplicates</strong></p><p>Idempotent producers prevent duplicate writes caused by producer retries but do not provide end-to-end exactly-once processing guarantees.</p><p>Without idempotence, a network hiccup causes a silent bug: the broker writes a message, the ack gets lost, the producer retries — and now the same message exists twice in the partition. Your order gets processed twice. Your invoice gets sent twice.</p><p><strong>The idempotent producer fixes this with two simple things:</strong></p><ul><li><strong>Producer ID (PID)</strong> — the broker assigns a unique ID to each producer instance on first connect.</li><li><strong>Sequence number</strong> — the producer stamps every message with a per-partition counter that increments with each send.</li></ul><p>When a retry arrives with the same PID + sequence number, the broker recognises it as a duplicate and silently discards it. Your consumer sees the message exactly once.</p><p><strong>How to enable it:</strong></p><p>properties</p><pre>enable.idempotence=true</pre><p>That single setting automatically enforces acks=all and retries=MAX_INT — the safest defaults.</p><p><strong>What it covers and what it doesn’t:</strong></p><ul><li>Exactly-once within a single partition — guaranteed.</li><li>Exactly-once across multiple partitions or topics — you need the <strong>Kafka Transactions API</strong> (producer.beginTransaction() / commitTransaction()).</li></ul><p><strong>Overhead:</strong> Nearly zero. The broker only tracks the last 5 sequence numbers per producer per partition.</p><p><strong>Bottom line:</strong> Always enable idempotence for any production producer writing critical data. It costs nothing and eliminates an entire class of subtle data bugs.</p><h3><strong>Schema Registry</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pUQbHSSI0X_-7z7VIdr0xQ.jpeg" /></figure><p><strong>Schema Registry — The Contract Between Producers and Consumers</strong></p><p>In Kafka, messages are just bytes. Without a schema, nothing stops a producer from changing its payload structure and silently breaking every consumer downstream. Schema Registry solves this.</p><p><strong>How it works:</strong></p><ol><li><strong>Producer registers the schema</strong> (Avro, Protobuf, or JSON Schema) with the registry on first use. The registry returns a schema ID.</li><li><strong>Each message carries only the schema ID</strong> (4 bytes) in its header — not the full schema. This keeps messages tiny.</li><li><strong>Consumer reads the schema ID</strong>, fetches the full schema from the registry (cached after first fetch), and deserializes the bytes correctly.</li></ol><p><strong>Schema Evolution — Compatibility Modes:</strong></p><p>ModeWhat it meansBACKWARD (default)New schema can read old messages — safe to add optional fieldsFORWARDOld schema can read new messages — safe to remove optional fieldsFULLBoth directions — safest, most restrictiveNONENo checks — use only in development</p><p><strong>Why it matters:</strong></p><ul><li>Breaking schema changes are <strong>rejected at publish time</strong> — not discovered when consumers crash in production.</li><li>The schema is stored once, referenced by ID — <strong>smaller messages</strong>, faster serialization.</li><li>Producers and consumers are <strong>decoupled from each other</strong> but coupled to the contract — the right tradeoff.</li></ul><p><strong>Popular implementations:</strong> Confluent Schema Registry, AWS Glue Schema Registry, Apicurio Registry.</p><p><strong>Bottom line:</strong> If you’re using Kafka in production with more than one team touching the same topic, Schema Registry is essential — it’s the API contract for your event streams.</p><h3><strong>Partitioning Strategy</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/680/1*jnTn4OP8RiUTH5ugHzo1QQ.jpeg" /></figure><p><strong>Kafka Partitioning Strategy — Choosing the Right One</strong></p><p>Partitioning decides <em>which partition</em> a message lands in. The right strategy depends on whether you need ordering, throughput, or custom routing.</p><p><strong>1. Key-based (default when key is set)</strong> hash(key) % numPartitions — same key always goes to the same partition. Guarantees ordering for all events sharing a key (e.g. all events for order-123 arrive in sequence). Best for: orders, user activity, sessions.</p><p><strong>2. Round-robin (no key set, pre-Kafka 2.4)</strong> Messages spread evenly across all partitions one at a time. Maximum load balance, zero ordering guarantee. Best for: logs, metrics, analytics where order doesn’t matter.</p><p><strong>3. Sticky (default no-key, Kafka 2.4+)</strong> Fills one partition’s batch completely before switching to the next. Produces fewer, larger batches — significantly better throughput than round-robin with the same no-ordering tradeoff. Best for: high-volume keyless workloads.</p><p><strong>4. Custom partitioner</strong> Implement the Partitioner interface and return any partition number you want. Use it for VIP user routing, geo-based lanes, or priority queues (critical vs. background jobs on separate partitions).</p><p><strong>Hot partition — the silent killer</strong> If a single key generates far more traffic than others, one partition absorbs all that load while others sit idle. The fix: append a random salt suffix to the key (user_id + &quot;-&quot; + random(0, N)) to spread the load — at the cost of losing strict per-user ordering.</p><p><strong>Rule of thumb:</strong> Use key-based when order matters. Use sticky when it doesn’t and throughput does. Use custom only when your routing logic can’t be expressed as a key hash.</p><h3><strong>Batch Processing</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/680/1*2YnA5lysqLieuLTJ4Se_xQ.jpeg" /></figure><p><strong>Kafka Batch Processing — How Records Are Grouped and Sent</strong></p><p>Kafka doesn’t send one message per network request. Instead, the producer groups records into batches before dispatching them — this is the primary reason Kafka can handle millions of messages per second.</p><p><strong>How it works:</strong></p><p><strong>1. RecordAccumulator buffers records</strong> Every call to producer.send() places the record into an in-memory buffer called the RecordAccumulator — organized per (topic, partition) pair. Records sit here until the batch is ready to flush.</p><p><strong>2. Two flush triggers</strong> A batch is sent when either condition is met first:</p><ul><li>batch.size — the batch has accumulated enough bytes (default 16 KB). Send immediately.</li><li>linger.ms — the wait timer has expired (default 0 ms). Send whatever is buffered, even if the batch isn&#39;t full.</li></ul><p><strong>3. Sender thread compresses and dispatches</strong> A background Sender thread drains ready batches, optionally compresses them, and sends one network request to the broker carrying all records in the batch.</p><p><strong>4. Broker appends the whole batch</strong> The broker writes the entire batch to the partition log in a single operation — far more efficient than writing records one at a time.</p><p><strong>Tuning tradeoffs:</strong></p><p>GoalSettingsLow latencylinger.ms=0, small batch.sizeHigh throughputlinger.ms=5–20, batch.size=64–512KBReduced I/Ocompression.type=lz4 or zstd</p><p><strong>Rule of thumb:</strong> For real-time pipelines, keep linger.ms=0. For analytics or log pipelines where a few milliseconds don&#39;t matter, bumping linger.ms to 5–20ms can double or triple your throughput with zero code changes.</p><h3><strong>Health Check and monitoring</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JMYKIMUpE1E5AuLROQEM4A.png" /></figure><p><strong>Kafka Health Check &amp; Monitoring — What to Watch</strong></p><p>Kafka exposes hundreds of metrics via JMX. These are the ones that actually matter in production.</p><p><strong>Broker Health — The Non-Negotiables</strong></p><p>MetricHealthy valueActiveControllerCountMust be exactly <strong>1</strong> — 0 means no controller, cluster is brokenUnderReplicatedPartitionsMust be <strong>0</strong> — any value means data isn&#39;t fully replicatedOfflinePartitionsCountMust be <strong>0</strong> — offline partitions mean data is unavailableDisk / CPU / NetworkWatch for saturation trends</p><p><strong>Producer Metrics</strong></p><ul><li>record-error-rate — should always be 0. Any errors mean messages are being dropped.</li><li>request-latency-avg — how long the broker takes to ack. Alert if consistently above 100ms.</li><li>record-queue-time-avg — time records spend waiting in the accumulator. A rising trend means the producer is falling behind.</li></ul><p><strong>Consumer Lag — Your Most Important Metric</strong></p><p>Consumer lag = log-end-offset − committed-offset. It tells you how far behind your consumers are.</p><ul><li>A stable lag is fine. A <strong>growing</strong> lag means consumers can’t keep up with producers — scale up consumers or optimize processing.</li><li>Track records-lag-max per partition and records-lag-avg per consumer group.</li><li>If lag exceeds your retention window, consumers will start losing messages.</li></ul><p><strong>Topic Metrics</strong></p><ul><li>MessagesInPerSec — write throughput per topic.</li><li>BytesIn/OutPerSec — bandwidth usage, useful for capacity planning.</li><li>LogEndOffset growth rate — how fast data is being written.</li></ul><p><strong>Alert Thresholds</strong></p><ul><li><strong>Page immediately:</strong> OfflinePartitions &gt; 0, UnderReplicatedPartitions &gt; 0, ActiveController != 1</li><li><strong>Warn:</strong> Consumer lag growing, record-error-rate &gt; 0, disk above 80%</li></ul><p><strong>Recommended tools:</strong> Prometheus + Grafana (open source), Confluent Control Center (managed), Datadog or New Relic for full-stack observability.</p><h3>Error Handling</h3><h4>Error Handling Strategy</h4><p>Production systems must handle failures gracefully.</p><h4>Producer Errors</h4><p>Handle Kafka publish failures:</p><pre>try<br>{<br>    await producer.ProduceAsync(topic, message);<br>}<br>catch (ProduceException&lt;string,string&gt; ex)<br>{<br>    Console.WriteLine($&quot;Kafka publish error: {ex.Error.Reason}&quot;);<br>}</pre><h4>Consumer Errors</h4><p>Possible failures:</p><p>• Deserialization errors<br>• Business logic failures<br>• External API failures</p><p>Best practice:</p><p>Use <strong>Dead Letter Topics (DLT)</strong> for failed messages.</p><p>Example:<br> An Order Service publishes an OrderCreated event.</p><ul><li>Payment Service consumes it</li><li>Inventory Service consumes it</li><li>Notification Service consumes it</li></ul><p>All independently.</p><h3><strong>Coding Example Architecture Diagram</strong></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DYU90ZFsGyOBwtJrJvDv3A.png" /></figure><blockquote><em>Explain:</em></blockquote><blockquote><em>In this demo:</em></blockquote><blockquote><em>• Order API acts as the </em><strong><em>event producer</em></strong><em><br> • Kafka stores the event in the </em><strong><em>orders topic</em></strong><em><br> • Consumer service processes events asynchronously</em></blockquote><h3>Example Code</h3><p>Let’s build a simple example:</p><p><strong>Scenario:</strong><br> An Order API publishes an event to Kafka.<br> A Background Worker consumes that event.</p><h3>Prerequisites</h3><ul><li>.NET 10+ SDK installed</li><li>Kafka installed locally or via Docker</li><li>Basic knowledge of ASP.NET Core</li><li>Visual Studio / VS Code</li></ul><p>To run Kafka using Docker:</p><pre>docker run -p 29092:29092 apache/kafka</pre><p>Required NuGet Packages</p><p>Install:</p><pre>dotnet add package Confluent.Kafka</pre><p>Package Used:</p><ul><li>Confluent.Kafka</li></ul><h3>Configuration</h3><p>Add in appsettings.json:</p><pre>{<br>  &quot;Kafka&quot;: {<br>    &quot;BootstrapServers&quot;: &quot;localhost:29092&quot;,<br>    &quot;Topic&quot;: &quot;order-events&quot;,<br>    &quot;GroupId&quot;: &quot;order-consumer-group&quot;<br>  }<br>}</pre><p><strong>Code Examples</strong></p><ol><li>Create Order Event Model</li></ol><pre>public class OrderCreatedEvent<br>{<br>    public string OrderId { get; set; }<br>    public string ProductName { get; set; }<br>    public double Price { get; set; }<br>}</pre><p>2. Kafka Producer Service</p><pre>using Confluent.Kafka;<br>using System.Text.Json;<br><br>public class KafkaProducer<br>{<br>    private readonly IProducer&lt;Null, string&gt; _producer;<br>    private readonly string _topic;<br><br>    public KafkaProducer(IConfiguration configuration)<br>    {<br>        var config = new ProducerConfig<br>        {<br>            BootstrapServers = configuration[&quot;Kafka:BootstrapServers&quot;],<br>            Acks = Acks.All,<br>            EnableIdempotence = true,<br>            MessageSendMaxRetries = int.MaxValue,<br><br>            CompressionType = CompressionType.Zstd,<br>            LingerMs = 5,<br>            BatchSize = 64 * 1024<br><br>        };<br><br>        _producer = new ProducerBuilder&lt;Null, string&gt;(config).Build();<br>        _topic = configuration[&quot;Kafka:Topic&quot;];<br>    }<br><br>    public async Task ProduceAsync(OrderCreatedEvent orderEvent)<br>    {<br>        var message = JsonSerializer.Serialize(orderEvent);<br><br>        await _producer.ProduceAsync(_topic, new Message&lt;Null, string&gt;<br>        {<br>            Value = message<br>        });<br>    }<br>}</pre><h3>Explain each setting</h3><h4>Acks = All</h4><p>Producer waits until all replicas confirm.</p><pre>Producer<br>   ↓<br>Leader Broker<br>   ↓<br>Replica 1<br>Replica 2</pre><p>Safer but slightly slower.</p><h4>EnableIdempotence = true</h4><p>Prevents duplicate messages when retries happen.</p><p>Without:</p><pre>Send OrderCreated<br>↓<br>Network timeout<br>↓<br>Retry<br>↓<br>Duplicate event</pre><p>With idempotence:</p><p>Kafka stores only one copy.</p><h4>CompressionType</h4><p>Reduces network traffic.</p><pre>100 MB<br> ↓<br>20 MB</pre><p>Faster and cheaper.</p><h4>LingerMs</h4><p>Waits a few milliseconds before sending.</p><p>Instead of:</p><pre>1 message<br>1 request<br><br>1 message<br>1 request</pre><p>Kafka batches:</p><pre>100 messages<br>1 request</pre><p>3. API Controller</p><pre>[ApiController]<br>[Route(&quot;api/[controller]&quot;)]<br>public class OrdersController : ControllerBase<br>{<br>    private readonly KafkaProducer _producer;<br><br>    public OrdersController(KafkaProducer producer)<br>    {<br>        _producer = producer;<br>    }<br><br>    [HttpPost]<br>    public async Task&lt;IActionResult&gt; CreateOrder(OrderCreatedEvent order)<br>    {<br>        await _producer.ProduceAsync(order);<br>        return Ok(&quot;Order event published successfully&quot;);<br>    }<br>}</pre><p>4. Kafka Consumer (Background Service)</p><pre>using Confluent.Kafka;<br>using System.Text.Json;<br><br>public class KafkaConsumer : BackgroundService<br>{<br>    private readonly IConfiguration _configuration;<br><br>    public KafkaConsumer(IConfiguration configuration)<br>    {<br>        _configuration = configuration;<br>    }<br><br>    protected override Task ExecuteAsync(CancellationToken stoppingToken)<br>    {<br>        var config = new ConsumerConfig<br>        {<br>            BootstrapServers = _configuration[&quot;Kafka:BootstrapServers&quot;],<br>            GroupId = _configuration[&quot;Kafka:GroupId&quot;],<br>            AutoOffsetReset = AutoOffsetReset.Earliest<br>        };<br><br>        var consumer = new ConsumerBuilder&lt;Ignore, string&gt;(config).Build();<br>        consumer.Subscribe(_configuration[&quot;Kafka:Topic&quot;]);<br><br>        return Task.Run(() =&gt;<br>        {<br>            while (!stoppingToken.IsCancellationRequested)<br>            {<br>                var result = consumer.Consume(stoppingToken);<br>                var orderEvent = JsonSerializer.Deserialize&lt;OrderCreatedEvent&gt;(result.Message.Value);<br><br>                Console.WriteLine($&quot;Order Received: {orderEvent.OrderId}&quot;);<br>            }<br>        }, stoppingToken);<br>    }<br>}</pre><p>5. Register Services in Program.cs</p><pre>builder.Services.AddSingleton&lt;KafkaProducer&gt;();<br>builder.Services.AddHostedService&lt;KafkaConsumer&gt;();</pre><p>How It Works</p><ul><li>Client calls POST /api/orders</li><li>API publishes event to Kafka topic</li><li>Kafka stores event in partition</li><li>Consumer reads event</li><li>Business logic executes independently</li></ul><p><strong>Example Output</strong></p><p>After running the ASP.NET Core API and Kafka consumer, we send a POST request:</p><pre>POST /api/orders</pre><p>Request Body:</p><pre>{<br>  &quot;orderId&quot;: &quot;ORD-1001&quot;,<br>  &quot;productName&quot;: &quot;Laptop&quot;,<br>  &quot;price&quot;: 75000<br>}</pre><p>The following output confirms successful event publishing and consumption.</p><figure><img alt="output of kafka" src="https://cdn-images-1.medium.com/max/1024/1*V5l0lGZudxDoCkfVA9acuA.png" /></figure><p>API Response</p><pre>Order event published successfully</pre><p>Consumer Console Output</p><pre>Order Received: ORD-1001<br>Product: Laptop<br>Price: 75000</pre><p>This confirms:</p><ul><li>Event was published</li><li>Kafka stored the message</li><li>Consumer processed the event successfully</li></ul><p>All asynchronously. No direct service-to-service blocking.</p><blockquote><em>The complete project is available on this </em><a href="https://github.com/dotnet-simformsolutions/dotnet-kafka-event-driven-architecture"><strong><em>GitHub repository.</em></strong></a></blockquote><h3>Final Conclusion</h3><p>Event-Driven Architecture with Kafka allows .NET applications to become more scalable, resilient, and loosely coupled.</p><p>By using Kafka as the event streaming platform, services can communicate asynchronously and handle massive workloads without tight dependencies.</p><p>As modern systems grow more distributed, adopting event-driven patterns becomes essential for building reliable and scalable applications.</p><p>Event-driven architectures require more than integrating Kafka into an application — they depend on thoughtful system design, resilient messaging patterns, and operational best practices. <a href="https://www.simform.com/">Simform </a>helps organizations build scalable event-driven platforms that improve resilience, throughput, and long-term maintainability across distributed systems.</p><blockquote><strong><em>For more updates on the latest development trends, follow the </em></strong><a href="https://medium.com/simform-engineering"><strong><em>Simform Engineering</em></strong></a><strong><em> blog.</em></strong></blockquote><blockquote><strong><em>Follow Us: </em></strong><a href="https://twitter.com/simform"><strong><em>Twitter</em></strong></a><strong><em> | </em></strong><a href="https://www.linkedin.com/company/simform/"><strong><em>LinkedIn</em></strong></a></blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c7d56bfc0b03" width="1" height="1" alt=""><hr><p><a href="https://medium.com/simform-engineering/event-driven-architecture-with-kafka-in-net-a-modern-approach-to-building-scalable-systems-c7d56bfc0b03">Event-Driven Architecture with Kafka in .NET: A Modern Approach to Building Scalable Systems</a> was originally published in <a href="https://medium.com/simform-engineering">Simform Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Mastering Agent Handoffs in Copilot: Build Powerful Multi-Agent Workflows]]></title>
            <link>https://medium.com/simform-engineering/mastering-agent-handoffs-in-copilot-build-powerful-multi-agent-workflows-b34f726f86ae?source=rss----ce67e0b67c0d---4</link>
            <guid isPermaLink="false">https://medium.com/p/b34f726f86ae</guid>
            <category><![CDATA[github-copilot]]></category>
            <category><![CDATA[multi-agent-systems]]></category>
            <category><![CDATA[workflow-automation]]></category>
            <category><![CDATA[agent-handoff]]></category>
            <category><![CDATA[ai-agents-in-action]]></category>
            <dc:creator><![CDATA[Vanshita Shah]]></dc:creator>
            <pubDate>Mon, 29 Jun 2026 11:05:21 GMT</pubDate>
            <atom:updated>2026-06-29T11:05:20.091Z</atom:updated>
            <content:encoded><![CDATA[<p>Create specialized agents and seamless handoffs to make Copilot operate like a real development team.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-O3KL9PiPqBboSJlcfW0Hg.png" /><figcaption><strong>Agent Handoff in Github Copilot</strong></figcaption></figure><h3><strong>Overview: Why One AI Agent Is No Longer Enough?</strong></h3><blockquote>One agent can plan.<br> Another can research.<br> A third can execute.</blockquote><p>Building a feature rarely involves just one kind of work. You might start by designing the architecture, move on to implementation, and then finish with code reviews, testing, performance checks, or security validation. Each stage requires a different mindset: planner, builder, reviewer.</p><p>Traditionally, developers switch between tools, prompts, and contexts to handle each step manually. But what if Copilot could work more like a real engineering team?</p><p>Imagine <strong><em>specialized AI agents</em></strong> taking ownership at different stages of the workflow: one planning the system design, another implementing the feature, and another reviewing the final code; all while seamlessly sharing context with each other.</p><p><strong>That’s the power of agent handoffs in VS Code Copilot.</strong></p><p>In this comprehensive guide, you’ll learn what agent handoffs are, how to build them using YAML configuration, the difference between handoffs and subagent orchestration, and best practices for production use.</p><h3>Benefits of Specialized Agents + Handoffs vs One Giant Agent</h3><p>While a single giant agent may feel quicker initially, the specialized handoff approach reduces total time by minimizing rework.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nLycsEC7j9MdMM0yi5eOsw.png" /><figcaption><strong>Agents + Handoffs vs One Giant Agent</strong></figcaption></figure><p><strong>Summary:</strong> <strong>When to Use Which?<br></strong>Use <strong>One Giant Agent</strong> for simple, quick, one-off tasks where speed is the priority and model choice doesn’t matter much.</p><p>Use <strong>Specialized Agents + Handoffs</strong> for structured, multi-step, production-grade workflows. This approach shines when you want:</p><ul><li>Higher quality and consistency</li><li>Better cost-efficiency through <strong>per-step model selection</strong> (e.g., fast &amp; cheap models like Claude Haiku for Types Agent, and more powerful models like Claude Opus for Implementation Agent)</li><li>Clear human oversight between phases</li></ul><h3>Understanding Agent Handoffs</h3><p><strong>Handoffs</strong> let one agent suggest or automatically transition to another specialized agent after completing its part of the task.</p><p>Instead of one giant agent trying to do everything, you break complex work into focused phases. After an agent finishes, Copilot shows <strong>prominent buttons</strong> that let the user:</p><ul><li>Switch to the next agent</li><li>Carry over the full conversation context</li><li>Pre-fill a prompt (and optionally auto-send it)</li></ul><h3>Understanding YAML Frontmatter in Custom Agents</h3><p>Every custom agent begins with YAML frontmatter, the configuration layer that defines how the agent behaves, what responsibilities it handles, and how it participates in your workflow.</p><pre>---<br>description: &quot;Short description shown as placeholder in chat if hint is not there&quot;<br>name: &quot;Types Agent&quot;                      # Display name in agent picker<br>model: &quot;model-name&quot;                      # Single model or array for fallbacks<br>tools: [read, edit, execute, search]     # Allowed tools only<br>argument-hint: &quot;Describe the feature you want to build: Placeholder&quot;<br><br>user-invocable: true                     # Show in agent selector<br><br>handoffs:                                # Array defines all available handoff destinations from the current agent<br>  - label: Build Service Layer<br>    agent:  Service Agent<br>    prompt: &quot;Use the generated types and module context above to implement typed API service functions that match this codebase conventions.&quot;<br>    send: true<br>  - label: &quot;Start Implementation&quot;        # Button text<br>    agent: &quot;Implementation Agent&quot;        # Target agent name (exact match)<br>    prompt: &quot;Use the service functions and module context above to implement the requested UI (page or component) and wire data flow end-to-end.&quot;<br>    send: false                          # Auto-send the prompt? (true/false)<br>---</pre><p>📌 <strong>Important Handoff Properties:</strong></p><p>🔸<strong>label</strong>: Clear, action-oriented button text</p><p>🔸<strong>agent</strong>: Exact name of the target agent</p><p>🔸<strong>prompt</strong>: Pre-filled message passed to the next agent</p><p>🔸<strong>send</strong>: Set to true for seamless auto-transition (use carefully)</p><p>The rest of the .agent.md contains detailed Markdown instructions that act as the agent’s system prompt.</p><h3><strong>Real-World Example: The Three-Agent Feature Development Pipeline</strong></h3><p>Build a complete feature from API specification to working React component, with each agent specialized for one task.</p><p><strong>Stage </strong>1️⃣<strong>: Types Agent (Intake &amp; Validation)</strong></p><p><strong>File:</strong>.github/agents/types-agent.agent.md</p><pre>---<br>description: &quot;Step 1 of handoff flow: validate API URL + sample JSON, infer module name, and generate TypeScript types for the module.&quot;<br>name: &quot;Types Agent&quot;<br>model: &quot;Claude Haiku 4.5&quot;<br>tools: [read, edit, execute]<br>argument-hint: &quot;Paste API endpoint URL and sample JSON response&quot;<br>handoffs:<br>- label: Build Service Layer<br>agent: Service Agent<br>prompt: &quot;Use the generated types and module context above to implement typed API service functions that match this codebase conventions.&quot;<br>send: true<br>---<br><br># You are a TypeScript types specialist… ( Add task details and specifications for types agent )<br></pre><p><strong>What it does:</strong></p><ul><li>Input: API endpoint URL + sample JSON response</li><li>Output: TypeScript interfaces (e.g., IPost, PostCreateInput) written to src/types/[module]/index.ts</li></ul><p><strong>✅ </strong>When the Types Agent finishes, a <strong>”Build Service Layer”</strong> button appears at the bottom of the response.</p><p>Click it. Because send: true is set in the Types Agent&#39;s YAML, the prompt is automatically submitted - no manual input needed. The Service Agent starts immediately.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/847/1*E7MTTbnwKHw2yeUoooBJtQ.png" /><figcaption><strong>Types agent outcome</strong></figcaption></figure><p><strong>Stage </strong>2️⃣<strong>: Service Agent (API Integration)</strong></p><p><strong>File:</strong> .github/agents/service-agent.agent.md</p><pre>---<br>description: &quot;Step 2: Generate typed API service functions&quot;<br>name: &quot;Service Agent&quot;<br>model: [&quot;GPT-5.3-Codex (copilot)&quot;, &quot;Gemini 3.1 Pro (Preview) (copilot)&quot;]<br>tools: [read, edit, execute]<br>handoffs:<br>  - label: Implement UI<br>    agent: Implementation Agent<br>    prompt: &quot;Use the service functions and module context above to implement the requested UI (page or component) and wire data flow end-to-end.&quot;<br>    send: false<br>---<br><br># You are a TypeScript API service specialist...</pre><p><strong>What it does:</strong></p><ul><li>Input: Generated TypeScript types from Types Agent</li><li>Output: Typed CRUD service functions using Axios + endpoint constants, written to src/services/[module]/moduleApi.ts</li></ul><p><strong>✅ </strong>When the Service Agent finishes, an <strong>“Implement UI”</strong> button appears at the bottom of the response.</p><p>Click it. Because send: false is set in the Service Agent&#39;s YAML, the prompt is pre-filled into the input field, but not submitted. Review it, then manually click Send (or press Enter).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/835/1*16Dr8Ilt5LlgQKGcn1MUfQ.png" /><figcaption><strong>Service agent outcome</strong></figcaption></figure><p><strong>Stage </strong>3️⃣<strong>: Implementation Agent (UI Assembly)</strong></p><p><strong>File: </strong>.github/agents/implementation-agent.agent.md</p><pre>---<br>description: &quot;Step 3: Build React page or component with React Query hooks, wired to the service layer end-to-end.&quot;<br>name: &quot;Implementation Agent&quot;<br>model: [&quot;Claude Sonnet 4.5 (copilot)&quot;, &quot;GPT-5.3-Codex (copilot)&quot;]<br>tools: [read, edit, execute]<br>---<br><br># You are the Implementation Agent...<br></pre><p>🧩<strong>What it does:</strong></p><ul><li>Input: Service functions + module context from Service Agent</li><li>Output: React page/component with React Query hooks (useQuery, useMutation), written to src/pages/[module]/ or src/components/[module]/</li></ul><h3>Subagents Orchestration vs Handoffs</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WpC7K5CLlOHGSFCpK2CZrw.png" /><figcaption><strong>Subagents Orchestration vs Handoffs</strong></figcaption></figure><p><strong>When to use which?</strong></p><ul><li>Use <strong>Handoffs</strong> when you want structured phases and human review gates.</li><li>Use <strong>Subagents</strong> when you want one “manager” agent to orchestrate work behind the scenes.</li></ul><p>You can also combine both approaches in advanced setups.</p><blockquote><strong><em>You can access the agent files using the following link: <br></em></strong><a href="https://github.com/vanshitaa-shah/agent-handoff/tree/master/.github/agents"><strong><em>GitHub Repository </em></strong></a><strong><em><br></em></strong><a href="https://github.com/vanshitaa-shah/agent-handoff/blob/master/README.md"><strong><em>Documentation</em></strong></a></blockquote><h3>Conclusion</h3><p><strong>Custom agents and handoffs</strong> transform Copilot from a generic coding assistant into a <strong>structured development</strong> teammate that can follow your team’s architecture, conventions, and workflow patterns.</p><p>The three-agent API pipeline shared in this guide is production-ready and easily extensible. You can introduce additional specialists later, such as Testing, Security, Documentation, or Review agents, depending on your workflow needs.</p><p><strong>Next Steps</strong></p><ol><li>Copy the three agents into your repository</li><li>Customize the agent instructions to match your project conventions</li><li>Try the workflow on your next API integration or feature implementation</li></ol><p>As AI-assisted development becomes part of modern engineering workflows, organizations need more than isolated coding assistants. They need well-defined development practices that combine automation, governance, and engineering standards. Simform helps teams design and implement AI-powered software development workflows that improve productivity while maintaining code quality, security, and architectural consistency.</p><blockquote><strong><em>For more updates on the latest tools and technologies, follow the </em></strong><a href="https://medium.com/simform-engineering"><strong><em>Simform Engineering</em></strong></a><strong><em> blog.</em></strong></blockquote><blockquote><strong><em>Follow Us: </em></strong><a href="https://twitter.com/simform"><strong><em>Twitter</em></strong></a><strong><em> | </em></strong><a href="https://www.linkedin.com/company/simform/"><strong><em>LinkedIn</em></strong></a></blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b34f726f86ae" width="1" height="1" alt=""><hr><p><a href="https://medium.com/simform-engineering/mastering-agent-handoffs-in-copilot-build-powerful-multi-agent-workflows-b34f726f86ae">Mastering Agent Handoffs in Copilot: Build Powerful Multi-Agent Workflows</a> was originally published in <a href="https://medium.com/simform-engineering">Simform Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Dynamic Islands Architecture with Astro: Hydration Reimagined]]></title>
            <link>https://medium.com/simform-engineering/dynamic-islands-architecture-with-astro-hydration-reimagined-cf6ffb6220a9?source=rss----ce67e0b67c0d---4</link>
            <guid isPermaLink="false">https://medium.com/p/cf6ffb6220a9</guid>
            <dc:creator><![CDATA[Ritesh Adwani ]]></dc:creator>
            <pubDate>Mon, 29 Jun 2026 11:00:15 GMT</pubDate>
            <atom:updated>2026-06-29T11:00:14.370Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>Great apps don’t hydrate more. They hydrate right.</em></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XOLls7PDbI9aTbtfxPnQOA.png" /></figure><p>Ever opened a website that looked ready… but wasn’t?</p><p>You click a button. Nothing happens.</p><p>You click again. Still nothing.</p><p>Then suddenly - everything wakes up at once like the browser just remembered it has responsibilities.</p><p>Modern frontend applications have quietly normalized this experience.</p><p>The page appears instantly, but underneath the surface, the browser is still downloading JavaScript bundles, parsing framework runtimes, reconstructing component trees, and hydrating an entire application just so a search bar and a dark mode toggle can function.</p><p>Somewhere along the way, we collectively normalized shipping JavaScript to parts of the page that never truly needed it.</p><p>And honestly? That’s a little insane.</p><p>For years, frontend development optimized heavily for developer experience.</p><ul><li>Reusable components</li><li>Client-side routing</li><li>Reactive state</li><li>SPA architectures</li></ul><p>And to be clear - none of these things are bad. Frameworks like React fundamentally changed frontend engineering for the better.</p><p>But there’s a tradeoff we stopped questioning.</p><p><em>Why are we sending JavaScript for parts of the page that were never interactive to begin with?</em></p><p>That question sits at the heart of Astro’s Islands Architecture. And once you see it, it becomes very difficult to unsee.</p><h3>The Web Accidentally Became a JavaScript Application</h3><p>There was a time when websites mostly shipped HTML. The browser received a document. The document rendered. Users interacted with it.</p><p>Life was simple.</p><p>What’s interesting is that the web didn’t become JavaScript-heavy overnight. We got here through a series of decisions that were, for the most part, entirely reasonable.</p><p>Applications became richer.<br>User expectations increased.<br>Interfaces became more dynamic.</p><p>Frameworks evolved to keep up.</p><p>Then SPAs happened. Suddenly, the browser wasn’t just rendering pages anymore - it was booting applications.</p><p>Modern frameworks started shipping things like:</p><ul><li>Virtual DOM runtimes</li><li>Hydration logic</li><li>Client-side routers</li><li>State managers</li><li>Reactive systems</li><li>Component trees</li></ul><p>Even for pages that were mostly static.</p><ul><li>A blog post</li><li>A documentation page</li><li>A marketing website</li><li>A product page</li></ul><p>We began treating every website like it was Figma running in the browser.</p><p>The result? A hydration cascade.</p><h3>Why Pages Feel Ready Before They Actually Are</h3><p>A typical SPA rendering flow often looks something like this:</p><pre>📄 HTML Arrives<br>        ↓<br>📦 JavaScript Downloads<br>        ↓<br>⚙️ JavaScript Executes<br>        ↓<br>💧 Hydration Begins<br>        ↓<br>✨ Finally Interactive</pre><p>The important detail here is this:</p><blockquote><em>Hydration usually happens for the </em>entire page<em>.</em></blockquote><p>The browser doesn’t really know that your hero section is static, your footer never changes, and your blog content is just text.</p><p>As far as the framework is concerned, every component gets treated as a potential source of interactivity.</p><p>Not just the parts that need interactivity. Everything.</p><ul><li>Navbar</li><li>Footer</li><li>Blog content</li><li>Cards</li><li>Hero sections</li><li>Static layouts.</li></ul><p>All of it gets JavaScript. Even if users never interact with those parts.</p><p>And this creates a subtle but important performance tax:</p><ul><li>Larger bundles</li><li>More parsing work</li><li>More CPU execution</li><li>Delayed Time to Interactive</li><li>Worse performance on lower-end devices</li></ul><p>The browser spends time waking up components that never needed waking up.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/413/1*dw-qUqa_NaWW7LKW6Ei24w.png" /></figure><h3>React Isn’t the Problem</h3><p>This is an important distinction. The issue isn’t React itself - it’s the assumption that every part of a page deserves the same level of interactivity. React was built for highly interactive applications, and for those use-cases, it absolutely shines.</p><ul><li>Dashboards</li><li>Collaborative tools</li><li>Realtime apps</li><li>Complex state-heavy interfaces.</li></ul><p>That’s React’s territory. But most websites on the internet are not collaborative design tools. Most websites are still mostly content.</p><p>And that leads to a much more interesting question:</p><blockquote><em>Does the </em>entire page<em> really need JavaScript?</em></blockquote><p>Or more specifically:</p><blockquote><em>Which parts of the page actually deserve interactivity?</em></blockquote><p>That’s the question Astro asks. And that question changes everything.</p><h3>Astro: A Framework That Questions the Default</h3><p>Astro arrived with a surprisingly simple philosophy 🚀</p><blockquote><em>Ship HTML by default. Add JavaScript only where it earns its place.</em></blockquote><p>That sounds obvious. But modern frontend tooling conditioned us to think the opposite way. In many frameworks, JavaScript everywhere became the default.</p><p>Astro flipped the mental model entirely. Instead of treating hydration as a page-level concern, Astro treats it as a component-level decision.</p><p>Some parts of the page can remain static. Others can become interactive.</p><p>And that distinction changes everything.</p><p>That tiny shift creates a massive architectural difference. Astro is what people often call an <em>HTML-first framework</em>.</p><p>Meaning:</p><ul><li>Pages render to HTML at build time</li><li>Components ship zero JavaScript by default</li><li>Interactivity is opt-in</li><li>Hydration becomes selective instead of global</li></ul><p>This is where Islands Architecture enters the story.</p><h3>The Islands Mental Model</h3><p>The term “Islands Architecture” was popularized by Jason Miller, who described modern pages as mostly static HTML with small islands of interactivity scattered throughout. And honestly, it’s one of the best frontend analogies ever created.</p><p>Imagine your webpage as an ocean</p><p>Most of that ocean is static content : headings, paragraphs, images, layouts, product descriptions, documentation, blog content</p><p>None of that needs JavaScript to exist. It’s just content.</p><p>Now scattered across that sea are small interactive regions</p><p>Search bars, carts, dropdowns, comment sections, theme toggles, carousels, filters.</p><p>Those are the islands. Astro hydrates the islands. The sea remains pure HTML. That distinction is the entire philosophy. And the size difference matters.</p><blockquote><em>The sea should be vast. The islands should be small.</em></blockquote><p>The goal isn’t to eliminate JavaScript. The goal is to become intentional about where JavaScript actually adds value. The goal is to stop treating JavaScript like free candy.</p><p>Because every kilobyte shipped to the browser eventually becomes:</p><ul><li>download time</li><li>parse time</li><li>execution time</li><li>memory usage</li><li>CPU work</li></ul><p>Especially on slower devices.</p><p>Astro simply forces developers to become intentional about it.</p><h3>What Actually Makes Something an Island?</h3><p>Not every component is automatically an island. An island has three important characteristics:</p><p>🔹 Independent: It doesn’t need to know other islands exist.</p><p>🔹 Self-contained: It owns its own state, lifecycle, and JavaScript.</p><p>🔹 Isolated: If one island fails or loads slowly, the rest of the page remains unaffected.</p><p>That’s what makes the architecture resilient.</p><h3>Rendering ≠ Hydration</h3><p>One of Astro’s smartest ideas is separating two concepts most frontend developers casually blur together. Rendering and hydration are not the same thing.</p><h3>Rendering</h3><p>Rendering means:</p><blockquote><em>Converting components into HTML.</em></blockquote><p>This happens on the server or at build time.</p><h3>Hydration</h3><p>Hydration means:</p><blockquote><em>Attaching JavaScript behavior to already-rendered HTML.</em></blockquote><p>This happens in the browser.</p><p>Most frameworks collapse these ideas into one continuous operation. Astro separates them deliberately. Every component renders. Only selected components hydrate. That might sound like a small implementation detail. Architecturally, it’s a completely different way of thinking about frontend rendering.</p><p>Or as I like to phrase it:</p><blockquote><em>In traditional SPAs, hydration is a page-level operation. In Astro, hydration becomes a component-level decision.</em></blockquote><p>That’s the shift.</p><h3>Selective Hydration: The Real Superpower</h3><p>Astro introduces something called <em>client directives</em>.</p><p>These directives decide:</p><ul><li>if a component hydrates</li><li>when it hydrates</li><li>under what conditions it hydrates</li></ul><p>And the API is beautifully simple.</p><h3>Example</h3><pre>&lt;SearchBar client:load /&gt;</pre><p>That single attribute tells Astro - <em>“This component actually needs JavaScript.”</em></p><p>Meanwhile, everything else can remain static HTML. And this is where Astro becomes incredibly interesting. Because hydration is no longer all-or-nothing. It becomes strategic.</p><h3>Meet the Client Directives</h3><h3>client:load</h3><p>Hydrates immediately when the page loads.</p><p>Best for:</p><ul><li>search bars</li><li>nav interactions</li><li>essential UI</li></ul><pre>&lt;SearchBar client:load /&gt;</pre><h3>client:idle</h3><p>Hydrates once the browser finishes critical work.</p><p>Basically Astro saying:</p><blockquote><em>“This feature matters… but not urgently.”</em></blockquote><p>Perfect for:</p><ul><li>chat widgets</li><li>recommendation sections</li><li>secondary UI</li></ul><pre>&lt;ChatWidget client:idle /&gt;</pre><h3>client:visible</h3><p>Hydrates only when the component enters the viewport. Which is honestly brilliant. After all, there’s no reason to wake up UI that the user hasn’t even seen yet.</p><pre>&lt;RecommendedProducts client:visible /&gt;</pre><h3>client:media</h3><p>Hydrates only when a media query matches. Useful for device-specific interactions.</p><pre>&lt;MobileMenu client:media=&quot;(max-width: 768px)&quot; /&gt;</pre><h3>client:only</h3><p>Skips server-side rendering entirely. Useful for components that fundamentally depend on browser APIs - maps, canvas &amp; certain third-party libraries.</p><pre>&lt;Map client:only=&quot;react&quot; /&gt;</pre><h3>The Cool Part: Astro Doesn’t Care About Your Framework</h3><p>This is where Astro starts feeling slightly rebellious</p><p>You can use pretty much anything you like:</p><ul><li>React</li><li>Vue</li><li>Svelte</li><li>Preact</li><li>SolidJS</li></ul><p>In the same project. On the same page. At the same time.</p><p>Which means this is completely valid:</p><pre>&lt;ReactSearch client:load /&gt;<br>&lt;VueCart client:idle /&gt;<br>&lt;SvelteNewsletter client:visible /&gt;</pre><p>Different frameworks. Different runtimes. Independent islands.</p><p>That’s kind of wild when you think about it. Astro doesn’t force framework loyalty. It only cares about delivery strategy. That’s a very different mindset from most frontend ecosystems and is surprisingly refreshing.</p><h3>What This Looks Like in Practice</h3><p>Let’s take a typical e-commerce product page.</p><p>What actually needs JavaScript here?</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/742/1*mf9wyIOBb36Bu1uK4RMU0w.png" /></figure><p>Traditional SPA approach? Hydrate everything.</p><p>Astro approach?</p><pre>&lt;ProductInfo /&gt;<br>&lt;ProductDescription /&gt;<br>&lt;AddToCart client:load /&gt;<br>&lt;Reviews client:idle /&gt;<br>&lt;Recommendations client:visible /&gt;</pre><p>That’s selective hydration in action.</p><p>Instead of treating the page as one massive interactive runtime, Astro isolates interactivity to only the components that genuinely need it.</p><p>To put that into perspective, imagine this page is made up of 20 components.</p><p>A traditional SPA hydration model would typically hydrate all 20 components, regardless of whether they’re interactive or not. An Astro implementation, on the other hand, might only hydrate the 3-4 components that actually need JavaScript.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/608/1*cpRIiCww7jPYSn-9TPMwIw.png" /></figure><p>The exact numbers will vary from project to project, but the architectural difference remains the same.</p><p>Astro doesn’t necessarily render less. In many cases, both approaches produce identical HTML. The difference is what happens after that HTML reaches the browser.</p><p>Less JavaScript to download. Less code to execute. Less UI to hydrate.</p><p>This is why teams migrating content-heavy sites to Astro frequently report Lighthouse scores jumping from the 50-70 range into the 90s. The content was never the bottleneck. The hydration overhead was.</p><p>And honestly? That architectural mindset feels far closer to how most websites actually behave.</p><h3>Performance Isn’t a Feature in Astro. It’s the Default.</h3><p>One thing Astro gets very right is this:</p><p>Performance isn’t treated like an optimization pass you do later. It becomes the architecture itself. Because when static components ship zero JavaScript:</p><ul><li>bundles shrink</li><li>parsing work decreases</li><li>CPU usage drops</li><li>TTI improves</li><li>content becomes readable faster</li></ul><p>The browser spends less time booting frameworks and more time simply rendering content.</p><p>Which, you know… used to be the browser’s main job.</p><p>And this is especially important on:</p><ul><li>slower phones</li><li>weak CPUs</li><li>poor network conditions</li><li>emerging markets</li></ul><p>Sometimes frontend discussions happen entirely on high-end MacBooks with fiber internet. Real users do not live there. Astro’s model respects that reality surprisingly well.</p><h3>But Astro Isn’t Magic</h3><p>And this is important. Islands Architecture is not the perfect solution for every application.</p><p>If your app is something like:</p><ul><li>highly realtime</li><li>deeply collaborative</li><li>state-heavy everywhere</li><li>interaction-first</li></ul><p>Then a traditional SPA is often the better architectural fit. In those scenarios, shrinking hydration boundaries often doesn’t buy you much. The application itself is the island.</p><p>A complex dashboard probably doesn’t benefit much from shrinking hydration boundaries. And that’s okay. Astro isn’t trying to replace React. It’s solving a different category of problems - especially content-heavy experiences where full-page hydration can feel excessive. That nuance matters.</p><p>Good engineering is not about picking one technology forever.</p><p>It’s about understanding tradeoffs.</p><h3>The Bigger Shift Isn’t Technical. It’s Philosophical.</h3><p>What makes Astro interesting isn’t just selective hydration. It’s the mental model underneath it. For years, frontend frameworks trained us to think:</p><blockquote><em>“Everything is an application.”</em></blockquote><p>Astro asks something much smaller. And much smarter.</p><blockquote><em>“Which parts actually deserve interactivity?”</em></blockquote><p>That sounds subtle. Architecturally, it changes everything.</p><p>Because once JavaScript stops being the automatic answer, you start evaluating interactivity much more critically. And that’s where better architectural decisions begin.</p><p>And honestly, the web needed that conversation. We became incredibly good at building complex frontend systems. But somewhere along the way, we also normalized making users download and execute huge amounts of JavaScript for pages that mostly just needed to display content.</p><p>Astro feels like a course correction. Not backwards. Just… more intentional.</p><h3>The Real Lesson Behind Islands Architecture</h3><p>For years, frontend frameworks optimized for building applications.</p><p>Astro reminds us that many websites aren’t applications at all.</p><p>They’re documents.<br>They’re content.<br>They’re experiences.</p><p>And those experiences shouldn’t have to pay for JavaScript they never asked for.</p><p>The most interesting thing Astro introduces isn’t a new rendering strategy. It’s a new default. And defaults matter. Because many performance problems don’t come from the decisions we actively make. They come from the decisions we never realize we’re making.</p><p>That’s what Islands Architecture challenges.</p><p>Not React. Not SPAs. Just the assumption that every page deserves the same amount of JavaScript.</p><p>Because the best JavaScript…is still the JavaScript you never had to ship.</p><p>Modern frontend architectures require thoughtful decisions around rendering, hydration, and JavaScript delivery to achieve fast, scalable user experiences. <a href="https://www.simform.com/">Simform</a> helps organizations design and modernize frontend applications with performance-first architectures that improve responsiveness, maintainability, and long-term scalability.</p><h3>References and Further Reading</h3><ul><li><a href="https://jasonformat.com/islands-architecture/">Jason Miller - Islands Architecture</a></li><li><a href="https://docs.astro.build/">Astro Documentation</a></li><li><a href="https://astro.build/showcase/">Astro Showcase</a></li></ul><blockquote><strong>For more updates on the latest tools and technologies, follow the </strong><a href="https://medium.com/simform-engineering"><strong>Simform Engineering</strong></a><strong> blog.</strong></blockquote><blockquote><strong>Follow us: </strong><a href="https://twitter.com/simform"><strong>Twitter</strong></a><strong> | </strong><a href="https://www.linkedin.com/company/simform/"><strong>LinkedIn</strong></a></blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=cf6ffb6220a9" width="1" height="1" alt=""><hr><p><a href="https://medium.com/simform-engineering/dynamic-islands-architecture-with-astro-hydration-reimagined-cf6ffb6220a9">Dynamic Islands Architecture with Astro: Hydration Reimagined</a> was originally published in <a href="https://medium.com/simform-engineering">Simform Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How I Decide Which Tech Debt to Kill and Which to Protect]]></title>
            <link>https://medium.com/simform-engineering/how-i-decide-which-tech-debt-to-kill-and-which-to-protect-a2c9bf88d36c?source=rss----ce67e0b67c0d---4</link>
            <guid isPermaLink="false">https://medium.com/p/a2c9bf88d36c</guid>
            <category><![CDATA[when-to-refactor-code]]></category>
            <category><![CDATA[tech-debt-vs-features]]></category>
            <category><![CDATA[tech-debt-prioritization]]></category>
            <category><![CDATA[engineering-tech-debt]]></category>
            <category><![CDATA[legacy-code-in-prod]]></category>
            <dc:creator><![CDATA[Milan Dadhaniya]]></dc:creator>
            <pubDate>Thu, 25 Jun 2026 05:50:05 GMT</pubDate>
            <atom:updated>2026-06-25T05:50:03.997Z</atom:updated>
            <content:encoded><![CDATA[<p><em>After years of leading engineering teams, I stopped treating all tech debt the same. Here’s the actual framework I use.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mYfWnRXjvwCzSzmykUJgZg.png" /></figure><p>Every lead engineer has been in this meeting.</p><p>Someone from product wants a new feature by end of quarter. Your backend has a data model that was clearly designed on a Friday afternoon in 2021. The authentication module is a tower of duct tape that somehow hasn’t fallen over. And somewhere in the backlog, there are 47 tickets tagged “tech debt” that haven’t been touched in eight months.</p><p>The question is never “do we have tech debt?” The question is always “which of this debt actually matters right now, and which can safely stay buried?”</p><p>For a long time I answered that question with instinct. Senior engineers would flag something, I’d agree it was messy, it would go into the backlog, and the cycle would continue. Nothing systemic. Nothing defensible when product asked why we were spending sprint capacity cleaning up old code instead of shipping.</p><p>Then we had an incident that forced me to get serious about it.</p><h3>The Incident That Changed My Thinking</h3><p>We were scaling a Node service that had been humming along fine at moderate traffic. As load increased, we started seeing intermittent failures that were nearly impossible to reproduce locally. It took three engineers two weeks to trace it back to a connection pooling pattern that had been written years earlier — technically functional, completely unmaintained, and sitting on an assumption about concurrency that was no longer true at our scale.</p><p>Nobody had flagged it as debt. It wasn’t messy-looking code. It just had a hidden load-bearing assumption that nobody remembered making, and nobody had documented.</p><p>That incident taught me the most important thing I know about tech debt: <strong>the danger isn’t always in the ugly code. It’s in the load-bearing assumptions that nobody wrote down.</strong></p><p>After that, I started thinking about debt completely differently.</p><h3>The Two Questions That Actually Matter</h3><p>Most frameworks for evaluating tech debt ask you to score it on “effort to fix” vs “impact if fixed.” That’s fine for prioritization once you’ve already decided something needs fixing. It doesn’t help you answer the harder question: <em>should this be fixed at all right now, or should we protect it?</em></p><p>The two questions I actually ask are:</p><p><strong>1. What is this debt blocking?</strong> Not in the abstract — specifically. Is it blocking a feature we’re shipping in the next two quarters? Is it blocking hiring, because no engineer wants to touch it? Is it blocking reliability, because it degrades under load? Or is it sitting in a stable part of the system that nobody is touching and nobody needs to touch?</p><p><strong>2. What happens when this breaks — not if?</strong> Every piece of debt breaks eventually. The question is what the failure mode looks like. Does it fail loudly with an error you can trace immediately? Does it fail silently in a way that corrupts data? Does it cascade across services? Does it require the one engineer who wrote it to fix it, or can anyone with context debug it in under an hour?</p><p>These two questions together tell me almost everything I need to know. Debt that blocks active work <em>and</em> fails catastrophically when it breaks gets killed. Debt that sits dormant and fails loudly and cleanly when it eventually breaks? That can wait — and sometimes, it can wait forever.</p><h3>The Four Categories I Actually Use</h3><p>Over time I’ve settled on four buckets. I don’t use a scoring matrix or a spreadsheet. I use these four words, and they’re enough.</p><p><strong>Kill — fix it now, in this quarter, full stop.</strong></p><p>This is debt that is actively in the critical path of something the team is building, <em>or</em> that carries a failure mode that would be catastrophic and silent. The Node connection pooling issue above was Kill category — not because it looked bad, but because it had a hidden assumption that would keep causing incidents as we scaled.</p><p>The test I use: would this debt meaningfully slow down or break the next important thing we’re trying to do? If yes, it gets scheduled, sized, and shipped like any feature. No exceptions.</p><p><strong>Shrink — reduce the blast radius without a full rewrite.</strong></p><p>Some debt is too risky to leave completely untouched but too expensive to eliminate entirely right now. The goal isn’t to fix it — it’s to make sure that when it eventually breaks, the damage is contained and diagnosable.</p><p>In practice this usually means: adding observability (logging, alerting, tracing), writing a runbook so anyone on the team can respond to an incident in that area, and adding a thin abstraction layer so the messy implementation can be replaced incrementally later without touching everything that depends on it.</p><p>This is the category most leads skip. They either fix things fully or leave them alone. The shrink move is underused and genuinely powerful.</p><p><strong>Protect — leave it alone deliberately.</strong></p><p>This one surprises people. Some debt is doing a job. It’s ugly, it’s old, it probably violates three patterns we care about today, but it is stable, well-understood by the team, and in a part of the system that isn’t actively changing.</p><p>Rewriting stable code is one of the most reliable ways to introduce new bugs. I’ve seen teams spend a quarter cleaning up a module that was working fine, only to introduce a regression that took another quarter to hunt down. The rewrite felt productive. It wasn’t.</p><p>The rule here is: if it’s stable, if it’s not in the path of active development, and if it fails loudly when it eventually breaks, protect it. Don’t touch it. Don’t let a new engineer refactor it as a “good first task.” Write down what it does, why it exists, and where the bodies are buried — then leave it alone.</p><p><strong>Watch — not urgent, but not invisible either.</strong></p><p>This is debt that doesn’t meet the bar for Kill or Shrink right now, but shouldn’t just disappear into the backlog either. It goes on a watchlist with a specific trigger: “we revisit this when X happens.” X might be “when we add a second service that depends on this,” or “when we hire a third engineer who needs to understand this codebase,” or “when we hit 10x current traffic.”</p><p>The watchlist prevents the debt from becoming invisible. Invisible debt is how you end up with 47 items in a backlog that nobody actually understands anymore.</p><h3>How I Have This Conversation With Product</h3><p>The hardest part of managing tech debt as a lead isn’t the technical judgment. It’s the conversation with your product manager or your director when they want to know why sprint capacity is going toward something users can’t see.</p><p>I’ve tried many framings over the years. The one that works is anchoring debt to a specific future capability the business cares about.</p><p>“This piece of the system is the bottleneck that will slow down feature X by two sprints if we don’t address it before we start building.”</p><p>“This module has a failure mode that, based on our growth trajectory, will cause an outage in production within the next six months. Here’s why.”</p><p>Notice what I’m not saying: “this code is messy” or “we really should clean this up.” Those are engineering aesthetics. Product doesn’t have to care about engineering aesthetics, and honestly they shouldn’t have to.</p><p>What product does care about is delivery velocity, reliability, and whether you’re going to be able to build what they need to build next. Frame the debt in those terms and the conversation stops being a negotiation between “technical purity” and “shipping features” — because it was never really about that in the first place.</p><h3>The Debt I Learned to Protect</h3><p>There’s one category of debt that I used to kill on sight that I now actively protect: <strong>intentional shortcuts taken under real constraints.</strong></p><p>When a team makes a deliberate, documented decision to cut a corner because shipping mattered more than perfection in that moment — that’s not the same as accidental mess. It’s a calculated bet. And some of those bets age fine.</p><p>The Vue components in our admin panel were written fast, inconsistent with the design system, and held together with more watchers than I’d like. But they’ve been stable for two years, they’re used by maybe fifteen internal users, and nobody is touching them. We have a document that says: “this module is intentionally rough; it was built in a two-week sprint to unblock ops; it is not the pattern we follow elsewhere.”</p><p>That documentation makes it protected, not embarrassing. It means a new lead engineer joining the team doesn’t spend their first week trying to “fix” something that doesn’t need fixing. Context is the difference between debt that’s dangerous and debt that’s just old.</p><h3>The Habit That Changed Everything</h3><p>The single practice that made my debt decisions more defensible than anything else: <strong>writing a short tech debt log entry every time I make a deliberate technical shortcut.</strong></p><p>Not a ticket. Not a comment in the code. A short entry in a shared doc that says: what we built, what we knowingly left rough, why we made that call, and what the trigger condition is for when it should be revisited.</p><p>Three sentences. Takes two minutes. Means that six months later, when someone asks “why is this done this way,” there’s an answer — and the answer tells you whether it’s debt worth killing or debt worth protecting.</p><p>Most tech debt isn’t the result of bad engineers making bad decisions. It’s the result of good engineers making reasonable decisions under constraints, and then never writing down the context. The code survives. The reasoning doesn’t. And without the reasoning, everything looks like a mess that needs cleaning up.</p><h3>The Framework in One Paragraph</h3><p>When a piece of tech debt comes to my attention, I ask: is it blocking something active, or sitting dormant? Does it fail catastrophically and silently, or loudly and cleanly? Based on that, it goes into one of four buckets — Kill, Shrink, Protect, or Watch. I frame the Kill and Shrink decisions for product in terms of delivery impact, not code quality. And I document every intentional shortcut we take so future me — and future team members — have the context to make the same judgment calls without starting from scratch.</p><p>That’s it. No scoring matrix. No spreadsheet. Just two questions, four buckets, and the discipline to write things down.</p><h3>One Last Thing</h3><p>The lead engineers I’ve seen struggle most with tech debt aren’t the ones who make bad technical judgments. They’re the ones who treat it as a binary — clean vs messy, fix vs ignore.</p><p>Real systems aren’t clean. They’re living things with history, context, and accumulated decisions made by people who were doing their best at the time. Your job isn’t to eliminate all the debt. It’s to know which debt is quietly holding up the ceiling, which debt is safe to walk past, and which debt is about to become someone else’s 2am incident.</p><p>The difference between those three things is almost entirely about context — and almost entirely absent from the codebase itself.</p><p>Technical debt becomes a business problem when it slows delivery, increases operational risk, or makes systems harder to evolve. <a href="https://www.simform.com/">Simform</a> helps organizations modernize applications, improve software maintainability, and make pragmatic engineering decisions that balance long-term architecture with near-term business priorities.</p><blockquote><strong><em>For more updates on the latest tools and technologies, follow the </em></strong><a href="https://medium.com/simform-engineering"><strong><em>Simform Engineering</em></strong></a><strong><em> blog.</em></strong></blockquote><blockquote><strong><em>Follow us: </em></strong><a href="https://twitter.com/simform"><strong><em>Twitter</em></strong></a><strong><em> | </em></strong><a href="https://www.linkedin.com/company/simform/"><strong><em>LinkedIn</em></strong></a></blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a2c9bf88d36c" width="1" height="1" alt=""><hr><p><a href="https://medium.com/simform-engineering/how-i-decide-which-tech-debt-to-kill-and-which-to-protect-a2c9bf88d36c">How I Decide Which Tech Debt to Kill and Which to Protect</a> was originally published in <a href="https://medium.com/simform-engineering">Simform Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Distributed Tracing in Java Spring Boot]]></title>
            <link>https://medium.com/simform-engineering/distributed-tracing-in-java-spring-boot-a123401ebde7?source=rss----ce67e0b67c0d---4</link>
            <guid isPermaLink="false">https://medium.com/p/a123401ebde7</guid>
            <category><![CDATA[microservices]]></category>
            <category><![CDATA[jaeger]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[distributed-tracing]]></category>
            <dc:creator><![CDATA[Avinash Hargun]]></dc:creator>
            <pubDate>Wed, 24 Jun 2026 04:58:17 GMT</pubDate>
            <atom:updated>2026-06-24T04:58:16.352Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BNyoBNwpG-wAXJYEvugKuA.png" /><figcaption>Distributed Tracing in Java Spring Boot</figcaption></figure><p><strong>The 3 AM Incident You Don’t Want to Have</strong></p><p>It’s 3 AM. Your on-call phone rings. Users are reporting that checkout is failing. You SSH into your servers, frantically grepping through log files across six different microservices the API gateway, user service, order service, inventory service, payment service, and notification service and all you can find are disconnected error messages with no clear thread linking them together.</p><p>Sound familiar?</p><p>This is the reality of debugging micro-services without observability tooling in place. As soon as you break a monolith into services, your logs fragment across machines, containers, and time zones. A single user request may touch a dozen services before it either succeeds or silently dies somewhere in the middle and your logs give you no way to follow it.</p><p><strong>Distributed tracing is the solution.</strong> In this article you’ll learn exactly what it is, how it works, and how to implement it in your Java Spring Boot applications using the industry-standard OpenTelemetry toolkit with Jaeger as the tracing backend.</p><h3>What Is Distributed Tracing?</h3><p>Distributed tracing is an observability technique that tracks a single request as it travels through every service in your system from the moment the client sends it to the moment a response comes back.</p><p>Think of it like attaching a GPS tracker to a package in a logistics network. You can see every warehouse it passed through, how long it spent at each stop, whether anything was delayed, and exactly where things went wrong.</p><p>In microservice terms, here’s a typical request flow and, crucially, how long each hop takes:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/917/1*r1e0PkcYdl2bnqQCs4OF1w.png" /></figure><p>With distributed tracing, you can instantly see that payment processing consumed 230ms of a 287ms total request. That’s actionable. Without it, you’re guessing.</p><h3>Why Traditional Logging Falls Short</h3><p>Logging is essential but it was designed for a single-process world. In a distributed system, logs have some painful shortcomings:</p><p><strong>Logs are scattered.</strong> When a request touches six services, the relevant log lines live in six separate log files on six separate machines. Pulling them together manually is tedious and error-prone.</p><p><strong>Logs lack context.</strong> A log line like ERROR: Payment failed tells you <em>what</em> happened but not <em>which</em> user request triggered it, <em>what chain of calls preceded it</em>, or <em>how long each step took</em>.</p><p><strong>Correlation is manual.</strong> Some teams use a correlation ID passed in HTTP headers, but implementing, propagating, and querying this consistently across all services is significant engineering work and you’re still not getting timing data.</p><p><strong>You can’t see the full picture.</strong> Even with perfect logs, you can’t easily visualize the shape of a distributed request which calls were parallel, which were sequential, what the latency profile looks like across the whole trace.</p><p>Distributed tracing solves all of this systematically.</p><h3>Core Concepts: The Vocabulary You Need</h3><p>Before jumping to code, let’s nail the core concepts. These terms appear everywhere in tracing tooling, so understanding them upfront will save you a lot of confusion.</p><h3>Trace</h3><p>A <strong>trace</strong> represents the complete journey of a single request through your entire system. From first touch to final response, everything is grouped under one trace.</p><h3>Trace ID</h3><p>A <strong>Trace ID</strong> is a globally unique 128-bit identifier (expressed as a hex string) assigned at the very first entry point of your system typically the API gateway or the first service that receives the request. This single ID is propagated to every downstream service and acts as the common thread that ties all related spans together. Example: traceId: 4bf92f3577b34da6a3ce929d0e0e4736</p><h3>Span</h3><p>A <strong>span</strong> is a single unit of work within a trace one HTTP call between services, one database query, one cache lookup, or any other discrete operation you want to measure. A trace is built from one or more spans. Think of the trace as the folder and spans as the individual files inside it. Example spans: “validate-user in User Service (12ms)”, “create-order in Order Service (45ms)”, “process-payment in Payment Service (230ms)”.</p><h3>Span ID</h3><p>Every span gets its own unique <strong>Span ID</strong> within the trace. While the Trace ID stays constant across the entire request chain, the Span ID changes with every new unit of work. This is how tracing systems distinguish between, say, three separate database calls made within the same service during the same request same Trace ID, three different Span IDs.</p><h3>Parent Span &amp; Child Span</h3><p>Spans are organized in a <strong>parent-child hierarchy</strong> that reflects the actual call tree. The first span in a trace is the <em>root span</em> (no parent). When Service A calls Service B, the span created inside Service B is a <em>child span</em> of Service A’s span. This is the relationship that lets tracing tools render a flame graph you can visually see which spans triggered which downstream work, and exactly where in the call tree latency is accumulating.</p><h3>Context Propagation</h3><p><strong>Context propagation</strong> is how the Trace ID and Span ID travel from service to service. When Service A makes an HTTP call to Service B, it injects the trace context into outgoing HTTP headers via the W3C traceparent standard. Service B reads those headers and creates a child span. Without propagation, each service would start a disconnected, orphaned trace.</p><h3>Sampling</h3><p>In production you don’t want to trace every single request that generates enormous data volumes. <strong>Sampling</strong> decides which requests get traced:</p><ul><li><strong>Head-based sampling:</strong> Decide at trace start (e.g., sample 10% of all requests)</li><li><strong>Tail-based sampling:</strong> Collect all traces but retain only those matching criteria errors, or requests slower than 500ms</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/925/1*7TcjAnJYfAb4MmKiOjel8g.png" /></figure><h3>How Distributed Tracing Works: Step by Step</h3><ol><li><strong>Request arrives</strong> at your API Gateway or first service.</li><li><strong>A Trace ID is generated</strong> a UUID that identifies this request chain for its entire lifetime.</li><li><strong>A root span is created</strong> to represent the work happening in this service.</li><li><strong>The service does its work</strong>, potentially calling downstream services.</li><li><strong>Before each downstream call</strong>, the trace context (Trace ID + Span ID) is injected into outgoing HTTP headers via the W3C TraceContext standard.</li><li><strong>The downstream service reads the headers</strong>, extracts context, and creates a child span linked to the parent.</li><li><strong>Steps 5–6 repeat</strong> for every subsequent service hop.</li><li><strong>Spans are exported</strong> to a tracing backend (Jaeger, Zipkin, Tempo) as each service completes its work.</li><li><strong>The backend assembles the spans</strong> into a flame graph giving you the complete picture.</li></ol><h3>The Ecosystem: Tools to Know</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/922/1*OyrpxZ2r3yiTJw3QrNfqrw.png" /></figure><blockquote><strong>Important:</strong> Spring Boot 3.x dropped Spring Cloud Sleuth. The correct modern approach is <strong>Micrometer Tracing + OpenTelemetry</strong>, which is exactly what we implement below.</blockquote><h3>Why OpenTelemetry? The Industry Standard Explained</h3><p>OpenTelemetry (OTel) didn’t just become popular it <em>replaced</em> an entire generation of fragmented, vendor-specific instrumentation libraries. Here’s why it’s the right choice for every new Spring Boot project.</p><p><strong>Vendor-neutral by design.</strong> OTel is a CNCF (Cloud Native Computing Foundation) project the same organization behind Kubernetes, Prometheus, and Envoy. You instrument your code once using the OTel API, and you can switch backends (Jaeger → Zipkin → Grafana Tempo → AWS X-Ray → Datadog) purely through configuration changes, with zero code modifications.</p><p><strong>It unified a fragmented ecosystem.</strong> Before OTel, the Java observability landscape was split across OpenTracing, OpenCensus, Zipkin’s Brave library, and dozens of vendor SDKs all incompatible. OpenTelemetry merged OpenTracing and OpenCensus and became their official successor. The entire industry cloud providers, APM vendors, framework maintainers has aligned behind it.</p><p><strong>Spring Boot 3.x has first-class OTel support.</strong> Spring Boot 3.x ships Micrometer Tracing as a native abstraction that bridges directly to OpenTelemetry. You get automatic HTTP request tracing, RestTemplate and WebClient context propagation, MDC log correlation, and actuator integration all out of the box with just the right dependencies. Spring Cloud Sleuth (the old approach) is no longer supported in Spring Boot 3.x; OTel via Micrometer is the official replacement.</p><p><strong>One SDK, three observability pillars.</strong> OTel isn’t just for traces. The same SDK covers traces, metrics, and logs under a unified data model. As your observability maturity grows, you can extend the same setup to export metrics to Prometheus and structured logs to your log aggregation platform all from a single, coherently maintained library.</p><h3>Hands-On: Distributed Tracing in Spring Boot 3.x</h3><p>Let’s build a minimal two-service system and wire up distributed tracing end to end.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/943/1*_GJtAPyKFDQylWIkREJypw.png" /></figure><h4>Step 1 — Maven Dependencies</h4><p>Add the following to <strong>both</strong> services’ pom.xml:</p><pre>&lt;!-- Spring Web --&gt;<br>&lt;dependency&gt;<br>  &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;<br>  &lt;artifactId&gt;spring-boot-starter-web&lt;/artifactId&gt;<br>&lt;/dependency&gt;<br><br>&lt;!-- Actuator (required for Micrometer Tracing) --&gt;<br>&lt;dependency&gt;<br>  &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;<br>  &lt;artifactId&gt;spring-boot-starter-actuator&lt;/artifactId&gt;<br>&lt;/dependency&gt;<br><br>&lt;!-- Micrometer bridge for OpenTelemetry --&gt;<br>&lt;dependency&gt;<br>  &lt;groupId&gt;io.micrometer&lt;/groupId&gt;<br>  &lt;artifactId&gt;micrometer-tracing-bridge-otel&lt;/artifactId&gt;<br>&lt;/dependency&gt;<br><br>&lt;!-- OpenTelemetry OTLP exporter → sends spans to Jaeger --&gt;<br>&lt;dependency&gt;<br>  &lt;groupId&gt;io.opentelemetry&lt;/groupId&gt;<br>  &lt;artifactId&gt;opentelemetry-exporter-otlp&lt;/artifactId&gt;<br>&lt;/dependency&gt;</pre><h4>Step 2 — application.yml Configuration</h4><p><strong>User Service</strong> (src/main/resources/application.yml):</p><pre>spring:<br>  application:<br>    name: user-service<br>server:<br>  port: 8080<br>management:<br>  tracing:<br>    sampling:<br>      probability: 1.0  # 100% — lower to 0.1 in production<br>  otlp:<br>    tracing:<br>      endpoint: http://localhost:4318/v1/traces<br>logging:<br>  pattern:<br>    level: &quot;%5p [${spring.application.name:},%X{traceId},%X{spanId}]&quot;</pre><p>Apply the same config to <strong>Order Service</strong>, changing name: order-service and port: 8081.</p><h4>Step 3 — RestTemplate Bean</h4><p>Creating RestTemplate via RestTemplateBuilder is critical this is how Spring Boot auto-registers the tracing interceptor that injects the traceparent header on every outbound call. A plain new RestTemplate() silently skips propagation.</p><pre><br>@Configuration<br>public class RestTemplateConfig {<br><br>  @Bean<br>  public RestTemplate restTemplate(RestTemplateBuilder builder) {<br>    // Builder auto-registers the tracing interceptor.<br>    // This is what injects the W3C traceparent header on every call.<br>    return builder.build();<br>  }<br>}</pre><h4>Step 4 — User Service Controller</h4><pre>@RestController<br>@RequestMapping(&quot;/users&quot;)<br>public class UserController {<br><br>  private static final Logger log = LoggerFactory.getLogger(UserController.class);<br>  private final RestTemplate restTemplate;<br><br>  public UserController(RestTemplate restTemplate) {<br>    this.restTemplate = restTemplate;<br>  }<br><br>  @GetMapping(&quot;/{userId}/profile&quot;)<br>  public Map&lt;String, Object&gt; getUserProfile(@PathVariable String userId) {<br>    log.info(&quot;Fetching profile for userId={}&quot;, userId);<br><br>    // traceparent header injected automatically by the instrumented RestTemplate<br>    String url = &quot;http://localhost:8081/orders/user/&quot; + userId;<br>    Map&lt;String, Object&gt; orders = restTemplate.getForObject(url, Map.class);<br><br>    return Map.of(<br>        &quot;userId&quot;, userId, &quot;name&quot;, &quot;Jane Doe&quot;,<br>        &quot;email&quot;, &quot;jane@example.com&quot;, &quot;recentOrders&quot;, orders<br>    );<br>  }<br>}</pre><h4>Step 5 — Order Service Controller</h4><pre>@RestController<br>@RequestMapping(&quot;/orders&quot;)<br>public class OrderController {<br><br>  private static final Logger log = LoggerFactory.getLogger(OrderController.class);<br><br>  @GetMapping(&quot;/user/{userId}&quot;)<br>  public Map&lt;String, Object&gt; getOrdersForUser(@PathVariable String userId) {<br>    // This traceId matches the one in User Service — same trace!<br>    log.info(&quot;Fetching orders for userId={}&quot;, userId);<br><br>    return Map.of(<br>        &quot;userId&quot;, userId,<br>        &quot;orders&quot;, List.of(<br>            Map.of(&quot;orderId&quot;, &quot;ORD-001&quot;, &quot;status&quot;, &quot;DELIVERED&quot;),<br>            Map.of(&quot;orderId&quot;, &quot;ORD-002&quot;, &quot;status&quot;, &quot;PROCESSING&quot;)<br>        )<br>    );<br>  }<br>}</pre><h4>Step 6 — Run Jaeger Locally</h4><pre>docker run -d --name jaeger \<br>  -p 16686:16686 \   # Jaeger UI<br>  -p 4318:4318 \     # OTLP HTTP receiver<br>  -p 4317:4317 \     # OTLP gRPC receiver<br>  jaegertracing/all-in-one:latest</pre><p>Open http://localhost:16686 to access the Jaeger UI.</p><h4>Step 7 — See It in Action</h4><pre>curl http://localhost:8080/users/42/profile</pre><p>Check your log output across both services. You’ll see the <strong>same Trace ID</strong> appearing in both, with different Span IDs:</p><pre># user-service log<br>INFO [user-service,4bf92f3577b34da6a3ce929d0e0e4736,a3ce929d0e0e4736] Fetching profile for userId=42<br><br># order-service log — SAME traceId, different spanId<br>INFO [order-service,4bf92f3577b34da6a3ce929d0e0e4736,b9c4f1d2e3a5b6c7] Fetching orders for userId=42</pre><p>In the Jaeger UI, select user-service from the dropdown and click <strong>Find Traces</strong>. You&#39;ll see the complete flame graph — User Service parent span with Order Service child span nested underneath, with exact timing for each.</p><h4>Observing Traces in the Jaeger UI</h4><p>Once both services are running and you’ve fired a request via curl, here&#39;s how to read exactly what Jaeger is showing you.</p><h4>Finding Your Trace</h4><p>Open http://localhost:16686. In the left panel, set the <strong>Service</strong> dropdown to user-service and click <strong>Find Traces</strong>. You&#39;ll see a reverse-chronological list of recent traces. Each row shows the service name, root operation, total duration, and the number of spans. Click any row to open the detail view.</p><h4>Reading the Flame Graph</h4><p>The detail view is a Gantt-style flame graph. The widest bar at the top is the <strong>root span</strong> — the total end-to-end duration of the request. Nested below are child spans, each indented to reflect the parent-child call tree. For our two-service example you’ll see something like this:</p><pre># Root span: total request time in user-service<br>user-service   GET /users/42/profile        287ms  ███████████████████████████<br>  # Child span: work done inside user-service<br>  user-service   validate-user                12ms  ██<br>  # Child span: the outbound call to order-service (same Trace ID!)<br>  order-service  GET /orders/user/42          45ms  ████</pre><h4>Expanding a Span for Details</h4><p>Clicking any span bar expands it to reveal its <strong>tags</strong> (key-value attributes) and <strong>logs</strong> (timestamped events). For an HTTP span, you’ll see the URL, method, status code, and response size. For a database span, you’ll see the query. This is where you look when a span shows unexpected latency the tags tell you the exact SQL query, cache key, or downstream endpoint that is taking time.</p><h4>Identifying Bottlenecks Visually</h4><p>The visual width of each bar directly represents its proportion of the total trace duration. A span that fills 80% of the bar width is your bottleneck no arithmetic required. In real production usage, Jaeger’s <strong>Compare Traces</strong> feature lets you overlay a slow trace against a fast one to immediately spot structural differences: an extra downstream call, a missing cache hit, or a sudden spike in a previously fast span.</p><h4>Filtering by Error or Latency</h4><p>In the search panel, you can filter traces by tags for example, set error=true to show only failed traces, or set a minimum duration to surface traces slower than a threshold. These filters turn Jaeger from a debugging tool into a continuous performance monitoring view: after every deployment, filter for P99 traces to immediately confirm whether latency improved or regressed.</p><h3>Conclusion</h3><p>Distributed tracing transforms the way you understand and debug microservice systems. Instead of piecing together logs from a dozen places, you get a single coherent view of every request across every service, every hop, and every millisecond.</p><p>Here’s what we covered: the core concepts (traces, spans, trace IDs, context propagation, sampling), how a trace flows automatically using W3C TraceContext headers, and how to implement end-to-end tracing in Spring Boot 3.x using Micrometer Tracing + OpenTelemetry + Jaeger. The implementation is deliberately low-effort just dependencies, three lines of YAML per service, and a RestTemplate bean.</p><p>The Jaeger UI gives you immediate visual feedback. And when something breaks at 3 AM, you’ll have exactly the tool you need to find it in seconds rather than hours.</p><p>As distributed systems grow in complexity, end-to-end observability becomes critical for maintaining reliability and performance. <a href="https://www.simform.com/">Simform</a> helps organizations implement tracing, metrics, and logging solutions that improve visibility across applications and accelerate issue resolution.</p><blockquote>Try It Yourself</blockquote><blockquote>If you’d like to explore the implementation discussed in this article, here is the repository : <a href="http://github.com/backend-simformsolutions/distributed-tracing-blog-poc">http://github.com/backend-simformsolutions/distributed-tracing-blog-poc</a></blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a123401ebde7" width="1" height="1" alt=""><hr><p><a href="https://medium.com/simform-engineering/distributed-tracing-in-java-spring-boot-a123401ebde7">Distributed Tracing in Java Spring Boot</a> was originally published in <a href="https://medium.com/simform-engineering">Simform Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Design Patterns Won’t Save You If You Don’t Know When to Use Them (Part 2 of 2)]]></title>
            <link>https://medium.com/simform-engineering/design-patterns-wont-save-you-if-you-don-t-know-when-to-use-them-part-2-of-2-2ab4ef59f3a7?source=rss----ce67e0b67c0d---4</link>
            <guid isPermaLink="false">https://medium.com/p/2ab4ef59f3a7</guid>
            <category><![CDATA[design-patterns]]></category>
            <category><![CDATA[architecture]]></category>
            <dc:creator><![CDATA[Akash Chauhan]]></dc:creator>
            <pubDate>Tue, 23 Jun 2026 05:28:54 GMT</pubDate>
            <atom:updated>2026-06-23T05:28:53.672Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>Part 2 of 2</strong> — Covers 11 behavioral patterns: The Communicators and Strategists.</p><p><a href="https://medium.com/@19it197.akashbhai.chauhan/design-patterns-wont-save-you-if-you-don-t-know-when-to-use-them-part-1-of-2-97f70afba43e">Part 1: Builders, Architects, and Connectors</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eyNHtUwxeCcGca0ZeMSp2w.png" /></figure><p>Part 1 covered the structural half: how objects are created, wrapped, and connected. Part 2 is about how objects <em>communicate</em> — who sends messages, who receives them, and how they’re routed.</p><p>These are the patterns with the sharpest production teeth. The Observer leak from the opening story (14 modules, memory leaking, event loop choking) is here. The middleware pipeline you write in Express every week is here. The Redux action model you use daily is here — most developers just don’t know the name.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UphWN1aDo0dKxrFZeGjDGw.png" /></figure><h3>The Communicators</h3><p><em>How objects send, route, and respond to messages.</em></p><h3>Observer</h3><p><strong>Intent: </strong>When one object changes state, notify all dependents automatically.</p><p><strong>Problem: </strong>Twelve React components need to react when a user’s subscription changes. Prop-drilling reaches 8 levels deep. Callbacks compound. A global event bus with string names works until nobody remembers to unsubscribe — and the app fires stale callbacks for users who logged out three sessions ago.</p><p><strong>Solution: </strong>Subscriptions are typed and explicit. Every subscription returns a cleanup function. Callers are responsible for calling it.</p><p><strong>Analogy:</strong> <em>A YouTube subscription — you don’t poll the channel. The channel notifies you when something publishes. You can unsubscribe anytime.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*KI9g5fmG0WcGtqigMq53_g.png" /></figure><pre>type SubscriptionEvents = {<br>  &#39;plan:upgraded&#39;: { newPlan: Plan; previousPlan: Plan };<br>  &#39;plan:cancelled&#39;: { effectiveDate: Date };<br>};<br><br>class SubscriptionService {<br>  private listeners = new Map&lt;keyof SubscriptionEvents, Set&lt;Function&gt;&gt;();<br><br>  on&lt;T extends keyof SubscriptionEvents&gt;(event: T, handler: (e: SubscriptionEvents[T]) =&gt; void): () =&gt; void {<br>    if (!this.listeners.has(event)) this.listeners.set(event, new Set());<br>    this.listeners.get(event)!.add(handler);<br>    return () =&gt; this.listeners.get(event)?.delete(handler); // returns a cleanup fn — caller must call it or leak<br>  }<br><br>  private emit&lt;T extends keyof SubscriptionEvents&gt;(event: T, data: SubscriptionEvents[T]) {<br>    this.listeners.get(event)?.forEach(h =&gt; h(data)); // notify every registered handler<br>  }<br>}<br><br>// In React — useEffect&#39;s return value IS the cleanup function<br>useEffect(() =&gt; {<br>  return subscriptionService.on(&#39;plan:upgraded&#39;, ({ newPlan }) =&gt; setCurrentPlan(newPlan));<br>  //     ↑ returning the cleanup fn here: React calls it on unmount, removing the handler<br>}, []);</pre><p><strong>Use when</strong></p><ul><li>Multiple unrelated parts of your system react to changes in one part</li><li>Subscriptions join and leave dynamically at runtime</li></ul><p><strong>Strengths</strong></p><ul><li>Decouples publisher from subscribers</li><li>Open/Closed — add subscribers without modifying publisher</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Control flow is hard to trace</li><li>Memory leaks when cleanup isn’t called</li></ul><p><strong>Critical rule:</strong> Every on() must have a corresponding off() or cleanup path. This single rule prevents the most common memory leak in browser applications.</p><h3>Mediator</h3><p><strong>Intent: </strong>Route communication between a set of objects through a central coordinator instead of directly between them.</p><p><strong>Problem: </strong>Your trading dashboard has OrderBook, PriceChart, TradeHistory, PositionTracker, and RiskCalculator — all needing to react to each other. Wired directly, it becomes a graph of n² connections. Adding, removing, or changing any component requires understanding all its connections.</p><p><strong>Solution: </strong>All components communicate through a Mediator. No component holds a reference to any other.</p><p><strong>Analogy:</strong> <em>Air traffic control — planes don’t talk to each other; they all route through the tower.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*QSDXKIv5vyYOo7zBsxIkug.png" /></figure><pre>type TradingEvent =<br>  | { type: &#39;order:filled&#39;; orderId: string; price: number }<br>  | { type: &#39;position:changed&#39;; symbol: string; netQty: number };<br><br>class TradingMediator {<br>  private components = new Map&lt;string, { onUpdate(e: TradingEvent): void }&gt;();<br><br>  register(name: string, component: { onUpdate(e: TradingEvent): void }) {<br>    this.components.set(name, component); // components know the mediator; they never know each other<br>  }<br><br>  dispatch(event: TradingEvent, source: string) {<br>    // fan out to every component except the one that sent the event<br>    this.components.forEach((c, name) =&gt; {<br>      if (name !== source) c.onUpdate(event);<br>    });<br>  }<br>}<br><br>// Without Mediator: OrderBook → PriceChart, OrderBook → RiskCalculator, PriceChart → PositionTracker… n² wires<br>// With Mediator:    every component → Mediator → every other component  (n wires total)</pre><p><strong>Use when</strong></p><ul><li>Components communicate in complex ways and the resulting dependencies are painful to maintain</li><li>Reusing a component is hard because it holds references to many others</li><li>UI widget coordination, game state, or chat systems</li></ul><p><strong>Strengths</strong></p><ul><li>Reduces n² connections to n</li><li>Components become reusable</li></ul><p><strong>Tradeoffs</strong></p><ul><li>The Mediator itself can become a God Object</li><li>Centralizes logic that may be better distributed</li></ul><p><strong>Related:</strong> Observer is decentralized — components subscribe directly. Mediator is centralized — everything routes through one coordinator. Use Observer for loose coupling, Mediator when you need coordination <em>logic</em>.</p><h3>Chain of Responsibility</h3><p><strong>Intent: </strong>Pass a request through a chain of handlers; each decides to process it or pass it on.</p><p><strong>Problem: </strong>An HTTP request needs to pass through auth, rate limiting, authorization, and validation before reaching the handler. Each concern is independent. If any fails, the chain stops. They need to be composable and orderable without any step knowing about the others.</p><p><strong>Solution: </strong>Each handler implements the same interface, calls next() to continue, or throws to stop. You&#39;ve used this every time you wrote Express middleware.</p><p><strong>Analogy:</strong> <em>Airport security — passport control, screening, customs, gate check. Each is independent. Fail one and you stop.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*Ehmgk2ZzIB7U88KqW7UWvQ.png" /></figure><pre>type NextFn = () =&gt; Promise&lt;void&gt;;<br><br>interface Middleware {<br>  handle(ctx: RequestContext, next: NextFn): Promise&lt;void&gt;;<br>}<br><br>class AuthMiddleware implements Middleware {<br>  async handle(ctx: RequestContext, next: NextFn) {<br>    const token = ctx.headers[&#39;authorization&#39;]?.replace(&#39;Bearer &#39;, &#39;&#39;);<br>    if (!token) throw new UnauthorizedError(); // stop the chain — next() is never called<br>    ctx.user = await verifyJWT(token);         // attach result to context for downstream handlers<br>    await next();                              // pass control to the next handler in the chain<br>  }<br>}<br><br>class RateLimitMiddleware implements Middleware {<br>  async handle(ctx: RequestContext, next: NextFn) {<br>    const count = await redis.incr(`limit:${ctx.user?.id}`);<br>    if (count &gt; RATE_LIMIT) throw new TooManyRequestsError(); // stop<br>    await next();                                              // pass through<br>  }<br>}<br><br>function buildChain(middlewares: Middleware[]) {<br>  return async (ctx: RequestContext) =&gt; {<br>    let i = 0;<br>    const next = async () =&gt; {<br>      if (i &lt; middlewares.length) await middlewares[i++].handle(ctx, next); // call current, advance index<br>    };<br>    await next(); // kick off the chain from index 0<br>  };<br>}<br><br>// Flow: AuthMiddleware → RateLimitMiddleware → … → handler<br>// A throw at any step stops propagation; forgetting to call next() stops it silently<br>const handle = buildChain([new AuthMiddleware(), new RateLimitMiddleware()]);</pre><p><strong>Use when</strong></p><ul><li>Multiple handlers may process a request and you don’t know which ahead of time</li><li>You need to compose, reorder, or swap processing steps without touching others</li><li>Building request pipelines, approval workflows, or validation chains</li></ul><p><strong>Strengths</strong></p><ul><li>Each handler is isolated and testable</li><li>Easy to add, remove, or reorder steps</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Unhandled requests are a silent failure mode</li><li>Can be confusing when requests pass through many handlers</li></ul><h3>Command</h3><p><strong>Intent: </strong>Encapsulate a request as an object so it can be stored, queued, logged, or reversed.</p><p><strong>Problem: </strong>Your app needs undo/redo. Snapshot-based undo (save full state before every keystroke) is too expensive. Hard-coding inverse operations in every action handler is unmanageable.</p><p><strong>Solution: </strong>Every user action becomes a Command object with execute() and undo(). A CommandHistory stack manages the undo/redo flow.</p><p><strong>Analogy:</strong> <em>A restaurant order ticket — the waiter writes the order (the Command). The kitchen queues and executes it. The ticket can be recalled before the food is cooked.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*E6p6M9I2wjnjV0LBWkzHKg.png" /></figure><pre>interface Command {<br>  execute(): void;<br>  undo(): void;<br>}<br><br>class InsertTextCommand implements Command {<br>  constructor(private doc: Document, private pos: number, private text: string) {}<br>  execute() { this.doc.insertAt(this.pos, this.text); }<br>  undo()    { this.doc.deleteAt(this.pos, this.text.length); } // exact inverse: delete what execute inserted<br>}<br><br>class CommandHistory {<br>  private history: Command[] = []; // commands that have run — pop to undo<br>  private undone:  Command[] = []; // commands that were undone — pop to redo<br><br>  execute(cmd: Command) {<br>    cmd.execute();<br>    this.history.push(cmd);<br>    this.undone = []; // new action clears the redo stack (same as every text editor)<br>  }<br><br>  undo() {<br>    const cmd = this.history.pop();          // remove from executed stack<br>    if (cmd) { cmd.undo(); this.undone.push(cmd); } // reverse it, save for potential redo<br>  }<br><br>  redo() {<br>    const cmd = this.undone.pop();           // take the most-recently-undone command<br>    if (cmd) { cmd.execute(); this.history.push(cmd); } // re-run it, put back in history<br>  }<br>}</pre><p><strong>Use when</strong></p><ul><li>You need undo/redo functionality</li><li>Operations must be queued, scheduled, or serialized for deferred execution</li></ul><p><strong>Strengths</strong></p><ul><li>Clean undo/redo without full snapshots</li><li>Supports transaction rollback</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Every action requires a Command class</li><li>Overkill for simple CRUD with no undo requirement</li></ul><p><strong>In the wild:</strong> Redux actions <em>are</em> the Command pattern. Actions = Commands. Reducer = executor. Redux DevTools time-travel = CommandHistory.undo().</p><h3>Iterator</h3><p><strong>Intent: </strong>Access elements of a collection sequentially without exposing its internal representation.</p><p><strong>Problem: </strong>You have a paginated API — 50,000 users spread across 500 pages. Callers shouldn’t need to manage pagination cursors, page sizes, or buffer logic. They just want for await (const user of users).</p><p><strong>Solution: </strong>Implement AsyncIterator so the collection handles its own traversal.</p><p><strong>Analogy:</strong> <em>A TV remote’s channel button — you don’t know how channels are stored. Press next, get the next one.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*At8aZ5626E1r0cpDNkCQzQ.png" /></figure><pre>class PaginatedAPIIterator&lt;T&gt; implements AsyncIterator&lt;T&gt; {<br>  private buffer: T[] = [];             // items fetched but not yet consumed by the caller<br>  private cursor: string | null = null; // tracks position in the remote dataset<br>  private done = false;<br><br>  constructor(private fetchPage: (cursor: string | null) =&gt; Promise&lt;{ items: T[]; nextCursor: string | null }&gt;) {}<br><br>  async next(): Promise&lt;IteratorResult&lt;T&gt;&gt; {<br>    if (this.buffer.length &gt; 0) return { value: this.buffer.shift()!, done: false }; // serve buffered items first<br>    if (this.done) return { value: undefined as any, done: true };                    // no more pages<br><br>    const { items, nextCursor } = await this.fetchPage(this.cursor); // fetch the next page<br>    this.cursor = nextCursor;<br>    this.done = nextCursor === null; // null cursor means the API has no more pages<br>    this.buffer.push(...items);<br><br>    return this.buffer.length &gt; 0<br>      ? { value: this.buffer.shift()!, done: false }<br>      : { value: undefined as any, done: true }; // page was empty<br>  }<br><br>  [Symbol.asyncIterator]() { return this; } // makes `for await (... of iterator)` work<br>}<br><br>for await (const user of new PaginatedAPIIterator(cursor =&gt; api.get(&#39;/users&#39;, { cursor }))) {<br>  await processUser(user); // caller never touches cursors, page sizes, or buffers<br>}</pre><p><strong>Use when</strong></p><ul><li>You want standard for...of traversal over a custom data structure</li><li>Lazy evaluation over large or infinite sequences</li></ul><p><strong>Strengths</strong></p><ul><li>Clean iteration interface</li><li>Lazy — doesn’t load all data upfront</li></ul><p><strong>Tradeoffs</strong></p><ul><li>A custom Iterator class is often unnecessary</li></ul><p><strong>Honest note:</strong> JavaScript’s built-in Symbol.iterator and generators make this largely unnecessary for simple cases. Before writing an Iterator class, check if a generator does it in 5 lines:</p><pre>function* flatten&lt;T&gt;(nested: T[][]): Generator&lt;T&gt; {<br>  for (const group of nested) yield* group;<br>}</pre><h3>The Strategists</h3><p><em>How to organize logic that does the same thing in different ways.</em></p><h3>Strategy</h3><p><strong>Intent: </strong>Define a family of algorithms, encapsulate each, and make them interchangeable.</p><p><strong>Problem: </strong>Your payment service supports Stripe, PayPal, Crypto, and Bank Transfer. A switch statement in processPayment means every new provider modifies existing logic, every test for one provider has to dodge the others, and any refactor touches everything.</p><p><strong>Solution: </strong>Each payment method is a Strategy. The service holds a reference to one and delegates to it.</p><p><strong>Analogy:</strong> <em>A GPS app — same destination, different routing algorithms. Swap the algorithm without changing the car or the route display.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*p5JTtRNSjW6qVjPhqqKOqg.png" /></figure><pre>interface PaymentStrategy {<br>  charge(amount: number, currency: string): Promise&lt;PaymentResult&gt;;<br>  refund(transactionId: string): Promise&lt;RefundResult&gt;;<br>}<br><br>class StripeStrategy implements PaymentStrategy {<br>  async charge(amount: number, currency: string) {<br>    const intent = await stripe.paymentIntents.create({ amount: amount * 100, currency }); // Stripe uses cents<br>    return { transactionId: intent.id };<br>  }<br>  async refund(transactionId: string) {<br>    const r = await stripe.refunds.create({ payment_intent: transactionId });<br>    return { refundId: r.id };<br>  }<br>}<br><br>class PaymentService {<br>  constructor(private strategy: PaymentStrategy) {} // accepts any strategy matching the interface<br><br>  processPayment(order: Order) {<br>    return this.strategy.charge(order.total, order.currency); // delegates — never knows which provider runs<br>  }<br><br>  setStrategy(s: PaymentStrategy) { this.strategy = s; } // swap provider at runtime, no rebuild<br>}<br><br>// New provider = new class. PaymentService and all call sites are untouched.<br>const service = new PaymentService(new StripeStrategy());<br>service.setStrategy(new PayPalStrategy());</pre><p><strong>Use when</strong></p><ul><li>Multiple variants of an algorithm need to be interchangeable at runtime</li><li>A method has a large conditional that selects between algorithm variants</li></ul><p><strong>Strengths</strong></p><ul><li>Algorithms are isolated and testable</li><li>Open/Closed — add strategies without modifying the service</li></ul><p><strong>Tradeoffs</strong></p><ul><li>More classes for simple scenarios</li></ul><p><strong>TypeScript shortcut:</strong> If your strategy has no state and only one method, it’s just a function parameter:</p><pre>type ChargeFn = (amount: number) =&gt; Promise&lt;PaymentResult&gt;;<br>function processPayment(order: Order, charge: ChargeFn) { ... }</pre><p>Reserve the full class hierarchy for stateful strategies with multiple methods.</p><h3>Template Method</h3><p><strong>Intent: </strong>Define the skeleton of an algorithm in a base class; let subclasses fill in the variable steps.</p><p><strong>Problem: </strong>CSV, JSON, and XML importers all follow the same pipeline: read → validate → parse → transform → save. Steps 1 and 5 are identical for all formats. Steps 2 and 3 differ. Without Template Method, you either duplicate the shared steps or create awkward abstractions.</p><p><strong>Solution: </strong>The invariant sequence lives in a final method in the base class. The variable steps are abstract methods subclasses implement.</p><p><strong>Analogy:</strong> <em>A franchise restaurant — “open, prep, serve, close” is the template. The menu changes; the sequence doesn’t.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*0BU5ADWfa-Asw8HQ0-iroA.png" /></figure><pre>abstract class DataImporter {<br>  // sealed — subclasses must NOT override this; it is the invariant sequence<br>  async import(filePath: string): Promise&lt;ImportResult&gt; {<br>    const raw = await fs.readFile(filePath, &#39;utf-8&#39;); // shared step 1<br>    this.validate(raw);                               // format-specific step 2<br>    const rows = await this.parse(raw);               // format-specific step 3<br>    const records = rows.map(r =&gt; this.transform(r)); // format-specific step 4<br>    return { imported: (await db.insertMany(records)).length }; // shared step 5<br>  }<br><br>  protected abstract validate(raw: string): void;           // subclass must implement these three<br>  protected abstract parse(raw: string): Promise&lt;RawRow[]&gt;;<br>  protected abstract transform(row: RawRow): DomainRecord;<br>}<br><br>class CSVImporter extends DataImporter {<br>  protected validate(raw: string) { if (!raw.startsWith(HEADER)) throw new Error(&#39;Bad header&#39;); }<br>  protected async parse(raw: string) { return parseCSV(raw); }<br>  protected transform(row: RawRow): DomainRecord { return { id: row[&#39;ID&#39;], name: row[&#39;Name&#39;] }; }<br>}<br><br>// CSVImporter never calls db.insertMany — the base class handles that<br>// JSONImporter and XMLImporter would differ only in validate/parse/transform</pre><p><strong>Use when</strong></p><ul><li>Multiple classes share the same algorithm skeleton with different steps</li><li>Building frameworks or pipelines with a fixed structure</li></ul><p><strong>Strengths</strong></p><ul><li>Eliminates duplication of the invariant sequence</li><li>Enforces process order</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Requires inheritance (harder to test)</li><li>Tight coupling between base and subclass</li></ul><p><strong>Related:</strong> Can often be replaced by Strategy (pass the variable steps as functions rather than inheriting them).</p><h3>State</h3><p><strong>Intent: </strong>Allow an object to alter its behavior when its internal state changes.</p><p><strong>Problem: </strong>A network connection can be disconnected, connecting, connected, or failed. send() behaves differently in each state. Without State, every method has a switch on the status flag, and adding a new state means touching every method.</p><p><strong>Solution: </strong>Each state is a class implementing the same interface. The object delegates to its current state.</p><p><strong>Analogy:</strong> <em>A traffic light — the same “change” signal produces a different result depending on current state.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*gok4vzwBNVACnFc5PO0KrA.png" /></figure><pre>interface ConnectionState {<br>  connect(ctx: Connection): void;<br>  send(ctx: Connection, data: Buffer): void;<br>  disconnect(ctx: Connection): void;<br>}<br><br>class DisconnectedState implements ConnectionState {<br>  connect(ctx: Connection) {<br>    ctx.setState(new ConnectingState()); // transition before the async work starts<br>    ctx.openSocket()<br>      .then(() =&gt; ctx.setState(new ConnectedState()))  // success path<br>      .catch(() =&gt; ctx.setState(new FailedState()));    // failure path<br>  }<br>  send()       { throw new Error(&#39;Not connected&#39;); } // illegal in this state<br>  disconnect() { /* no-op — already disconnected */ }<br>}<br><br>class Connection {<br>  private state: ConnectionState = new DisconnectedState(); // starts disconnected<br><br>  setState(s: ConnectionState) { this.state = s; } // state objects call this to transition<br><br>  // These public methods never change — behavior changes because this.state changes<br>  connect()           { this.state.connect(this); }<br>  send(data: Buffer)  { this.state.send(this, data); }<br>  disconnect()        { this.state.disconnect(this); }<br>}</pre><p><strong>Use when</strong></p><ul><li>An object’s behavior changes radically based on internal state</li><li>Methods have large conditionals all branching on the same status flag</li></ul><p><strong>Strengths</strong></p><ul><li>Eliminates state-based conditionals</li><li>Each state is isolated and testable</li></ul><p><strong>Tradeoffs</strong></p><ul><li>State classes multiply quickly</li><li>Overkill for 2–3 states that won’t grow</li></ul><p><strong>TypeScript shortcut:</strong> For small, stable state machines, a discriminated union + switch is often cleaner than State classes.</p><h3>Visitor</h3><p><strong>Intent: </strong>Add operations to an object structure without modifying the objects.</p><p><strong>Problem: </strong>Your query language AST needs to support: SQL serialization, cost estimation, validation, and display formatting. If each operation is a method on AST nodes, node classes grow to include concerns that have nothing to do with representing queries.</p><p><strong>Solution: </strong>Operations become Visitor classes. Each node has an accept(visitor) method. New operations = new Visitor classes. Zero changes to nodes.</p><p><strong>Analogy:</strong> <em>A tax auditor visiting businesses — the auditor knows how to audit each type. Each business knows how to receive an auditor. The audit logic isn’t inside the business itself.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*ZGo0nebaHWVWmcXaQqbmBQ.png" /></figure><pre>interface ASTNode { accept&lt;T&gt;(v: ASTVisitor&lt;T&gt;): T; }<br><br>class SelectNode implements ASTNode {<br>  constructor(public fields: string[], public table: string) {}<br>  accept&lt;T&gt;(v: ASTVisitor&lt;T&gt;): T { return v.visitSelect(this); } // node says &quot;I&#39;m a SelectNode, dispatch to visitSelect&quot;<br>}<br><br>interface ASTVisitor&lt;T&gt; {<br>  visitSelect(node: SelectNode): T;<br>  // add visitWhere(node: WhereNode), visitJoin(node: JoinNode), etc. as the AST grows<br>}<br><br>class SQLSerializer implements ASTVisitor&lt;string&gt; {<br>  visitSelect(node: SelectNode): string {<br>    return `SELECT ${node.fields.join(&#39;, &#39;)} FROM ${node.table}`; // knows how to render SelectNode as SQL<br>  }<br>}<br><br>// How the double dispatch works:<br>// 1. node.accept(visitor)        → node calls visitor.visitSelect(this)<br>// 2. visitor.visitSelect(node)   → visitor handles it with full type information<br>// Adding CostEstimator: implement ASTVisitor&lt;number&gt;. Zero changes to any node class.<br>const sql = new SelectNode([&#39;id&#39;, &#39;email&#39;], &#39;users&#39;).accept(new SQLSerializer());<br>// → &quot;SELECT id, email FROM users&quot;</pre><p><strong>Use when</strong></p><ul><li>Many distinct operations on a stable object structure</li><li>The structure rarely changes but operations are added frequently</li><li>Working with ASTs, document models, or recursive structures</li></ul><p><strong>Strengths</strong></p><ul><li>Add operations without touching the structure</li><li>Separates algorithm from data structure</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Add a new node type → modify every visitor</li><li>Double-dispatch is non-obvious</li></ul><p><strong>Wrong fit:</strong> If your structure grows frequently but operations are stable, use polymorphism or a discriminated union instead.</p><h3>Memento</h3><p><strong>Intent: </strong>Save and restore an object’s state without exposing its internals.</p><p><strong>Problem: </strong>A multi-step onboarding wizard needs “Back” to restore exactly what the user entered. Snapshot-based undo is simpler than Command-based inverse operations when the state is complex or when “inverse” is hard to define.</p><p><strong>Solution: </strong>The object produces opaque snapshots of its state. A history manager stores and restores them.</p><p><strong>Analogy:</strong> <em>A git commit — a complete snapshot of repository state. Restore any previous commit at any time.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*Bzha1eANH2wbqaWVHD030w.png" /></figure><pre>interface WizardSnapshot {<br>  readonly step: number;<br>  readonly data: Readonly&lt;Record&lt;string, unknown&gt;&gt;; // opaque to callers — only restored via restore()<br>}<br><br>class OnboardingWizard {<br>  private step = 1;<br>  private data: Record&lt;string, unknown&gt; = {};<br><br>  save(): WizardSnapshot {<br>    return { step: this.step, data: structuredClone(this.data) }; // deep copy — future mutations won&#39;t corrupt the snapshot<br>  }<br><br>  restore(s: WizardSnapshot) {<br>    this.step = s.step;<br>    this.data = structuredClone(s.data); // deep copy again — protects the stored snapshot from being modified<br>  }<br><br>  updateField(field: string, value: unknown) { this.data[field] = value; }<br>  next() { this.step++; }<br>}<br><br>class WizardHistory {<br>  private snapshots: WizardSnapshot[] = [];<br>  save(w: OnboardingWizard) { this.snapshots.push(w.save()); }             // call before each step change<br>  undo(w: OnboardingWizard) { const s = this.snapshots.pop(); if (s) w.restore(s); } // go back one step<br>}</pre><p><strong>Use when</strong></p><ul><li>You need undo/redo based on state snapshots rather than inverse operations</li><li>Checkpoints or save points in a long-running process</li></ul><p><strong>Strengths</strong></p><ul><li>Simple undo without complex inverse logic</li><li>Encapsulates internals — callers get opaque snapshots</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Memory-intensive for large state</li></ul><p><strong>Related:</strong> If your state lives in an immutable structure (Redux, Zustand with Immer), snapshots are essentially free — you already have them.</p><h3>Interpreter</h3><p><strong>Intent: </strong>Evaluate sentences in a simple language by representing grammar rules as a class hierarchy.</p><p><strong>Problem: </strong>You’re building a filter query language: status:open AND label:bug. Users type queries; your system evaluates them against records. Each token type has a rule; rules compose recursively.</p><p><strong>Solution: </strong>Each grammar rule is a class implementing interpret(). Sentences are trees of rule objects.</p><p><strong>Analogy:</strong> <em>A compiler’s front-end — each grammar rule is a class; a sentence is a composed tree of those rules.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*aXUDrL2bmWERDN7_ZlyC6w.png" /></figure><pre>interface Expression {<br>  interpret(ctx: SearchContext): boolean;<br>}<br><br>class FieldEquals implements Expression { // leaf: checks one field<br>  constructor(private field: string, private value: string) {}<br>  interpret(ctx: SearchContext) { return ctx.get(this.field) === this.value; }<br>}<br><br>class And implements Expression { // composite: both children must pass<br>  constructor(private left: Expression, private right: Expression) {}<br>  interpret(ctx: SearchContext) { return this.left.interpret(ctx) &amp;&amp; this.right.interpret(ctx); }<br>}<br><br>// &quot;status:open AND label:bug&quot; becomes a tree of Expression objects:<br>//   And<br>//   ├── FieldEquals(&#39;status&#39;, &#39;open&#39;)<br>//   └── FieldEquals(&#39;label&#39;, &#39;bug&#39;)<br>const query = new And(new FieldEquals(&#39;status&#39;, &#39;open&#39;), new FieldEquals(&#39;label&#39;, &#39;bug&#39;));<br><br>// Evaluating recurses down the tree: And → left.interpret AND right.interpret<br>const results = allIssues.filter(i =&gt; query.interpret(new SearchContext(i)));</pre><p><strong>Use when</strong></p><ul><li>A simple, stable language or rule set can be represented as an AST</li><li>Building filters, rules engines, or configuration DSLs with simple grammars</li></ul><p><strong>Strengths</strong></p><ul><li>Grammar rules are explicit and isolated</li><li>Easy to add new expressions</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Complex grammars become unmaintainable class trees</li><li>Only practical for simple grammars</li></ul><p><strong>Honest take:</strong> This is the most rarely-needed GoF pattern. For anything beyond simple expressions, use a parser library (chevrotain, nearley, peg.js) instead of rolling your own class hierarchy.</p><h3>What GoF Gets Wrong in 2026</h3><p><strong>Strategy is often just a function.</strong> In C++, passing algorithm variants required a class hierarchy. In TypeScript:</p><pre>// This is Strategy without the ceremony<br>function processPayment(order: Order, charge: (amount: number) =&gt; Promise&lt;Result&gt;) { ... }</pre><p>Use the full class pattern only when your strategy holds state or has multiple methods.</p><p><strong>React hooks are design patterns.</strong></p><ul><li>useReducer = Command (actions are commands, reducer is executor)</li><li>useContext + Provider = Singleton for component trees</li><li>Redux DevTools time-travel = CommandHistory.undo()</li></ul><p>The React team understood patterns deeply enough to bake them into the API invisibly.</p><p><strong>Patterns GoF missed that you use every day:</strong></p><ul><li><strong>Module pattern</strong> — import/export is this pattern, native</li><li><strong>Pub/Sub</strong> — distinct from Observer (has a broker: Redis, SNS, socket.io)</li><li><strong>Middleware pipeline</strong> — Chain of Responsibility formalized as an ecosystem convention</li><li><strong>Repository</strong> — not in GoF; arguably the most important pattern for keeping domain logic clean</li></ul><p><strong>Patterns that TypeScript makes mostly obsolete:</strong></p><ul><li><strong>Iterator</strong> — generators replace 90% of custom Iterator implementations</li><li><strong>Template Method</strong> — composition (passing step functions) is usually cleaner than inheritance</li><li><strong>Interpreter</strong> — parser libraries exist for every language now</li></ul><h3>When to Reach for a Pattern</h3><p>Three questions. In order.</p><p><strong>1. What specific pain exists right now — not someday?</strong></p><p>Patterns solve problems. No problem, no pattern. If you’re not feeling the pain of 14 inconsistent event subscription implementations, don’t implement Observer as prevention.</p><p><strong>2. Would more direct code be so much worse?</strong></p><p>A readable 50-line method beats a 5-file pattern implementation that requires orientation to navigate. The measure isn’t elegance. It’s how fast a new developer understands what it does and why.</p><p><strong>3. Who has to maintain this?</strong></p><p>Patterns are a shared vocabulary only for people who share the vocabulary. Introducing Visitor where your team hasn’t encountered it doesn’t give you elegance — it gives you a puzzle with no answer key.</p><p><strong>Heuristic:</strong> If you can describe the pattern in one sentence and a colleague immediately says “yes, that makes sense” — it belongs. If they need a 10-minute explanation of double dispatch — it’s costing more than it’s buying.</p><h3>The Real Lesson</h3><p>The goal was never to learn 23 patterns.</p><p>The goal is to develop the muscle for recognizing <em>structure</em>. To look at 14 modules independently reimplementing the same subscription logic and immediately see: this is an Observer problem. To look at a method doing 8 different things and see: this wants to be a Chain. To see new EmailClient() embedded in business logic and recognize: that&#39;s a Factory waiting to be named.</p><p>Naming something gives you power over it. Once you can say “this is an Observer problem,” you know the failure modes: subscription lifecycles, cleanup paths, ordering bugs, memory leaks. You’ve collapsed months of debugging into a known problem with known solutions.</p><p>Pick one pattern you’ve never consciously applied. Implement it this week — in your actual codebase, against an actual problem. Then re-read its section here.</p><p>The words will mean something different the second time.</p><p>Recognizing patterns is one thing. Applying them to complex, real-world systems is where engineering experience matters.</p><p>Learn how <a href="https://www.simform.com/">Simform</a> helps businesses build scalable, maintainable, and resilient software through modern architecture, platform engineering, cloud modernization, and AI-driven solutions.</p><p><strong><em>For more updates on the latest tools and technologies, follow the </em></strong><a href="https://medium.com/simform-engineering"><strong><em>Simform Engineering</em></strong></a><strong><em> blog.</em></strong></p><p><strong><em>Follow us: </em></strong><a href="https://twitter.com/simform"><strong><em>Twitter</em></strong></a><strong><em> | </em></strong><a href="https://www.linkedin.com/company/simform/"><strong><em>LinkedIn</em></strong></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2ab4ef59f3a7" width="1" height="1" alt=""><hr><p><a href="https://medium.com/simform-engineering/design-patterns-wont-save-you-if-you-don-t-know-when-to-use-them-part-2-of-2-2ab4ef59f3a7">Design Patterns Won’t Save You If You Don’t Know When to Use Them (Part 2 of 2)</a> was originally published in <a href="https://medium.com/simform-engineering">Simform Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Design Patterns Won’t Save You If You Don’t Know When to Use Them (Part 1 of 2)]]></title>
            <link>https://medium.com/simform-engineering/design-patterns-wont-save-you-if-you-don-t-know-when-to-use-them-part-1-of-2-97f70afba43e?source=rss----ce67e0b67c0d---4</link>
            <guid isPermaLink="false">https://medium.com/p/97f70afba43e</guid>
            <category><![CDATA[architecture]]></category>
            <category><![CDATA[design-patterns]]></category>
            <dc:creator><![CDATA[Akash Chauhan]]></dc:creator>
            <pubDate>Tue, 23 Jun 2026 05:22:27 GMT</pubDate>
            <atom:updated>2026-06-23T05:22:26.403Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>Part 1 of 2</strong> — Covers 12 structural patterns: The Builders, Architects, and Connectors.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pO-T8Zd5U-k5rVcMqjZKYA.png" /></figure><p>Three weeks before we shipped a fintech platform to 200,000 users, our notification system started dropping messages under load.</p><p>The root cause: 14 separate modules had each independently reimplemented an event subscription system, with different cleanup logic, some listeners never removed. Memory leaked. The event loop choked.</p><p>The fix took four hours. The <em>real</em> fix — the architectural one — was understanding the Observer pattern deeply enough to recognize we’d built it 14 times, badly. That’s the thing about design patterns: you’re already using them. You’re just doing it inconsistently.</p><p><strong>What design patterns actually are:</strong></p><p>Not solutions to memorize. A shared vocabulary for structural problems that, once named, become vastly easier to communicate and solve. The GoF catalogued 23 of them in 1994, for C++. Half exist because of language limitations TypeScript simply doesn’t have. That context matters.</p><p><strong>One warning:</strong> The most dangerous thing you can do with patterns is treat them as solutions looking for problems. No pain, no pattern.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*P1NMNGsfEAhokVre4HdM9g.png" /></figure><h3>The Builders</h3><p><em>How objects come into existence when construction gets complex.</em></p><h3>Singleton</h3><p><strong>Intent: </strong>Ensure a class has exactly one instance and provide a global access point to it.</p><p><strong>Problem: </strong>Your database connection pool is being instantiated 47 times because four different modules all believe they’re responsible for creating it.</p><p><strong>Solution: </strong>Make the constructor private. Expose a static method that creates the instance on first call and returns the same one on every subsequent call.</p><p><strong>Analogy:</strong> <em>A country’s central bank — one exists, everyone accesses the same one, by deliberate design.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*5KIxRenjt8rVggNzWyuRKg.png" /></figure><pre>class DatabasePool {<br>  private static instance: DatabasePool | null = null;<br><br>  private constructor(private config: DBConfig) { // private: `new DatabasePool()` is now a compile error<br>    this.connect();<br>  }<br><br>  static getInstance(config: DBConfig): DatabasePool {<br>    if (!DatabasePool.instance) {                         // first call: create<br>      DatabasePool.instance = new DatabasePool(config);<br>    }<br>    return DatabasePool.instance;                         // every subsequent call: same object<br>  }<br><br>  async query(sql: string): Promise&lt;QueryResult&gt; {<br>    return this.getAvailableConnection().execute(sql);<br>  }<br>}<br><br>const pool = DatabasePool.getInstance(dbConfig); // same instance everywhere</pre><p><strong>Use when</strong></p><ul><li>One instance of a resource-intensive object is required across the entire app (connection pools, config, logger)</li><li>You need global accessibility without prop-drilling</li></ul><p><strong>Strengths</strong></p><ul><li>Guarantees a single instance</li><li>Lazy initialization</li><li>Global access point</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Breaks unit tests — hard to mock</li><li>Hidden global state</li><li>Violates Single Responsibility</li></ul><p><strong>Related:</strong> Dependency Injection solves the same problem with better testability. Prefer DI in new services.</p><h3>Factory Method</h3><p><strong>Intent: </strong>Define an interface for creating an object, but let subclasses decide which class to instantiate.</p><p><strong>Problem: </strong>Your notification service is coupled to new EmailNotification(). Next quarter: push notifications. The quarter after: SMS. Every new channel means modifying business logic that should never need to change.</p><p><strong>Solution: </strong>Replace new with an abstract &quot;factory method&quot; that subclasses override. Business logic stays constant; only the created type changes.</p><p><strong>Analogy:</strong> <em>A job posting — you define the role requirements; the hired person fills the role. HR doesn’t pick the candidate.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*ZJ_LbCkgRo4YzDO3tM1AXA.png" /></figure><pre>abstract class NotificationService {<br>  abstract createNotification(): Notification; // hook: subclass decides what to build<br><br>  async notify(userId: string, message: string) {<br>    const prefs = await getUserPrefs(userId);<br>    const notification = this.createNotification(); // calls the hook — no `new` hardcoded here<br>    await notification.send(prefs.contact, message); // works regardless of which type was created<br>  }<br>}<br><br>class EmailNotificationService extends NotificationService {<br>  createNotification(): Notification { return new EmailNotification(); } // concrete decision lives here<br>}<br><br>class PushNotificationService extends NotificationService {<br>  createNotification(): Notification { return new PushNotification(); }  // swap type — nothing else changes<br>}</pre><p><strong>Use when</strong></p><ul><li>A class can’t anticipate the exact type of objects it must create</li><li>Building plugin systems with open-ended product families</li></ul><p><strong>Strengths</strong></p><ul><li>Decouples creator from concrete types</li><li>Open/Closed Principle</li><li>Easy to extend with new types</li></ul><p><strong>Tradeoffs</strong></p><ul><li>More files, more classes</li><li>Can over-engineer simple creation</li></ul><p><strong>Related:</strong> Factory Method creates one product via a hook. Abstract Factory creates <em>families</em> of coordinated products.</p><h3>Abstract Factory</h3><p><strong>Intent: </strong>Create families of related objects without specifying their concrete classes.</p><p><strong>Problem: </strong>You’re building a cross-platform UI library. Web and mobile each have their own Button, Modal, and Input — but they must always be used together. You can never mix a web Button with a mobile Modal.</p><p><strong>Solution: </strong>Define a factory interface that produces a complete product family. Platform decision happens once, at the boundary.</p><p><strong>Analogy:</strong> <em>An IKEA furniture collection — each collection has matching pieces designed to work together. You choose the collection once.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*x0M1LZvfEzn1tP4U4yIfTA.png" /></figure><pre>interface UIFactory {<br>  createButton(): Button;<br>  createModal(): Modal;<br>}<br><br>class WebUIFactory implements UIFactory {<br>  createButton(): Button { return new WebButton(); }<br>  createModal(): Modal   { return new WebModal(); }<br>}<br><br>class MobileUIFactory implements UIFactory {<br>  createButton(): Button { return new MobileButton(); }<br>  createModal(): Modal   { return new MobileBottomSheet(); } // different type, same contract<br>}<br><br>class Screen {<br>  private button: Button;<br>  private modal: Modal;<br><br>  constructor(factory: UIFactory) { // Screen never knows which factory it received<br>    this.button = factory.createButton();<br>    this.modal  = factory.createModal(); // guaranteed matched pair — can&#39;t mix WebButton + MobileBottomSheet<br>  }<br>}<br><br>const factory = isMobile ? new MobileUIFactory() : new WebUIFactory(); // one decision, one place<br>const screen  = new Screen(factory);</pre><p><strong>Use when</strong></p><ul><li>Code must work with multiple families of related objects</li><li>Building theme systems, cross-platform UI kits, or DB driver abstractions</li></ul><p><strong>Strengths</strong></p><ul><li>Guarantees product family consistency</li><li>Decoupled from concrete types</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Adding a new product type requires changing every factory</li><li>Lots of classes even for small families</li></ul><p><strong>Related:</strong> Often implemented with Factory Methods internally. Can use Singleton to ensure one factory per environment.</p><h3>Builder</h3><p><strong>Intent: </strong>Construct complex objects step by step, separating construction from representation.</p><p><strong>Problem: </strong>A UserAccount constructor with 8+ parameters — name, email, password, optional 2FA, optional billing, optional team — produces an argument list nobody can write or read without mistakes. This is the <em>telescoping constructor</em> problem.</p><p><strong>Solution: </strong>A builder with one method per field, each returning this for chaining. Validate at each step, not in one buried constructor call.</p><p><strong>Analogy:</strong> <em>A custom PC configurator — build incrementally: processor, then RAM, then storage. Each step is validated and meaningful on its own.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*IPD2cKATKIMu2jnev5LiVA.png" /></figure><pre>class UserAccountBuilder {<br>  private account: Partial&lt;UserAccount&gt; = { twoFactorEnabled: false };<br><br>  withEmail(email: string): this { // returns `this` so calls can chain: .withEmail(...).withPassword(...)<br>    if (!email.includes(&#39;@&#39;)) throw new Error(`Invalid email: ${email}`); // validate at the step, not buried in build()<br>    this.account.email = email;<br>    return this;<br>  }<br><br>  withPassword(plaintext: string): this {<br>    this.account.passwordHash = bcrypt.hashSync(plaintext, 12); // transform happens here, not in the domain object<br>    return this;<br>  }<br><br>  withTeam(teamId: string): this {<br>    this.account.teamId = teamId; // optional — omitting this is fine<br>    return this;<br>  }<br><br>  build(): UserAccount {<br>    if (!this.account.email || !this.account.passwordHash) {<br>      throw new Error(&#39;Email and password are required&#39;); // final gate: catch anything still missing<br>    }<br>    return this.account as UserAccount;<br>  }<br>}<br><br>// Each method name documents what it sets — no positional argument guessing<br>const account = new UserAccountBuilder()<br>  .withEmail(&#39;user@company.com&#39;)<br>  .withPassword(&#39;securepassword&#39;)<br>  .withTeam(&#39;team_abc&#39;)<br>  .build();</pre><p><strong>Use when</strong></p><ul><li>Constructor has 5+ parameters, especially optional ones</li><li>You need per-step validation during construction</li><li>The same construction process should produce different representations</li></ul><p><strong>Strengths</strong></p><ul><li>Readable, self-documenting call sites</li><li>Validates at each step</li><li>Supports optional params cleanly</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Overkill for simple objects</li><li>Requires a separate Builder class</li></ul><p><strong>Related:</strong> For simple objects with 2–3 fields, use TypeScript’s destructured params instead: createUser({ email, teamId? }).</p><h3>Prototype</h3><p><strong>Intent</strong></p><p>Create new objects by cloning an existing instance rather than building from scratch.</p><p><strong>Problem: </strong>A Document object requires a database round-trip, AST parsing, and style computation to create. A user wants &quot;duplicate this document&quot; — 90% identical to an existing one.</p><p><strong>Solution: </strong>Define a clone() method. New objects copy an existing instance and specialize the copy.</p><p><strong>Analogy:</strong> <em>Biological cell division — copy what already works, then specialize.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*dbmNbaa7RPO897iE4EIyHA.png" /></figure><pre>class DocumentTemplate {<br>  constructor(<br>    public baseStyles: StyleSheet,<br>    public meta: DocumentMeta,<br>    private sections: Section[],<br>  ) {}<br><br>  clone(): DocumentTemplate {<br>    return new DocumentTemplate(<br>      structuredClone(this.baseStyles), // deep copy — mutations to the clone won&#39;t touch the original<br>      structuredClone(this.meta),<br>      this.sections.map(s =&gt; s.clone()), // each section clones itself recursively<br>    );<br>  }<br><br>  withTitle(title: string): DocumentTemplate {<br>    const copy = this.clone(); // always start from a clean copy of the template<br>    copy.meta.title = title;<br>    return copy;              // return the modified copy; original is untouched<br>  }<br>}<br><br>const blogTemplate = new DocumentTemplate(styles, meta, sections); // expensive once: DB + parsing<br>const post  = blogTemplate.withTitle(&#39;Design Patterns 2025&#39;); // cheap: clone + one field change<br>const draft = blogTemplate.withTitle(&#39;Untitled Draft&#39;);       // blogTemplate is still unchanged</pre><p><strong>Use when</strong></p><ul><li>Object creation is expensive (DB reads, network calls, heavy parsing)</li><li>You need many similar objects with small variations</li><li>Implementing undo systems that snapshot state before each change</li></ul><p><strong>Strengths</strong></p><ul><li>Avoids expensive re-initialization</li><li>Decoupled from concrete classes</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Deep-cloning circular refs is hard</li><li>Custom clone logic becomes a maintenance burden</li></ul><p><strong>Related:</strong> Objects produced by Prototype can be stored in a Prototype Registry (a Flyweight-like factory).</p><h3>The Architects</h3><p><em>How objects wrap each other to change their interface, behavior, or access.</em></p><h3>Adapter</h3><p><strong>Intent: </strong>Allow incompatible interfaces to work together without modifying either side.</p><p><strong>Problem : </strong>Your codebase calls analytics.trackEvent(name, props). The new third-party SDK expects sdk.record({ type, metadata, timestamp }). You can&#39;t change the SDK. Coupling to it in 40 files means a full-codebase change every time you swap providers.</p><p><strong>Solution: </strong>Write an Adapter class that implements your interface and translates calls to the SDK internally.</p><p><strong>Analogy:</strong> <em>A universal power adapter — your laptop and the wall socket don’t change; the adapter bridges them.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*0LWKklBnbgF0GrbJMpuaSA.png" /></figure><pre>interface Analytics { // your codebase&#39;s contract — you own and control this<br>  trackEvent(name: string, props: Record&lt;string, unknown&gt;): void;<br>}<br><br>class SegmentAdapter implements Analytics {<br>  constructor(private sdk: SegmentSDK) {}<br><br>  trackEvent(name: string, props: Record&lt;string, unknown&gt;): void {<br>    // translate your interface into what the SDK actually expects<br>    this.sdk.record({ type: name, metadata: props, timestamp: Date.now() });<br>  }<br>}<br><br>// Swap Segment for Mixpanel → write MixpanelAdapter. Every call site stays the same.<br>const analytics: Analytics = new SegmentAdapter(new SegmentSDK());<br>analytics.trackEvent(&#39;signup&#39;, { plan: &#39;pro&#39; }); // callers never import or touch the SDK directly</pre><p><strong>Use when</strong></p><ul><li>A class interface doesn’t match what your code expects and you can’t change it</li><li>Migrating between libraries gradually</li></ul><p><strong>Strengths</strong></p><ul><li>Zero changes to existing code</li><li>Swap providers in one place</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Adds an indirection layer</li><li>Adapters accumulate with each SDK version</li></ul><p><strong>Related:</strong> Decorator also wraps an object — but to <em>add</em> behavior, not translate an interface.</p><h3>Decorator</h3><p><strong>Intent</strong></p><p>Add behavior to an object dynamically without modifying its class or interface.</p><p><strong>Problem: </strong>An API client needs logging in dev, retry logic in prod, and request signing for auth. Building LoggingRetrySignedClient as a single class is a combinatorial explosion — 8 combinations for 3 concerns.</p><p><strong>Solution: </strong>Each concern is its own wrapper that implements the same interface and delegates to an inner object.</p><p><strong>Analogy:</strong> <em>A coffee order — start with espresso, wrap it with milk, wrap that with vanilla. The cup still fits in the same holder.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*02vXlYhzRtTWf8_gvLgLqg.png" /></figure><pre>interface APIClient {<br>  request&lt;T&gt;(endpoint: string, opts: RequestOptions): Promise&lt;T&gt;;<br>}<br><br>class RetryDecorator implements APIClient {<br>  constructor(private inner: APIClient, private maxAttempts = 3) {}<br><br>  async request&lt;T&gt;(endpoint: string, opts: RequestOptions): Promise&lt;T&gt; {<br>    for (let i = 1; i &lt;= this.maxAttempts; i++) {<br>      try { return await this.inner.request&lt;T&gt;(endpoint, opts); } // delegate to the next layer<br>      catch (e) { if (i === this.maxAttempts) throw e; await delay(i * 200); } // backoff, then retry<br>    }<br>    throw new Error(&#39;unreachable&#39;);<br>  }<br>}<br><br>class SigningDecorator implements APIClient {<br>  constructor(private inner: APIClient, private secret: string) {}<br><br>  async request&lt;T&gt;(endpoint: string, opts: RequestOptions): Promise&lt;T&gt; {<br>    const sig = computeHMAC(endpoint, opts.body, this.secret);<br>    return this.inner.request&lt;T&gt;(endpoint, {  // add the header, then pass through<br>      ...opts, headers: { ...opts.headers, &#39;X-Signature&#39;: sig },<br>    });<br>  }<br>}<br><br>// Each layer wraps the one inside it — outermost runs first<br>// Call order: RetryDecorator → SigningDecorator → BaseAPIClient<br>const client: APIClient = new RetryDecorator(<br>  new SigningDecorator(new BaseAPIClient(), process.env.API_SECRET!), 3<br>);</pre><p><strong>Use when</strong></p><ul><li>Adding cross-cutting concerns (logging, caching, auth, metrics) without modifying core logic</li><li>Concerns need to be mixed and matched at runtime</li></ul><p><strong>Strengths</strong></p><ul><li>Compose behaviors independently</li><li>Follows Open/Closed Principle</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Stack traces become harder to read</li><li>Order of decorators matters and isn’t obvious</li></ul><p><strong>Related:</strong> Decorator <em>adds</em> behavior; Proxy <em>controls access</em>. TypeScript’s @decorator syntax is a language feature — not the same thing.</p><h3>Facade</h3><p><strong>Intent: </strong>Provide a simple, unified interface to a complex subsystem.</p><p><strong>Problem: </strong>Your app uses AWS S3 and DynamoDB together for document storage. Every developer using it must understand both SDKs, the correct initialization sequence, and idempotency patterns. That’s a tax paid every time someone new joins the team.</p><p><strong>Solution: </strong>A Facade exposes only the 8% of the surface area you actually use. The SDKs are an implementation detail.</p><p><strong>Analogy:</strong> <em>A hotel concierge — behind that desk is a network of vendors and services. You just say “book me a taxi.” You don’t see the machinery.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*fhRY4K7eKNOi9nkeVLTsPA.png" /></figure><pre>class StorageService {<br>  private s3     = new AWS.S3({ region: config.region });            // SDKs are private —<br>  private dynamo = new AWS.DynamoDB.DocumentClient({ region: config.region }); // callers never see them<br><br>  async uploadDocument(userId: string, file: Buffer, meta: DocumentMeta): Promise&lt;string&gt; {<br>    const key = `users/${userId}/${crypto.randomUUID()}`;<br><br>    // Step 1: store the file in S3<br>    await this.s3.putObject({ Bucket: BUCKET, Key: key, Body: file }).promise();<br><br>    // Step 2: write metadata with an idempotency guard (prevents duplicate entries on retry)<br>    await this.dynamo.put({<br>      TableName: TABLE,<br>      Item: { userId, key, ...meta },<br>      ConditionExpression: &#39;attribute_not_exists(#k)&#39;,<br>      ExpressionAttributeNames: { &#39;#k&#39;: &#39;key&#39; },<br>    }).promise();<br><br>    // Step 3: return a time-limited URL — callers never touch S3 directly<br>    return this.s3.getSignedUrlPromise(&#39;getObject&#39;, { Bucket: BUCKET, Key: key, Expires: 3600 });<br>  }<br>}<br>// Callers: uploadDocument(userId, buffer, meta) → presigned URL. That&#39;s it.</pre><p><strong>Use when</strong></p><ul><li>Wrapping a complex SDK or legacy system</li><li>You want a single, documented entry point for a subsystem</li><li>Migrating legacy systems gradually (Facade = the new contract)</li></ul><p><strong>Strengths</strong></p><ul><li>Simplifies the interface for callers</li><li>Isolates SDK changes to one place</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Can hide complexity you actually need to understand</li><li>Becomes a leaky abstraction if callers need different behaviors</li></ul><p><strong>Related:</strong> Facade provides a <em>new</em> interface to a subsystem. Adapter makes an <em>existing</em> interface compatible.</p><h3>Proxy</h3><p><strong>Intent: </strong>Provide a substitute that controls access to another object.</p><p><strong>Problem: </strong>Your data layer needs authorization checks, but putting if (user.canAccess...) inside every repository method couples security concerns to business logic. Changes to auth rules ripple everywhere.</p><p><strong>Solution: </strong>A Proxy implements the same interface as the real object and intercepts calls to enforce rules, caching, lazy init, or logging.</p><p><strong>Analogy:</strong> <em>A credit card — same interface as cash (pay for things), but it controls access: verifies funds, can decline.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*IXrjFdfspQg9H69nNetsDw.png" /></figure><pre>interface UserRepository {<br>  findUser(id: string): Promise&lt;User&gt;;<br>  updateUser(id: string, data: Partial&lt;User&gt;): Promise&lt;User&gt;;<br>}<br><br>class AuthorizedUserRepository implements UserRepository {<br>  constructor(private inner: UserRepository, private currentUser: AuthUser) {}<br><br>  async findUser(id: string): Promise&lt;User&gt; {<br>    if (id !== this.currentUser.id &amp;&amp; !this.currentUser.isAdmin) {<br>      throw new ForbiddenError(`Cannot access user ${id}`); // stop here — never reaches inner<br>    }<br>    return this.inner.findUser(id); // gate passed: delegate to the real repository<br>  }<br><br>  async updateUser(id: string, data: Partial&lt;User&gt;): Promise&lt;User&gt; {<br>    if (id !== this.currentUser.id &amp;&amp; !this.currentUser.isAdmin) {<br>      throw new ForbiddenError(`Cannot modify user ${id}`);<br>    }<br>    const { role, ...safeData } = data; // strip fields a regular user cannot self-assign<br>    return this.inner.updateUser(id, safeData);<br>  }<br>}<br>// Callers use AuthorizedUserRepository exactly like UserRepository — auth is invisible to them</pre><p><strong>Use when</strong></p><ul><li>Access control without polluting the object’s core logic (authorization proxy)</li><li>Expensive objects created only when first accessed (virtual/lazy proxy)</li></ul><p><strong>Strengths</strong></p><ul><li>Separates security from business logic</li><li>Transparent to callers</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Adds an extra layer of indirection</li><li>Responses may be delayed (lazy init)</li></ul><p><strong>Related:</strong> Proxy and Decorator look similar. Key distinction: Proxy manages the <em>lifecycle and access</em> of its subject; Decorator simply adds behavior without controlling access.</p><h3>The Connectors</h3><p><em>How objects connect structurally at a deeper level.</em></p><h3>Bridge</h3><p><strong>Intent: </strong>Separate an abstraction from its implementation so both can evolve independently.</p><p><strong>Problem: </strong>You have chart types (Bar, Line, Pie) and rendering backends (SVG, Canvas, WebGL). Naive approach: BarChartSVG, BarChartCanvas... Nine classes. Add one chart or one renderer and it triples.</p><p><strong>Solution: </strong>Chart types and renderers become separate hierarchies connected by composition, not inheritance.</p><p><strong>Analogy:</strong> <em>A universal TV remote — the remote’s interface and the TV brand vary independently. Any remote pairs with any TV.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*b59Xd1Cs3qQH67dX7gRB4w.png" /></figure><pre>interface ChartRenderer {         // implementation side — grows independently (SVG, Canvas, WebGL…)<br>  drawBar(x: number, y: number, w: number, h: number): void;<br>  drawLine(points: Point[]): void;<br>}<br><br>abstract class Chart {            // abstraction side — grows independently (Bar, Line, Pie…)<br>  constructor(protected data: ChartData, protected renderer: ChartRenderer) {}<br>  abstract render(): void;<br>}<br><br>class BarChart extends Chart {<br>  render() {<br>    const max = Math.max(...this.data.values);<br>    this.data.values.forEach((v, i) =&gt; {<br>      // BarChart knows layout math; renderer knows how to draw — neither knows the other&#39;s internals<br>      this.renderer.drawBar(i * 50, 300 - (v / max) * 300, 40, (v / max) * 300);<br>    });<br>  }<br>}<br><br>// 3 chart types + 3 renderers = 6 classes covering 9 combinations — no class explosion<br>const chart  = new BarChart(salesData, new SVGRenderer());<br>const chart2 = new BarChart(salesData, new WebGLRenderer()); // swap renderer, chart type unchanged</pre><p><strong>Use when</strong></p><ul><li>Two independent dimensions of variation would produce a class explosion</li><li>You want to switch implementations at runtime</li><li>Building platform-independent abstractions</li></ul><p><strong>Strengths</strong></p><ul><li>Eliminates class explosion</li><li>Both dimensions evolve independently</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Adds indirection even when it isn’t needed yet</li><li>Can be over-engineering if you only have one dimension</li></ul><h3>Composite</h3><p><strong>Intent: </strong>Compose objects into tree structures and treat individual items and collections identically.</p><p><strong>Problem: </strong>File systems, UI component trees, org charts. Anywhere things contain other things of the same type. Code that traverses them shouldn’t care whether it’s dealing with a leaf or a container.</p><p><strong>Solution: </strong>Both leaf nodes and composite nodes implement the same interface. Composites delegate to their children recursively.</p><p><strong>Analogy:</strong> <em>A folder on your filesystem — contains files and other folders. Code listing contents treats both uniformly.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*5_jwqb33jIAw6BUEImoJXg.png" /></figure><pre>interface UIComponent {<br>  render(): string;<br>  getHeight(): number;<br>}<br><br>class Button implements UIComponent { // leaf — has no children<br>  constructor(private label: string, private height: number) {}<br>  render()    { return `&lt;button&gt;${this.label}&lt;/button&gt;`; }<br>  getHeight() { return this.height; }<br>}<br><br>class Panel implements UIComponent { // composite — contains other UIComponents (including other Panels)<br>  private children: UIComponent[] = [];<br>  add(c: UIComponent): this { this.children.push(c); return this; }<br>  render()    { return `&lt;div&gt;${this.children.map(c =&gt; c.render()).join(&#39;&#39;)}&lt;/div&gt;`; }<br>  getHeight() { return this.children.reduce((h, c) =&gt; h + c.getHeight(), 0) + 32; } // sum children + own padding<br>}<br><br>// measure() works on a single Button, a Panel, or a Panel containing Panels — same call either way<br>function measure(c: UIComponent) { return c.getHeight(); }<br><br>const nav = new Panel()<br>  .add(new Button(&#39;Home&#39;, 40))<br>  .add(new Panel()                      // nested Panel — Panel doesn&#39;t care, it just recurses<br>  .add(new Button(&#39;Profile&#39;, 40))<br>  .add(new Button(&#39;Settings&#39;, 40)));</pre><p><strong>Use when</strong></p><ul><li>Your domain has part-whole hierarchies (trees within trees)</li><li>Client code should treat leaves and composites the same way</li><li>Recursive operations over tree-structured data</li></ul><p><strong>Strengths</strong></p><ul><li>Uniform interface across the tree</li><li>Easy to add new component types</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Makes it hard to restrict what can be added where</li></ul><h3>Flyweight</h3><p><strong>Intent: </strong>Reduce memory by sharing common state across large numbers of similar objects.</p><p><strong>Problem: </strong>A map rendering engine needs 50,000 trees. Each tree has species, texture, and a 3D mesh. If each object stores all this, you’re holding gigabytes of duplicated data — most trees of the same species share the exact same texture and mesh.</p><p><strong>Solution: </strong>Split state into <em>intrinsic</em> (shared, immutable — stored once) and <em>extrinsic</em> (unique per instance — passed in at use time).</p><p><strong>Analogy:</strong> <em>A chess set — the white king piece is shared; its position on the board changes. You don’t carve 64 boards into each piece.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*xfCrZ9ccQauIWy_OLDlbCw.png" /></figure><pre>interface TreeType {<br>  species: string;<br>  texture: WebGLTexture; // intrinsic: same for every oak — loaded once, shared across all oak instances<br>  mesh: Float32Array;    // intrinsic: same for every oak<br>}<br><br>class TreeTypeFactory {<br>  private cache = new Map&lt;string, TreeType&gt;();<br><br>  get(species: string, textureUrl: string): TreeType {<br>    const key = `${species}:${textureUrl}`;<br>    if (!this.cache.has(key)) {<br>      // cache miss: load GPU texture + mesh (expensive — only happens once per species)<br>      this.cache.set(key, { species, texture: loadGPUTexture(textureUrl), mesh: loadMesh(species) });<br>    }<br>    return this.cache.get(key)!; // cache hit: all subsequent oaks share this exact object<br>  }<br>}<br><br>class Tree {<br>  constructor(<br>    public x: number, public y: number, // extrinsic: unique per tree instance<br>    private type: TreeType,              // intrinsic: shared reference — NOT a per-tree copy<br>  ) {}<br><br>  draw(renderer: Renderer) {<br>    renderer.draw(this.type.mesh, this.type.texture, this.x, this.y);<br>  }<br>}<br><br>// 50,000 trees, 5 species → 5 TreeType objects in GPU memory instead of 50,000</pre><p><strong>Use when</strong></p><ul><li>Creating massive numbers of similar objects (thousands to millions)</li><li>Memory consumption is a measured, real constraint</li></ul><p><strong>Strengths</strong></p><ul><li>Dramatic memory savings when applied correctly</li></ul><p><strong>Tradeoffs</strong></p><ul><li>Significantly complicates code</li><li>Only worthwhile when you’ve <em>measured</em> the memory problem</li></ul><p><strong>Related:</strong> Flyweight is similar to Singleton in spirit (shared instance), but Flyweight manages <em>many</em> shared types, not one.</p><h3>What’s in Part 2</h3><p>Twelve patterns down, the structural half. You’ve seen how objects are made, wrapped, and connected.</p><p>Part 2 is about how objects <em>talk</em> to each other: Observer (the 14-module story from the opening), Chain of Responsibility (the middleware pipeline you write every day), Command (undo/redo, Redux), and six more. Plus: what GoF gets wrong in 2026, which patterns are obsolete in TypeScript, and the 3-question framework for knowing when to reach for any of them.</p><p><a href="https://medium.com/@19it197.akashbhai.chauhan/design-patterns-wont-save-you-if-you-don-t-know-when-to-use-them-part-2-of-2-2ab4ef59f3a7">Part 2: Communicators &amp; Strategists →</a></p><p><strong><em>For more updates on the latest tools and technologies, follow the </em></strong><a href="https://medium.com/simform-engineering"><strong><em>Simform Engineering</em></strong></a><strong><em> blog.</em></strong></p><p><strong><em>Follow us: </em></strong><a href="https://twitter.com/simform"><strong><em>Twitter</em></strong></a><strong><em> | </em></strong><a href="https://www.linkedin.com/company/simform/"><strong><em>LinkedIn</em></strong></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=97f70afba43e" width="1" height="1" alt=""><hr><p><a href="https://medium.com/simform-engineering/design-patterns-wont-save-you-if-you-don-t-know-when-to-use-them-part-1-of-2-97f70afba43e">Design Patterns Won’t Save You If You Don’t Know When to Use Them (Part 1 of 2)</a> was originally published in <a href="https://medium.com/simform-engineering">Simform Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Integrating AI with .NET Using the Official MCP C# SDK v1.0]]></title>
            <link>https://medium.com/simform-engineering/integrating-ai-with-net-using-the-official-mcp-c-sdk-v1-0-e4d911c281c5?source=rss----ce67e0b67c0d---4</link>
            <guid isPermaLink="false">https://medium.com/p/e4d911c281c5</guid>
            <category><![CDATA[mcp-server]]></category>
            <category><![CDATA[ai-integration]]></category>
            <category><![CDATA[mcp-csharp-sdk]]></category>
            <category><![CDATA[dotnet]]></category>
            <category><![CDATA[csharp]]></category>
            <dc:creator><![CDATA[Panthee Patel]]></dc:creator>
            <pubDate>Thu, 18 Jun 2026 05:39:28 GMT</pubDate>
            <atom:updated>2026-06-18T05:39:27.283Z</atom:updated>
            <content:encoded><![CDATA[<h4>From .NET Services to AI-Callable Tools — A Practical Guide</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PjD3xqPYYRFjA9FZ71WLTQ.png" /></figure><p>AI is improving rapidly at answering questions, writing code and sparking new ideas. But in practical use, that’s just part of the picture. The true power lies in its ability to integrate seamlessly with the tools, services and workflows your application already depends on. The <strong>MCP C# SDK v1.0</strong> provides that capability, built specifically for<strong> .NET</strong>, maintained by <strong>Microsoft</strong>, and designed to feel like a <strong>natural extension of the code</strong> you already write.</p><h3>What MCP is and why it exists</h3><p>MCP (Model Context Protocol) is a standard that helps AI applications connect to tools, services and data sources in a consistent way. It exists to reduce custom integration work and make it easier to connect AI with the systems your application already uses.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xRbiOTq_pQgofC3ZKWAmtQ.png" /><figcaption>MCP flow in a .NET application</figcaption></figure><p>Notice the <strong>transport layer</strong> — this is where <strong>HTTP </strong>and <strong>stdio </strong>differ. Your choice here depends on whether you need a persistent networked server or a local development tool. Later in the blog we will explore this in detail.</p><h3>How MCP Architecture Works</h3><p>MCP follows a simple structure with <strong>three parts</strong>: host, client and server. The <strong>host </strong>is the app or interface the user interacts with. The <strong>client</strong> lives inside that app and manages the MCP connection. The <strong>server</strong> contains the tools, actions or data sources. The host sends the request, the client forwards it and the server performs the work and returns the result. This separation keeps the AI-facing part of the application clean while your core business logic stays in one place.</p><p>For .NET developers, MCP enables AI to move beyond generating responses and interact with real application behavior. Instead of exposing internal services, APIs and business logic through custom integrations, MCP allows AI to request capabilities while your application handles execution. This keeps your architecture intact and provides a consistent way to enable AI-driven workflows.</p><blockquote>Just like USB-C reduced cable clutter, MCP reduces the need for custom integrations between AI models and applications.</blockquote><p>If you’ve used OpenAI, Azure OpenAI or Semantic Kernel, MCP may look similar to <strong>Function Calling</strong>. The difference is scope: Function Calling is model-specific and requires separate integrations for each AI client, while MCP is a standard protocol. Expose your .NET capabilities once as MCP tools and any MCP-compatible host can discover and use them without additional integration work.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/689/1*3hG95_xrfeYpUjPZtrBSWA.png" /><figcaption>Comparison of MCP vs Function Calling</figcaption></figure><p><strong>QUE: Why not just call my APIs directly?</strong> <br><strong>ANS:</strong> You can. But then you write a custom integration for every AI model you support — Claude needs one format, GPT needs another, future tools need another. MCP standardizes the connection once. Your .NET server stays the same regardless of which AI host connects to it.</p><p><strong>QUE: What actually changes for the end user?<br>ANS: </strong>Before MCP, users manually fill forms, select categories and track requests. With MCP, they simply describe the issue and the AI handles everything — creating the ticket, categorizing it and retrieving relevant information.</p><h3>What You Get with the Official MCP C# SDK v1.0</h3><p>The MCP C# SDK makes AI integration feel like a natural extension of your existing .NET application instead of a separate system.</p><h4>Key Benefits</h4><ul><li><strong>Turn existing logic into AI tools</strong><br>Expose your services and methods using simple attributes like [McpServerTool]</li><li><strong>No need for custom integrations</strong><br>Avoid building separate connectors for every AI use case</li><li><strong>Works with your current architecture</strong><br>Fits naturally with service layers, APIs and dependency injection</li><li><strong>Structured and consistent approach</strong><br>Define tools once and let AI clients discover and use them</li><li><strong>Supports multiple hosting styles</strong><br>Use stdio for local tools or HTTP for production scenarios</li></ul><h3>Setting Up the MCP C# SDK in a .NET Project</h3><p><strong>Step 1: Create the project and install the MCP package<br></strong>You can set up MCP in two ways depending on your starting point.</p><p><strong>Option 1: Use the MCP Server Template (Recommended for new projects)<br></strong>If you’re starting fresh, the <strong><em>MCP Server App</em></strong> template is the quickest way to get started, as it sets up a minimal MCP server with the required configuration.</p><figure><img alt="https://learn.microsoft.com/en-us/dotnet/ai/media/build-mcp-server/create-new-project-mcp-server-app.png" src="https://cdn-images-1.medium.com/max/1024/1*Pni2OVyRBZatrv_YnqrYKg.png" /><figcaption>Built-in template provided by Microsoft for MCP Server App</figcaption></figure><p><strong>Option 2: Add MCP to an Existing or Custom Project<br></strong>If you’re working with an existing .NET application or prefer full control, you can install the MCP SDK manually using NuGet packages.</p><p>The SDK is split into three packages:</p><ul><li><strong>ModelContextProtocol</strong> —<em> </em>The<em> main SDK</em> and the <em>right starting point </em>for most projects. Includes the MCP server runtime, stdio transport support, and integration with Microsoft.Extensions.Hosting and dependency injection. References ModelContextProtocol.Core.</li><li><strong>ModelContextProtocol.Core — </strong>The <em>base layer</em>. Use this only if you need low-level client or server APIs with minimal dependencies, such as building custom clients or servers.</li><li><strong>ModelContextProtocol.AspNetCore — </strong>Required when your MCP server runs as an ASP.NET Core web application and communicates over HTTP/SSE. <em>Includes everything above</em> plus HTTP transport — you do not need to install the other two separately.</li></ul><p>Install the required packages using the .NET CLI:</p><pre>dotnet add package ModelContextProtocol<br>dotnet add package Microsoft.Extensions.Hosting<br># Optional — only if needed<br>dotnet add package ModelContextProtocol.AspNetCore<br>dotnet add package ModelContextProtocol.Core</pre><blockquote>The MCP C# SDK is officially maintained by Microsoft and distributed via NuGet. It targets .NET 8 and above, and both stdio and HTTP transports are included — no additional packages needed to switch between them.</blockquote><p><strong>Step 2: Configure MCP in </strong><strong>Program.cs</strong></p><pre>using HelpDesk.McpServer.Data;<br>using HelpDesk.McpServer.Tools;<br><br>var builder = WebApplication.CreateBuilder(args);<br><br>builder.Services.AddSingleton&lt;ITicketRepository, InMemoryTicketRepository&gt;();<br>builder.Services.AddSingleton&lt;KnowledgeBaseRepository&gt;();<br><br>builder.Services.AddMcpServer()<br>    .WithHttpTransport(options =&gt; { options.Stateless = true; })<br>    .WithToolsFromAssembly(typeof(TicketTools).Assembly); //<br><br>var app = builder.Build();<br>        <br>app.MapMcp(); //MapMcp() maps the MCP endpoint to the root / path.<br><br>app.Run();</pre><p>Your MCP server is now set up, but it still needs tools before an AI can actually use it. Next, we’ll connect it to real application logic.</p><p><strong>QUE:</strong> <strong>How MCP Finds Your Tools?</strong><br><strong>ANS:</strong> Because you configured .WithToolsFromAssembly() in Program.cs, the SDK scans your assembly at startup and registers every method marked with [McpServerTool] inside a class marked with [McpServerToolType]. Both attributes are required — neither works without the other.<br>No manual registration. No tool list to maintain. Add a new method with the right attributes and it is automatically available to any AI host that connects.</p><p><strong>QUE: Dependency Injection in Tools<br>ANS: </strong>Your tool methods receive services the same way controllers or minimal API handlers do — through DI. Register your services in Program.cs and the SDK injects them automatically</p><p><strong>How AI Decides Which Tool to Call<br></strong>Tool discovery tells the AI what tools exist. Tool selection is how it decides which one to invoke for a given user request. The AI makes that decision based on mainly these things:</p><ul><li><strong>Tool name</strong> — should be specific and action-oriented. CreateTicket is clear; ProcessData is not</li><li><strong>Description</strong> — the [Description(&quot;...&quot;)] attribute is what the AI reads to understand the tool&#39;s purpose. Treat it like documentation, not a label.</li><li><strong>Parameters</strong> — parameter names and types signal what the tool expects. TicketCategory category tells the AI more than string input</li><li><strong>Return schema</strong> — a structured return type gives the AI predictable output to reason about and present to the user</li></ul><p>This is why two tools with identical logic but different names and descriptions will behave differently in practice. <strong><em>The AI isn’t reading your code ; it’s reading the metadata you attach to it.</em></strong> Investing a few extra seconds in a clear name and a precise description directly improves how reliably the AI selects and uses your tools.</p><h3>Creating MCP Tools in .NET</h3><p>In simple terms, MCP tool is just a method that your application exposes so an AI system can call it when needed.</p><h4>Defining Your First MCP Tool</h4><p>Let’s start with a simple example. We are taking an example of <strong>Helpdesk Ticket Manager</strong> using MCP. Suppose you want to expose a method that creates a ticket.</p><pre>using System.ComponentModel;<br>using HelpDesk.McpServer.Data;<br>using HelpDesk.McpServer.Models;<br>using ModelContextProtocol.Server;<br><br>namespace HelpDesk.McpServer.Tools;<br><br>[McpServerToolType]<br>public static class TicketTools<br>{<br>    [McpServerTool(Name = &quot;CreateTicket&quot;)]<br>    [Description(&quot;Creates a new IT helpdesk support ticket&quot;)]<br>    public static async Task&lt;ToolResponse&lt;CreateTicketResult&gt;&gt; CreateTicket(<br>      ITicketRepository repo,<br>      string title,<br>      string description,<br>      string createdBy,<br>      TicketCategory category,<br>      TicketPriority priority)<br>    {<br>        var ticket = new Ticket<br>      {<br>        Id = Guid.NewGuid().ToString(),<br>        Title = title,<br>        Description = description,<br>        CreatedBy = createdBy,<br>        Category = category,<br>        Priority = priority,<br>        Status = TicketStatus.Open,<br>        CreatedAt = DateTime.UtcNow<br>      };<br><br>      await repo.CreateAsync(ticket);<br><br>      return new ToolResponse&lt;CreateTicketResult&gt;<br>      {<br>        Success = true,<br>        Data = new CreateTicketResult<br>        {<br>          TicketId = ticket.Id,<br>          Title = ticket.Title,<br>          Category = ticket.Category.ToString(),<br>          Priority = ticket.Priority.ToString(),<br>          Status = ticket.Status.ToString(),<br>          CreatedAt = ticket.CreatedAt.ToString(&quot;u&quot;)<br>        }<br>      };<br>    }<br>}</pre><pre>namespace HelpDesk.McpServer.Models;<br><br>public class ToolResponse&lt;T&gt;<br>{<br>  public bool Success { get; set; }<br>  public T? Data { get; set; }<br>  public string? Error { get; set; }<br>}<br><br>public class CreateTicketResult<br>{<br>  public string TicketId { get; set; } = string.Empty;<br>  public string Title { get; set; } = string.Empty;<br>  public string Category { get; set; } = string.Empty;<br>  public string Priority { get; set; } = string.Empty;<br>  public string Status { get; set; } = string.Empty;<br>  public string CreatedAt { get; set; } = string.Empty;<br>}</pre><h4>What’s Happening Here</h4><ul><li><strong>[McpServerToolType]</strong> — tells the SDK this class contains MCP tools. Without this, WithToolsFromAssembly() will not discover any tools inside it, even if they have [McpServerTool]</li><li><strong>[McpServerTool(Name = &quot;CreateTicket&quot;)]</strong> —registers this method as a callable tool and sets the exact name the AI will use to invoke it</li><li><strong>ITicketRepository repo</strong> as first parameter — the MCP SDK integrates with .NET&#39;s dependency injection. Services registered in Program.cs are automatically injected into tool methods. You do not instantiate them manually.</li><li><strong>async Task&lt;ToolResponse&lt;CreateTicketResult&gt;&gt;</strong> — all tools should be async. Tool calls are I/O operations and blocking them defeats the purpose of a responsive server. Returning a typed DTO instead of a plain string gives the AI a consistent structure across all tools</li><li><strong>ToolResponse&lt;T&gt;</strong>— wraps every tool response with a Success flag, a Data payload, and an Error message. The AI uses this structure to determine whether the operation succeeded and what to surface to the user.</li></ul><h4>Validation and Error Handling</h4><p>Since AI constructs tool arguments from natural language, inputs won’t always arrive in the format your code expects. The CreateTicket tool validates each required field — title, description and createdBy — before any business logic runs and enforces basic rules such as minimum title length. If a required value is missing or invalid, the tool returns Success: false with a descriptive error message rather than throwing an exception. The AI receives this signal and responds accordingly — either asking the user for the missing information or surfacing the failure cleanly. Wrapping the core logic in exception handling ensures that any unexpected runtime errors are also caught and returned in the same structured format, keeping the AI&#39;s experience consistent regardless of what goes wrong.</p><h4>Connect your MCP server to GitHub Copilot in Visual Studio</h4><ol><li>Open GitHub Copilot Chat</li><li>Click the <strong>Tools / Toolbox icon</strong></li><li>Click ‘<strong>+’ </strong>icon to<strong> Add MCP Server</strong></li><li>Select your running MCP server</li><li>Ensure it appears in the active tools list</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ONDtr13RDLXGFc1AnlRgDA.jpeg" /><figcaption>Initializing MCP in Visual Studio</figcaption></figure><p>Once connected, Copilot can discover and call your tools automatically and you can have full list of methods which you have defined using <strong>[McpServerTool]</strong>. You do not write any client code — the AI host handles tool discovery, decides which tools to call based on user intent and presents results naturally.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/367/1*1im2WUCKRFxWaBB7ebsK_A.png" /></figure><blockquote><strong>TIP:</strong> <strong>Using stdio instead of HTTP?</strong> Switch WithHttpTransport() to WithStdioServerTransport() in Program.cs. Then configure Copilot with Type: stdio and Command: dotnet run --project &quot;path\to\HelpDesk.McpServer&quot;. Copilot manages the server process automatically. Tools work identically — only the transport changes.</blockquote><p>Both stdio and HTTP transports are fully supported, but the right choice depends on your deployment context. Use this as a quick reference:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/486/1*a2BtG0wrBozbQ7-yq7z-Kw.png" /></figure><h3>Key MCP SDK Components at a Glance</h3><p>The MCP C# SDK uses attributes and configuration to expose your application’s functionality to AI systems. Even a basic setup works, but knowing the key components helps you structure it better.</p><p><strong>Attributes</strong></p><ul><li>[McpServerToolType] — marks a <strong>class </strong>so the SDK can discover it during assembly scanning</li><li>[McpServerTool] — marks a <strong>method </strong>as an MCP tool that AI can invoke</li><li>[Description(&quot;...&quot;)] — explains what the tool does; AI uses this to <strong>decide when to call</strong> it</li></ul><p><strong>[McpServerTool] properties</strong></p><ul><li>Name — overrides the default tool name exposed to the AI</li><li>Title — a human-friendly display name</li><li>OpenWorld — indicates the tool works with dynamic or external data</li><li>Destructive — signals the tool modifies or deletes data</li><li>ReadOnly — signals the tool only reads data, no side effects</li><li>IconSource — allows associating a visual icon with each tool for client display</li></ul><p><strong>Transport registration</strong> (in Program.cs)</p><ul><li>.WithStdioServerTransport() — for local/CLI-based communication</li><li>.WithHttpTransport() — for HTTP-based communication</li></ul><p><strong>Tool discovery</strong> (in Program.cs)</p><ul><li>.WithToolsFromAssembly() — scans the assembly and registers all classes marked with [McpServerToolType]</li></ul><h3>Where MCP Fits in Real Projects</h3><p>MCP shines when AI needs to work with your existing .NET services rather than just generating text. Here are practical scenarios where it delivers real value:</p><p><strong>Perfect use cases:</strong></p><ul><li><strong>Internal tools</strong> — Helpdesk ticket lookup, IT support queries</li><li><strong>Service automation</strong> — Order status, inventory checks, shipment tracking</li><li><strong>Data lookups</strong> — Customer records, reports, database queries</li><li><strong>Workflow triggers</strong> — Deployments, notifications, approval flows</li><li><strong>DevOps tasks</strong> — Build status, CI/CD monitoring, repo info</li></ul><p><strong>QUE: Is MCP only useful for data access?<br>ANS: </strong>No. Data retrieval is the simplest pattern. MCP also enables actions (creating, updating, triggering workflows), knowledge retrieval (searching unstructured content), system integrations (wrapping third-party APIs), and orchestration (chaining multiple tools based on user intent). The <strong>HelpDesk </strong>Practical covers four of these patterns in one scenario.</p><p><strong>What v1.0 makes better:</strong></p><ul><li><strong>Incremental scope consent — </strong>The AI client starts with the minimum permissions needed and requests additional access only when a specific operation requires it. This follows the principle of least privilege — your server stays secure by default without manually managing permission scopes per tool.</li><li><strong>Richer tool metadata — </strong>Tools, resources and prompts can now carry icons, titles and descriptions. This helps the AI select the right tool in the right context, especially when multiple tools are available. Clear metadata reduces incorrect tool invocations and makes your server easier to use from any AI host.</li><li><strong>Authentication support for HTTP transport — </strong>V1.0 ships with built-in OAuth 2.0 support for HTTP-hosted servers. This includes authorization server discovery, JWT token validation and incremental scope handling — everything needed to secure a production MCP server without building your own auth layer.</li><li><strong>Long-running requests and progress tracking over HTTP — </strong>This is the most significant addition for production use. Previously, long operations risked HTTP timeouts with no recovery path. V1.0 solves this with an SSE-based polling model — the server sends an initial event with an ID and closes the connection. The client reconnects using that ID to check progress and retrieve the result when ready. No held connections, no lost results.</li><li><strong>Tasks — durable state tracking (experimental)</strong> Built on top of HTTP polling, tasks add persistent tracking for operations that run in the background. The client gets a task ID immediately and can check status, retrieve results, or cancel the operation at any point — even if the original connection dropped. This maps naturally to existing .NET patterns like background services, batch jobs or anything returning Task&lt;T&gt;.</li></ul><blockquote>V1.0 also introduces advanced capabilities including tool calling in sampling and URL mode elicitation — features worth exploring once your first MCP server is running.</blockquote><p><strong>When to skip MCP:</strong></p><ul><li>Simple chat responses</li><li>One-off API calls (use REST directly)</li><li>Pure text generation apps</li><li>No existing business logic</li></ul><p><strong>The pattern:</strong> Your .NET app keeps the real logic. MCP just gives AI a clean, secure way to access it. Start small — expose one service, see what AI can do with it.</p><h3>Using MCP Safely in Production</h3><p>MCP makes it easy to expose your application’s logic to AI. That openness is also what makes it worth thinking about carefully before you deploy.</p><p><strong>Define clear tool boundaries</strong> Each tool should do one thing and expose only what the AI actually needs. Avoid creating broad tools that return entire records or datasets when only a specific field is required. The more focused your tools are, the less surface area there is for unintended behaviour.</p><p><strong>Never put secrets inside tool responses</strong> If your tool calls an internal service that returns sensitive data, filter that data before returning it. Connection strings, API keys, tokens, and internal identifiers should never pass through a tool response — even if the AI is unlikely to surface them directly.</p><p><strong>Use OAuth for HTTP-hosted servers</strong> When your MCP server runs over HTTP, treat it like any other protected API. V1.0 ships with built-in OAuth 2.0 support including authorization server discovery and JWT token validation. Use it. An unprotected HTTP MCP endpoint is an open door to your business logic.</p><p><strong>Apply the principle of least privilege</strong> Use ReadOnly = true on tools that only read data and Destructive = true on tools that modify or delete it. These signals help the AI host and your own middleware make better decisions about when and how tools are invoked.</p><p><strong>Skipping the input validations </strong>Without validation, a missing or malformed value from the AI passes directly into your service layer. The result is either an unhandled exception or silent incorrect behavior — neither of which gives the AI anything useful to work with.</p><p><strong>Log tool invocations</strong> Treat MCP tool calls like API requests. Log what was called, with what parameters, and what was returned. This is essential for debugging unexpected AI behaviour and for auditing in regulated environments.</p><h3>Common MCP Pitfalls to Avoid</h3><p>These mistakes are easy to make when you’re first building MCP tools and harder to undo once your server is in use.</p><p><strong>Returning large datasets unnecessarily</strong><br>A tool that returns an entire list of tickets when the AI only needed one creates noise in the response and increases token usage. Return only what the AI needs to answer the user’s question — filter, paginate, or summarize at the tool level.</p><p><strong>Exposing database entities directly</strong><br>Returning your EF Core entities or database models as tool responses leaks your internal schema to the AI. Map to a dedicated response DTO instead. It gives you control over what gets exposed and makes future schema changes easier to manage without breaking tool contracts.</p><p><strong>Creating overly generic tools</strong><br>A tool named ManageTicket that handles create, update and delete based on a string parameter forces the AI to guess intent. Separate tools with clear, specific names let the AI select the right one confidently. Specificity in tool design directly improves selection accuracy.</p><p><strong>Skipping input validation</strong><br>AI constructs tool arguments from natural language, which means inputs won’t always arrive in the format your code expects. Treat every parameter the way you’d treat form input from a web page — validate before passing it to your services.</p><p><strong>Embedding business logic inside tool classes</strong><br>Tool classes should be thin. They receive input, call a service, and return a result. If your [McpServerTool] method contains conditional logic, database calls, or calculations, that logic is now invisible to the rest of your application and impossible to unit test cleanly. Keep your services as the source of truth and let tools be the bridge.</p><h3>Practical Example: HelpDesk Ticket Manager</h3><p>The HelpDesk POC is a working .NET 10 solution that exposes a ticket management system as an MCP server. It demonstrates seven MCP tools covering the full range of helpdesk operations — creating tickets, checking status, searching a knowledge base, categorizing, escalating, and updating. The client simulates how an AI host discovers and invokes these tools.</p><p>This repository contains the full .NET MCP server and client implementation used in this example, including all tools, data models and integration setup.</p><pre>HelpDeskMcp/<br>├── HelpDesk.McpServer/      ← ASP.NET Core MCP Server<br>│   ├── Models/              ← Ticket, enums, KnowledgeBaseArticle<br>│   ├── Data/                ← ITicketRepository, InMemoryTicketRepository,<br>│   │                             KnowledgeBaseRepository<br>│   ├── Tools/               ← TicketTools.cs (MCP tools)<br>│   └── Program.cs           ← MCP registration + HTTP transport<br>│<br>└── HelpDesk.McpClient/      ← Console App MCP Client<br>    └── Program.cs           ← Interactive menu, SseClientTransport</pre><p><strong>Moving Beyond In-Memory Storage:</strong> This POC uses in-memory storage — data resets on restart. In production, replace InMemoryTicketRepository with an EF Core implementation. Because all tools depend on ITicketRepository, not the implementation, your tool code changes nothing. Register the new implementation in Program.cs and you&#39;re done.</p><p>How Tools Works Together:<br>(Make sure server project is running before asking)</p><pre>Prompt used in Copilot:<br>&quot;Please use the connected MCP tools. First search the knowledge base, then create a ticket, then escalate if needed. Issue: My laptop keyboard stopped working after a Windows update.&quot;<br><br>User: &quot;My laptop keyboard stopped working after a Windows update.&quot;<br><br>1. Copilot calls SearchKnowledgeBase(&quot;keyboard&quot;)  <br>   → Returns 1 relevant KB article  <br>   (Troubleshooting Unresponsive Keyboards and Mice)<br><br>2. Copilot calls CreateTicket(...)  <br>   → Ticket created: ID a7b9f224<br><br>3. Copilot calls EscalateTicket(...)  <br>   → Escalated to IT Infrastructure, priority set to Critical<br><br>Final Response:  <br>&quot;I&#39;ve found a relevant KB article and raised ticket a7b9f224 as Hardware/Critical, assigned to OS Support Team. In the meantime, try checking Device Manager for driver issues.&quot;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/677/1*ExR_qwJyzPn6ZknnGpozPA.png" /><figcaption>Summary by HelpDeskMCP integrated in project</figcaption></figure><p>GitHub Repository (Complete Working POC): <br><a href="https://github.com/dotnet-simformsolutions/ai-dotnet-mcp-demo">dotnet-simformsolutions/ai-dotnet-mcp-demo</a></p><h3>Final thoughts: What .NET developers should try next</h3><p>Most developers initially see MCP as a data-access layer as a way for AI to query data; but that is only part of the picture. MCP gives AI hands: it can not only read data but also trigger actions, call external systems, and chain multiple operations into complete workflows. This is what sets MCP apart from traditional APIs — while APIs require explicit instructions on what to call, MCP enables AI to decide what to invoke and in what sequence based on the user’s intent.</p><p><strong>Start small, then scale</strong><br>Expose one existing service as an MCP tool, test it locally and then expand to more APIs and production transport as needed.</p><p><strong>Why this matters:</strong> MCP keeps your architecture clean. Your .NET app handles the real work; AI just knows <em>what</em> to ask for.</p><p>Building a proof of concept MCP server is relatively straightforward. Scaling it across enterprise applications is where architecture, governance, security, and tool design become critical. As organizations look to connect AI systems with existing .NET services, they need consistent patterns for authentication, authorization, observability, and lifecycle management. <a href="https://www.simform.com/">Simform</a> helps engineering teams design and implement production-ready AI integrations, modern application architectures, and platform capabilities that allow AI systems to interact safely with business-critical applications at scale.</p><blockquote>MCP isn’t about replacing .NET — it’s about making your services smarter. Start with one tool, see what AI can do with it, then scale from there.</blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e4d911c281c5" width="1" height="1" alt=""><hr><p><a href="https://medium.com/simform-engineering/integrating-ai-with-net-using-the-official-mcp-c-sdk-v1-0-e4d911c281c5">Integrating AI with .NET Using the Official MCP C# SDK v1.0</a> was originally published in <a href="https://medium.com/simform-engineering">Simform Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Reactive vs Blocking: Real Benchmark Results]]></title>
            <link>https://medium.com/simform-engineering/reactive-vs-blocking-real-benchmark-results-5d2b294be26a?source=rss----ce67e0b67c0d---4</link>
            <guid isPermaLink="false">https://medium.com/p/5d2b294be26a</guid>
            <category><![CDATA[blocking-vs-reactive]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[spring-webflux]]></category>
            <category><![CDATA[reactive-programming]]></category>
            <dc:creator><![CDATA[Arshit Moradiya]]></dc:creator>
            <pubDate>Wed, 17 Jun 2026 07:53:24 GMT</pubDate>
            <atom:updated>2026-06-17T07:53:23.245Z</atom:updated>
            <content:encoded><![CDATA[<p>A practical comparison using Spring Boot MVC and WebFlux — with numbers from actual local testing</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*C07Wiz5SjLZcl6joX01ePA.png" /><figcaption>Reactive vs Blocking: Real Benchmark Results</figcaption></figure><h3>Introduction</h3><p>Every few months the “should we go reactive?” conversation comes up. Usually it ends with someone linking a Medium article that shows WebFlux handling 10x more requests, and someone else pointing out that nobody on the team can debug a Mono.zip() chain at 2 AM.</p><p>So I ran my own benchmark. Nothing fancy — no Kubernetes, no cloud, no distributed tracing. Three Spring Boot services on my laptop: a blocking service (Spring MVC), a reactive service (Spring WebFlux), and a shared mock service simulating slow downstream APIs. Then k6 hammering both with concurrent load.</p><p>I wanted real numbers from a real scenario, not theoretical arguments.</p><h3>The Scenario</h3><p>I picked a <strong>Product Dashboard Aggregation API</strong> — a single GET /products/dashboard endpoint that:</p><ol><li>Fetches products from a database</li><li>Calls an inventory API (300ms latency)</li><li>Calls a pricing API (300ms latency)</li><li>Aggregates everything into one response</li></ol><p>The endpoint spends most of its time <em>waiting</em> on downstream IO, not doing CPU work. That’s the kind of scenario where reactive is supposed to shine, so it felt like a fair test.</p><p>The blocking version calls inventory and pricing <strong>sequentially</strong> (thread blocked for ~600ms total). The reactive version fires both calls <strong>concurrently</strong> using Mono.zip() (waits ~300ms total).</p><h3>Features</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/810/1*ofd-ekIg-X2_QZKTpJc5Uw.png" /><figcaption>Feature Comparison</figcaption></figure><h3>Advantages</h3><h4>Blocking (Spring MVC)</h4><ul><li><strong>Familiar programming model.</strong> Every Java developer knows how to read and write sequential code. There’s no mental overhead.</li><li><strong>Full ecosystem support.</strong> JPA, Hibernate, Spring Security — everything works out of the box without workarounds.</li><li><strong>Simple debugging.</strong> Stack traces make sense. You can set a breakpoint and step through the logic.</li><li><strong>Good enough for most workloads.</strong> If your service handles a few hundred concurrent requests and your downstream calls are fast, blocking is fine.</li></ul><h4>Reactive (Spring WebFlux)</h4><ul><li><strong>Efficient thread usage.</strong> A small Netty event-loop pool can handle thousands of concurrent connections because threads are never blocked.</li><li><strong>Better under IO pressure.</strong> When downstream APIs are slow, reactive doesn’t burn threads waiting — it schedules callbacks.</li><li><strong>Concurrent downstream calls.</strong> Mono.zip() fires multiple API calls in parallel with zero extra thread overhead.</li><li><strong>Lower memory footprint.</strong> Fewer threads means less memory consumed per connection at high concurrency.</li></ul><h3>Use Cases</h3><h4>When Blocking Makes Sense</h4><ul><li><strong>CRUD applications</strong> — standard REST APIs backed by a relational database. JPA fits perfectly here.</li><li><strong>Enterprise internal systems</strong> — moderate traffic, complex business logic, teams that need to move fast without reactive expertise.</li><li><strong>Simpler business applications</strong> — where the bottleneck is CPU or database, not concurrent IO waits.</li></ul><h4>When Reactive Makes Sense</h4><ul><li><strong>API aggregation / gateway layers</strong> — exactly what we benchmarked. Multiple downstream calls, lots of IO waiting.</li><li><strong>High-concurrency IO-heavy systems</strong> — chat backends, notification services, real-time dashboards.</li><li><strong>Streaming systems</strong> — SSE, WebSocket feeds, or continuous data pipelines where backpressure matters.</li></ul><h4>A Note on Virtual Threads</h4><p>Java 21 shipped virtual threads (Project Loom). The idea is simple — you write normal blocking code, but the JVM parks the virtual thread during IO instead of tying up an OS thread. In theory this gives you the readability of blocking with much of the concurrency benefit of reactive. Worth testing before committing to a full WebFlux stack, especially if your team is already on Java 21.</p><h3>Benchmark Setup</h3><h4>Environment</h4><ul><li>Java 21</li><li>32 GB RAM</li><li>Three services running locally — mock-service (9090), blocking-service (8080), reactive-service (8081)</li><li>k6 for load testing</li><li>Both services use JPA with H2 in-memory databases</li></ul><h4>Scenarios</h4><p>Two scenarios were tested: 100 virtual users for 30 seconds (low concurrency) and 500 virtual users for 30 seconds (high concurrency). Both with a fixed 300ms simulated downstream delay on inventory and pricing APIs.</p><p><strong>k6 Script</strong></p><pre>import http from &#39;k6/http&#39;;<br>import { check, sleep } from &#39;k6&#39;;<br><br>const BASE_URL = __ENV.TARGET_URL || &#39;http://localhost:8080&#39;;<br>const SCENARIO = __ENV.SCENARIO || &#39;low&#39;;<br><br>export const options = {<br>    scenarios: {<br>        benchmark: {<br>            executor: &#39;constant-vus&#39;,<br>            vus: SCENARIO === &#39;high&#39; ? 500 : 100,<br>            duration: &#39;30s&#39;,<br>            exec: &#39;dashboard&#39;,<br>        },<br>    },<br>};<br><br>export function dashboard() {<br>    const res = http.get(`${BASE_URL}/products/dashboard`);<br>    check(res, { &#39;status 200&#39;: (r) =&gt; r.status === 200 });<br>    sleep(0.1);<br>}</pre><p><strong>Run each scenario separately:</strong></p><pre># Blocking — 100 VUs<br>k6 run -e TARGET_URL=http://localhost:8080 -e SCENARIO=low benchmark.js<br><br># Blocking — 500 VUs<br>k6 run -e TARGET_URL=http://localhost:8080 -e SCENARIO=high benchmark.js<br><br># Reactive — 100 VUs<br>k6 run -e TARGET_URL=http://localhost:8081 -e SCENARIO=low benchmark.js<br><br># Reactive — 500 VUs<br>k6 run -e TARGET_URL=http://localhost:8081 -e SCENARIO=high benchmark.js</pre><h3>Real Benchmark Results</h3><h4>100 Concurrent Users</h4><pre>Metric                  Blocking (MVC)    Reactive (WebFlux)<br>──────────────────────  ────────────────  ──────────────────<br>Avg Response Time       5,670 ms          315 ms<br>P95 Latency             6,180 ms          331 ms<br>Throughput              15.9 req/s        238 req/s<br>Total Requests          570               7,245<br>Success Rate            100%              100%<br>Threads Used            ~100 (Tomcat)     ~4 (Netty)</pre><p>Even at 100 users, the gap is significant. The blocking service averaged ~5.7s per request. Each request holds a Tomcat thread for ~600ms just sitting on two sequential downstream calls. Under sustained load from 100 VUs cycling continuously, requests pile up — the thread doesn’t do anything useful during those 600ms, it just waits. The reactive service stayed at ~315ms because Mono.zip() fires inventory and pricing concurrently (wait = max(300, 300) ≈ 300ms) and Netty event loops never block.</p><h4>500 Concurrent Users</h4><pre>Metric                  Blocking (MVC)    Reactive (WebFlux)<br>──────────────────────  ────────────────  ──────────────────<br>Avg Response Time       18,250 ms         508 ms<br>P95 Latency             30,140 ms         531 ms<br>Throughput              20.3 req/s        810 req/s<br>Total Requests          1,222             24,814<br>Success Rate            79.3%             98.8%<br>Threads Used            ~500 (pool max)   ~4 (Netty)</pre><p>This is where the blocking model falls apart. With 500 VUs and a 500-thread Tomcat pool, every thread is blocked on downstream IO. Requests queue massively, p95 hit 30 seconds, and 20% of requests failed. The reactive service handled the same load with ~500ms average response time and 40x higher throughput. It processed over 24,000 requests while blocking managed just 1,222.</p><h4>Key Observations</h4><ul><li><strong>Thread pool exhaustion is real.</strong> Even at 100 VUs, the blocking service struggled because threads were held for 600ms each during sequential IO waits. At 500 VUs it essentially collapsed.</li><li><strong>Reactive throughput scaled linearly.</strong> From 238 req/s at 100 VUs to 810 req/s at 500 VUs — the Netty event loop handled it without breaking a sweat.</li><li><strong>Reactive used 4 threads the entire time.</strong> The Netty event loop doesn’t grow with load. That’s the whole point.</li><li><strong>The gap is about IO waits, not raw speed.</strong> If those downstream calls returned in 5ms instead of 300ms, the blocking service would perform much closer to reactive.</li><li><strong>Blocking was easier to profile and debug.</strong> Thread dumps made sense. Adding logging was straightforward.</li></ul><h3>Example Code</h3><h4>Prerequisites</h4><ul><li>Java 21</li><li>Maven 3.9+</li><li>k6 installed locally</li></ul><h4>Dependencies</h4><p><strong>Blocking:</strong> spring-boot-starter-web, spring-boot-starter-data-jpa, h2</p><p><strong>Reactive:</strong> spring-boot-starter-webflux, spring-boot-starter-data-jpa, h2</p><h4>Blocking Service — Controller &amp; Service</h4><pre>@RestController<br>public class ProductDashboardController {<br>    private final ProductService productService;<br><br>    public ProductDashboardController(ProductService productService) {<br>        this.productService = productService;<br>    }<br><br>    @GetMapping(&quot;/products/dashboard&quot;)<br>    public DashboardResponse getDashboard() {<br>        return productService.getDashboard();<br>    }<br>}<br><br>@Service<br>public class ProductService {<br>    private final ProductRepository productRepository;<br>    private final RestTemplate restTemplate;<br><br>    public DashboardResponse getDashboard() {<br>        List&lt;Product&gt; products = productRepository.findAll();<br><br>        // Sequential — thread blocked for ~600ms total<br>        Map inventory = restTemplate.getForObject(<br>            &quot;http://localhost:9090/mock/inventory&quot;, Map.class);<br>        Map pricing = restTemplate.getForObject(<br>            &quot;http://localhost:9090/mock/pricing&quot;, Map.class);<br><br>        return new DashboardResponse(products, inventory, pricing);<br>    }<br>}</pre><h4>Reactive Service — Controller &amp; Service</h4><pre>@RestController<br>public class ProductDashboardController {<br>    private final ProductService productService;<br><br>    @GetMapping(&quot;/products/dashboard&quot;)<br>    public Mono&lt;DashboardResponse&gt; getDashboard() {<br>        return productService.getDashboard();<br>    }<br>}<br><br>@Service<br>public class ProductService {<br>    private final ProductRepository productRepository;<br>    private final WebClient webClient;<br><br>    public Mono&lt;DashboardResponse&gt; getDashboard() {<br>        List&lt;Product&gt; products = productRepository.findAll();<br>        <br>        Mono&lt;Map&lt;String, Object&gt;&gt; inventory = webClient.get()<br>            .uri(&quot;http://localhost:9090/mock/inventory&quot;)<br>            .retrieve()<br>            .bodyToMono(new ParameterizedTypeReference&lt;&gt;() {});<br><br>        Mono&lt;Map&lt;String, Object&gt;&gt; pricing = webClient.get()<br>            .uri(&quot;http://localhost:9090/mock/pricing&quot;)<br>            .retrieve()<br>            .bodyToMono(new ParameterizedTypeReference&lt;&gt;() {});<br><br>        // Mono.zip — fires both API calls concurrently<br>        return Mono.zip(<br>            Mono.just(products),<br>            inventory,<br>            pricing<br>        )<br>            .map(t -&gt; new DashboardResponse(t.getT1(), t.getT2(), t.getT3()));<br>    }<br>}</pre><h4>GitHub Repository</h4><blockquote>The complete source code is available here: <a href="https://github.com/backend-simformsolutions/java-blog-poc-reactive-vs-blocking">java-blog-poc-reactive-vs-blocking</a></blockquote><pre>java-blog-poc-reactive-vs-blocking/<br>├── mock-service/                    # Shared mock downstream APIs (port 9090)<br>│   ├── src/main/java/.../mock/<br>│   │   ├── MockServiceApplication.java<br>│   │   └── controller/<br>│   │       └── MockApiController.java<br>│   ├── src/main/resources/application.yml<br>│   └── pom.xml<br>├── blocking-service/                # Spring MVC (port 8080)<br>│   ├── src/main/java/.../blocking/<br>│   │   ├── BlockingServiceApplication.java<br>│   │   ├── DataSeeder.java<br>│   │   ├── controller/<br>│   │   │   └── ProductDashboardController.java<br>│   │   ├── dto/<br>│   │   │   └── DashboardResponse.java<br>│   │   ├── entity/<br>│   │   │   └── Product.java<br>│   │   ├── repository/<br>│   │   │   └── ProductRepository.java<br>│   │   └── service/<br>│   │       └── ProductService.java<br>│   ├── src/main/resources/application.yml<br>│   └── pom.xml<br>├── reactive-service/                # Spring WebFlux (port 8081)<br>│   ├── src/main/java/.../reactive/<br>│   │   ├── ReactiveServiceApplication.java<br>│   │   ├── DataSeeder.java<br>│   │   ├── controller/<br>│   │   │   └── ProductDashboardController.java<br>│   │   ├── dto/<br>│   │   │   └── DashboardResponse.java<br>│   │   ├── entity/<br>│   │   │   └── Product.java<br>│   │   ├── repository/<br>│   │   │   └── ProductRepository.java<br>│   │   └── service/<br>│   │       └── ProductService.java<br>│   ├── src/main/resources/application.yml<br>│   └── pom.xml<br>├── k6-test/<br>│   └── benchmark.js<br>├── pom.xml<br>└── README.md</pre><h3>Conclusion</h3><p>The numbers tell a clear story for this kind of workload: reactive wins at high concurrency with slow IO, and it’s not close. 40x throughput difference at 500 users is hard to argue with.</p><p>But I’d push back on using that as justification to go reactive everywhere. A standard CRUD service doesn’t need an event loop — it needs JPA, readable stack traces, and a team that can fix bugs quickly without a Reactor deep-dive every time something breaks in production.</p><p>The honest answer is: if your service fans out to multiple slow downstream APIs under high concurrency, reactive is the better fit. If it’s a business application backed by a relational database with moderate traffic, blocking Spring MVC is still excellent and far easier to maintain.</p><p>Virtual threads in Java 21 are worth watching — they let you write sequential code without blocking OS threads, which narrows this gap considerably. But that’s a benchmark for another day.</p><p>Run this against your own downstream latency profile before making any call. Local results on a laptop are directionally useful, not gospel.</p><p>Benchmark results can reveal performance characteristics, but production architecture decisions require a deeper understanding of workload behavior, scalability requirements, and operational trade-offs. Whether you’re evaluating Spring MVC, WebFlux, virtual threads, or broader application modernization initiatives, <a href="https://www.simform.com/">Simform</a> helps organizations design, optimize, and scale Java-based systems using modern engineering and cloud-native practices.</p><blockquote><em>Explore more articles on the Simform engineering blog for in-depth technical analyses, performance benchmarks, implementation patterns, and lessons learned from building and scaling real-world software systems.</em></blockquote><blockquote><strong>For more updates, connect with us on </strong><a href="https://twitter.com/simform"><strong>Twitter</strong></a><strong> and </strong><a href="https://www.linkedin.com/company/simform/"><strong>LinkedIn</strong></a></blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5d2b294be26a" width="1" height="1" alt=""><hr><p><a href="https://medium.com/simform-engineering/reactive-vs-blocking-real-benchmark-results-5d2b294be26a">Reactive vs Blocking: Real Benchmark Results</a> was originally published in <a href="https://medium.com/simform-engineering">Simform Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[OpenTelemetry Java SDK — Standardized Tracing & Metrics]]></title>
            <link>https://medium.com/simform-engineering/opentelemetry-java-sdk-standardized-tracing-metrics-a58fdd149867?source=rss----ce67e0b67c0d---4</link>
            <guid isPermaLink="false">https://medium.com/p/a58fdd149867</guid>
            <category><![CDATA[opentelemetry]]></category>
            <category><![CDATA[otel]]></category>
            <category><![CDATA[standardized-tracing]]></category>
            <category><![CDATA[opentelemetry-collector]]></category>
            <dc:creator><![CDATA[Vidit Sampat]]></dc:creator>
            <pubDate>Tue, 16 Jun 2026 06:28:41 GMT</pubDate>
            <atom:updated>2026-06-16T06:28:40.300Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Lqq52o5X5J5BhaJCvisEYw.png" /></figure><h3>A practical guide to vendor-neutral observability for Spring Boot services</h3><blockquote><strong><em>At a glance</em></strong><em> — two Spring Boot services, the OpenTelemetry Java SDK, and a local Collector that feeds Jaeger, Prometheus, and Grafana. One order request becomes a single distributed trace that crosses both services with the </em>same<em> trace ID. Full runnable source is in the companion repository; the </em><a href="https://github.com/backend-simformsolutions/java-blog-poc-opentelemetry-java-sdk/blob/otel-impl/README.md"><em>README</em></a><em> carries the technical detail.</em></blockquote><h3>Topic Overview</h3><p>Distributed systems generate three signals worth caring about: <strong>traces, metrics, and logs</strong>. When a single user request fans out across a handful of services, a stack trace from any one of them tells you almost nothing on its own. You need to follow the request as it threads through every hop, correlate latency spikes with throughput, and pin error rates to the exact deployment that introduced them. That is the job of observability.</p><p>The historical problem is fragmentation. Jaeger shipped its own client. Zipkin had Brave. Datadog, New Relic, and AppDynamics each shipped proprietary agents with their own APIs, their own propagation headers, and their own mental models. Switching vendors meant rewriting instrumentation across every service.</p><p>OpenTelemetry, a CNCF graduated project, collapses that fragmentation into a single API and SDK. You instrument once. You export to whichever backend you choose, and you can switch backends without touching application code.</p><p>By the end of this article you will have built two Spring Boot microservices that:</p><ul><li>Emit traces and custom metrics through the OpenTelemetry Java SDK</li><li>Propagate W3C Trace Context across HTTP calls</li><li>Export everything via OTLP to a local OpenTelemetry Collector</li><li>Surface traces in Jaeger and metrics in Prometheus and Grafana</li></ul><p>The companion GitHub repository contains a runnable demo. Clone it, run docker compose up, start each service with</p><p>mvn spring-boot:run, and watch a distributed trace cross service boundaries in real time.</p><h3>Features</h3><ul><li><strong>Unified API for traces, metrics, and logs.</strong> A single set of interfaces (Tracer, Meter, Logger) covers all three signals, so you do not need a different library per signal.</li><li><strong>Vendor-neutral OTLP export.</strong> The OpenTelemetry Protocol is the wire format. Any backend that speaks OTLP — Jaeger, Tempo, Honeycomb, Datadog, New Relic, Dynatrace — consumes your data without code changes.</li><li><strong>Context propagation.</strong> Built-in support for W3C Trace Context and Baggage headers means traces stitch together cleanly across HTTP, gRPC, and messaging boundaries.</li><li><strong>Auto-instrumentation via the Java agent.</strong> Attach a single -javaagent JAR and the agent instruments JDBC, Spring MVC, Kafka, Redis, and roughly 100 other libraries with no code changes.</li><li><strong>Manual instrumentation via the SDK.</strong> When auto-instrumentation is not enough, the SDK exposes a clean API to create spans, record attributes and events, and emit metrics with full control.</li><li><strong>Resource attributes and semantic conventions.</strong> Every signal carries identity (service.name, service.version, deployment.environment, host metadata) following the OpenTelemetry semantic conventions, which keeps backends consistent across teams.</li><li><strong>Pluggable exporters.</strong> Swap OTLP gRPC for OTLP HTTP, Prometheus pull, Jaeger Thrift, or a no-op debug exporter without rewriting application code.</li><li><strong>Sampling strategies.</strong> Choose AlwaysOn, AlwaysOff, TraceIdRatioBased, or ParentBased samplers. Production systems typically use head-based parent sampling at a fixed ratio and defer tail-based sampling to the Collector.</li></ul><h3>What we build</h3><p>Two services, deliberately small so the observability is the star:</p><ul><li><strong>order-service</strong> (:8080) — accepts POST /api/orders, then calls inventory over HTTP.</li><li><strong>inventory-service</strong> (:8081) — reserves stock; any SKU starting with OOS- is &quot;out of stock&quot;.</li></ul><p>Both emit telemetry through the OpenTelemetry Java SDK over OTLP to a local <strong>Collector</strong>, which routes traces to <strong>Jaeger</strong> and metrics to <strong>Prometheus</strong> (visualised in <strong>Grafana</strong>).</p><pre>order-service ──HTTP (with W3C traceparent)──▶ inventory-service<br>      │                                               │<br>      └──────────── OTLP ──▶ Collector ──▶ Jaeger (traces) + Prometheus/Grafana (metrics)</pre><p>By the end you can place an order and watch the request thread through both services as <strong>one trace</strong>, see the same trace_id on every log line in both processes, and query business metrics like order throughput and p95 latency.</p><h3>Project layout</h3><pre>java-blog-poc-opentelemetry-java-sdk/<br>├── pom.xml                       # parent: pins OTel instrumentation BOM (+ alpha BOM)<br>├── BLOG.md                       # the published blog (Markdown source)<br>├── README.md<br>├── LICENSE<br>├── docker/<br>│   ├── docker-compose.yml        # Collector + Jaeger + Prometheus + Grafana<br>│   ├── otel-collector-config.yaml<br>│   ├── prometheus.yml<br>│   └── grafana/provisioning/datasources/datasource.yml<br>├── otel-common/                  # shared instrumentation library (plain JAR)<br>│   └── src/main/java/com/simform/otel/common/<br>│       ├── annotation/Traced.java<br>│       ├── aspect/TracingAspect.java<br>│       └── config/OtelCommonAutoConfiguration.java<br>│   └── src/main/resources/META-INF/spring/...AutoConfiguration.imports<br>├── order-service/                # port 8080 — entry point, calls inventory-service<br>│   └── src/main/java/com/simform/otel/order/<br>│       ├── controller/OrderController.java<br>│       ├── service/OrderService.java        # @Traced + Span.current() + custom metrics<br>│       ├── service/InventoryClient.java     # explicit W3C context propagation<br>│       └── config/OpenTelemetryConfig.java  # manual-otel profile (optional)<br>└── inventory-service/            # port 8081 — reserves stock<br>    └── src/main/java/com/simform/otel/inventory/<br>        ├── controller/InventoryController.java<br>        └── service/InventoryService.java    # @Traced + manual ERROR status for OOS</pre><h3>Architecture</h3><pre>+----------------+   HTTP + W3C traceparent   +-------------------+<br>|  order-service | -------------------------&gt; | inventory-service |<br>|   (port 8080)  |                            |    (port 8081)    |<br>+--------+-------+                            +---------+---------+<br>         |                                              |<br>         |  OTLP gRPC :4317                             |  OTLP gRPC :4317<br>         v                                              v<br>                    +-------------------------+<br>                    | OpenTelemetry Collector |<br>                    +-----+-------------+-----+<br>                  traces  |             |  metrics (:8889 scrape)<br>                          v             v<br>                    +-----------+   +---------------+<br>                    |  Jaeger   |   |  Prometheus   |<br>                    | (UI 16686)|   |  (UI :9090)   |<br>                    +-----------+   +-------+-------+<br>                                            |<br>                                            v<br>                                       +---------+<br>                                       | Grafana |<br>                                       | (:3000) |<br>                                       +---------+</pre><h3>Prerequisites</h3><ul><li>JDK 17 (a 17–23 JDK; <strong>not</strong> 24/25 — see the version table above)</li><li>Maven 3.9+</li><li>Docker + Docker Compose v2</li></ul><h3>Quickstart</h3><pre># 1. Start the observability backend (Collector + Jaeger + Prometheus + Grafana)<br>cd docker<br>docker compose up -d<br>cd ..<br><br># 2. Build all modules with a JDK 17 toolchain<br>#    (example: export JAVA_HOME to a Corretto/Temurin 17 install first)<br>mvn clean package -DskipTests<br><br># 3. Run inventory-service (terminal A) - start this first; order-service calls it<br>java -jar inventory-service/target/inventory-service.jar<br>#    or: cd inventory-service &amp;&amp; mvn spring-boot:run<br><br># 4. Run order-service (terminal B)<br>java -jar order-service/target/order-service.jar<br>#    or: cd order-service &amp;&amp; mvn spring-boot:run<br><br># 5. Generate traffic (terminal C)<br>curl -X POST http://localhost:8080/api/orders \<br>  -H &#39;Content-Type: application/json&#39; \<br>  -d &#39;{&quot;sku&quot;:&quot;SKU-001&quot;,&quot;quantity&quot;:2}&#39;</pre><p>The one thing that matters: shared context</p><p>If you take away a single idea, take this one. The value of distributed tracing is entirely about <strong>context propagation</strong> — the trace identity travelling <em>with</em> the request across the network. Get it right and a request that spans five operations across two services renders as one clean waterfall:</p><pre>POST /api/orders               order-service       ← HTTP server span (automatic)<br>  OrderService.createOrder     order-service       ← business span<br>    InventoryClient.reserve    order-service       ← outbound call span<br>      POST /api/inventory/reserve   inventory-service   ← continues the SAME trace<br>        InventoryService.reserve    inventory-service   ← business span</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vCsMKP2ggsK6wSNc8zf-Uw.png" /></figure><p><strong>Error Traces in Jaeger:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qOdyg56Yi49nHICqAvnrRQ.png" /></figure><p>Get it wrong and you get two disconnected traces, each blind to the other — the exact failure that makes people give up on tracing. The mechanics are simple: the caller writes a W3C traceparent header onto the outgoing request; the callee reads it and continues the trace instead of starting a new one. On the receiving side a Spring Boot service does this for you automatically. On the <strong>sending</strong> side, the catch is that the HTTP client only propagates if it is actually instrumented — so in this demo we inject the header explicitly and leave nothing to chance. (The <a href="https://github.com/backend-simformsolutions/java-blog-poc-opentelemetry-java-sdk/blob/otel-impl/README.md">README</a> shows the five-line interceptor that does it.)</p><h3>Business metrics, not just system metrics</h3><p>Traces tell you about one request; metrics tell you about all of them. The services record a few custom instruments through the same SDK:</p><ul><li><strong>orders created</strong> — a counter, broken down by SKU.</li><li><strong>order processing duration</strong> — a histogram, so you get real p50/p95/p99, not a misleading average.</li><li><strong>inventory reservations</strong> — a counter labelled by SKU and success/failure.</li></ul><p>These land in Prometheus as standard time series (orders_created_total, orders_processing_duration_milliseconds_bucket, inventory_reservations_total) and drive Grafana panels for throughput and latency. A few example queries live in the <a href="https://github.com/backend-simformsolutions/java-blog-poc-opentelemetry-java-sdk/blob/otel-impl/README.md">README</a>.</p><h3>Why teams choose OpenTelemetry</h3><ul><li><strong>No vendor lock-in.</strong> Switching backends is a Collector config change, not a re-instrumentation project.</li><li><strong>One API for the whole fleet.</strong> Developers learn it once; on-call engineers read consistent data everywhere.</li><li><strong>Auto + manual, side by side.</strong> The Spring Boot starter instruments HTTP, JDBC, and more for free; the SDK is there when you need a custom span or metric.</li><li><strong>Multi-signal correlation.</strong> Traces, metrics, and logs share IDs, so a latency alert is one hop from the exact trace and the exact log line.</li><li><strong>Cost control at the edge.</strong> The Collector samples, batches, drops, and routes data before it reaches a paid backend.</li><li><strong>Future-proof.</strong> Logs are a first-class signal and profiling is stabilising; the same instrumentation collects them as they land.</li></ul><h3>Where it fits</h3><ul><li><strong>End-to-end tracing across microservices</strong> — find the slow hop in a multi-service request without correlating timestamps by hand.</li><li><strong>SLA and latency monitoring</strong> — histogram metrics give honest percentiles for dashboards and alerts.</li><li><strong>Cross-service error debugging</strong> — the trace shows the failing downstream span, its exception, and your business attributes.</li><li><strong>Migrating off a proprietary APM</strong> — run OpenTelemetry alongside the old agent, compare dashboards, then cut over with application code untouched.</li></ul><h3>A note on what’s not worth it</h3><p>OpenTelemetry shines once a request crosses a process boundary. For a single monolith you are already happy observing through one APM, or a brand-new prototype where any instrumentation is premature, hold off. Everywhere else — anything that fans out across services — the payoff is immediate and the lock-in is gone.</p><h3>Conclusion</h3><p>OpenTelemetry has settled what tracing and metrics should look like on the JVM. The Java SDK is stable, the Spring Boot starter covers the libraries that matter, and the Collector gives you the operational freedom to send data anywhere. The demo in this repo shows the whole loop: instrument with one annotation, propagate context across a real HTTP boundary, and watch a single request light up across two services in Jaeger — with metrics in Prometheus and Grafana, and the same trace_id on every log line.</p><p>Clone it, run docker compose up, start both services, and place an order. The README walks through every screen and the technical choices behind them.</p><p>Building observability is often easier in a proof of concept than in production. As systems grow, teams need consistent instrumentation standards, distributed tracing strategies, telemetry governance, and cost-effective data collection across hundreds of services. <a href="https://www.simform.com/">Simform</a> helps engineering teams design and implement scalable observability platforms using OpenTelemetry, cloud-native monitoring tools, and modern platform engineering practices.</p><p><em>Companion repository: </em><a href="https://github.com/backend-simformsolutions/java-blog-poc-opentelemetry-java-sdk">https://github.com/backend-simformsolutions/java-blog-poc-opentelemetry-java-sdk</a><em>. Built with Spring Boot 3.4, the OpenTelemetry Java SDK, and a Docker Compose stack of the OpenTelemetry Collector, Jaeger, Prometheus, and Grafana.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a58fdd149867" width="1" height="1" alt=""><hr><p><a href="https://medium.com/simform-engineering/opentelemetry-java-sdk-standardized-tracing-metrics-a58fdd149867">OpenTelemetry Java SDK — Standardized Tracing &amp; Metrics</a> was originally published in <a href="https://medium.com/simform-engineering">Simform Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>