<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Denis Toropov</title>
    <description>The latest articles on DEV Community by Denis Toropov (@denis_toropov).</description>
    <link>https://dev.to/denis_toropov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3951276%2F81e2d775-315a-4bbf-b424-898ec9b14e2f.png</url>
      <title>DEV Community: Denis Toropov</title>
      <link>https://dev.to/denis_toropov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/denis_toropov"/>
    <language>en</language>
    <item>
      <title>When @Cacheable caches not the data, but the problem</title>
      <dc:creator>Denis Toropov</dc:creator>
      <pubDate>Thu, 08 Oct 2026 17:53:09 +0000</pubDate>
      <link>https://dev.to/denis_toropov/when-cacheable-caches-not-the-data-but-the-problem-13mg</link>
      <guid>https://dev.to/denis_toropov/when-cacheable-caches-not-the-data-but-the-problem-13mg</guid>
      <description>&lt;p&gt;A few days ago, I came across an unusual bug came from &lt;code&gt;@Cacheable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The method looked harmless: it returned a collection, and we cached the result. That method was used in several places. Some callers only read the collection. Others modified it - removing items, reordering it, or adding extra data for their own flow.&lt;/p&gt;

&lt;p&gt;That worked fine until caching turned the returned collection into shared state.&lt;/p&gt;

&lt;p&gt;After &lt;code&gt;@Cacheable&lt;/code&gt;, callers were no longer getting a freshly built result. They were often getting the same object from the cache. So once one code path changed that collection, the cached value itself was changed. The next caller could receive an already mutated version.&lt;/p&gt;

&lt;p&gt;In a multi-user system, this gets ugly fast. The bug looks random. One request changes something “locally,” another request sees unexpected data, and nothing in the logs clearly points to the real cause.&lt;/p&gt;

&lt;p&gt;The issue was not the cache itself. The issue was caching a mutable object and then letting different parts of the code treat it as their own copy.&lt;/p&gt;

&lt;p&gt;Since then, I’ve been much more careful with cached method results.&lt;/p&gt;

&lt;p&gt;If a method is annotated with &lt;code&gt;@Cacheable&lt;/code&gt;, I treat its return value as read-only.&lt;/p&gt;

&lt;p&gt;In practice, that usually means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;return &lt;code&gt;List.copyOf(...)&lt;/code&gt; or &lt;code&gt;Set.copyOf(...)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;avoid exposing collections that callers may modify&lt;/li&gt;
&lt;li&gt;create a defensive copy if the ownership boundary is not obvious&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A small optimization introduced shared mutable state across requests. That was the whole bug.&lt;/p&gt;

&lt;p&gt;A good reminder that caching changes more than performance. It also changes the contract of a method.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>bug</category>
      <category>debugging</category>
      <category>java</category>
    </item>
    <item>
      <title>When microservices make data harder than it needs to be</title>
      <dc:creator>Denis Toropov</dc:creator>
      <pubDate>Sun, 04 Oct 2026 12:23:28 +0000</pubDate>
      <link>https://dev.to/denis_toropov/when-microservices-make-data-harder-than-it-needs-to-be-165g</link>
      <guid>https://dev.to/denis_toropov/when-microservices-make-data-harder-than-it-needs-to-be-165g</guid>
      <description>&lt;p&gt;Datadog’s recent research captures a reality many teams already feel in practice: once companies move to microservices, their database landscape tends to fragment quickly. Based on data from more than 2.5 million services, Datadog found that more than half of organizations use three or more database technologies, about a quarter use five or more, and nearly half run SQL and NoSQL side by side.&lt;sup&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;That trend makes sense at scale. Different services have different workloads, and large organizations often have enough platform maturity to support multiple storage models. But the same pattern is much harder to justify in small companies. In many cases, teams adopt several databases not because the business truly needs them, but because microservices make local decisions easier than system-wide ones.&lt;/p&gt;

&lt;p&gt;The result is a familiar architectural trade-off. Teams gain service autonomy, but they lose data simplicity.&lt;/p&gt;

&lt;p&gt;In a monolith, the shared database is often a bottleneck for team independence. Schema changes require coordination, coupling grows over time, and one database has to satisfy many use cases. But there is also a major upside: joins, referential integrity, and transactional consistency stay close to the data. Complex business questions can often be answered inside one engine.&lt;/p&gt;

&lt;p&gt;Microservices change that. As Datadog puts it, organizations replace one global schema with hundreds or even thousands of smaller schemas owned by individual services.&lt;sup&gt;&lt;/sup&gt; That improves isolation, but it also makes cross-domain queries harder. Customer data lives in one place, orders in another, payments somewhere else, and support history in yet another store. The business still needs a unified view, but the data is no longer naturally unified.&lt;/p&gt;

&lt;p&gt;This is where many teams discover that the join never disappeared. It just moved up the stack.&lt;/p&gt;

&lt;p&gt;Datadog points to GraphQL as one way organizations handle this problem. Instead of joining data inside a relational database, they assemble it in an integration layer that queries multiple services and data stores behind a single schema.&lt;sup&gt;&lt;/sup&gt; That works, but it comes with real cost. According to Datadog, 55% of GraphQL executions include more than 10 child resolve spans, and some services handle more than 100 resolves per execution.&lt;sup&gt;&lt;/sup&gt; The median &lt;code&gt;graphql.execute&lt;/code&gt; lasts about 200 ms, while individual field resolutions are closer to 40 ms, showing how query depth and resolver fan-out add latency even when work is parallelized.&lt;sup&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;This is an important architectural point. A database join and a distributed join are not equivalent. A relational database resolves joins close to the data, inside one execution engine. A GraphQL layer has to orchestrate calls across service boundaries, manage partial failures, control N+1 query behavior, and hide inconsistent latency from the client. It can be done well, but it is undeniably more complex.&lt;/p&gt;

&lt;p&gt;For small companies, that complexity is often disproportionate. A team with limited platform capacity now has to operate multiple databases, maintain integration logic, and solve analytics separately. Datadog notes that analytics becomes harder in microservice environments because data from distributed systems has to be transformed and consolidated into OLAP platforms such as Snowflake, Redshift, Databricks, BigQuery, or ClickHouse; 44% of organizations in their data already use at least one such platform.&lt;sup&gt;&lt;/sup&gt; Service integration gets harder too, which helps explain why nearly 70% of customers use message queues to decouple services asynchronously.&lt;sup&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;None of this means microservices are a mistake. It means they should not automatically lead to polyglot persistence. For a smaller company, a standard relational database plus a small number of carefully justified supporting technologies is often the better choice. PostgreSQL for transactional data, Redis for caching, Kafka only when event streaming is truly needed—this is usually easier to scale organizationally than letting every service pick its own storage engine.&lt;/p&gt;

&lt;p&gt;The main lesson from Datadog’s research is not that using many databases is modern. It is that fragmentation has become common, and common is not the same as optimal. Large companies may benefit from specialized databases because they have the scale, workloads, and platform teams to support them. Smaller companies often inherit the downside first: more schemas, harder joins, more integration layers, and more operational surface area.&lt;/p&gt;

&lt;p&gt;So the practical question is not whether a service could choose its own database. It is whether the organization can afford the data integration problem that follows.&lt;/p&gt;

&lt;p&gt;If the answer depends on GraphQL layers, resolver batching, warehouses, queues, and custom aggregation logic, then the cost of database diversity is already showing up. At that point, the simplest architecture may not be the most fashionable one. It may be the one with fewer databases.&lt;/p&gt;

&lt;p&gt;Source: Datadog, &lt;em&gt;How microservice architectures have shaped the usage of database technologies&lt;/em&gt;.&lt;sup&gt;&lt;/sup&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Technical debt: why it appears and how to handle it systematically</title>
      <dc:creator>Denis Toropov</dc:creator>
      <pubDate>Wed, 23 Sep 2026 19:23:32 +0000</pubDate>
      <link>https://dev.to/denis_toropov/technical-debt-why-it-appears-and-how-to-handle-it-systematically-1k2g</link>
      <guid>https://dev.to/denis_toropov/technical-debt-why-it-appears-and-how-to-handle-it-systematically-1k2g</guid>
      <description>&lt;p&gt;Technical debt usually gets attention in two situations: when releases become painful, or when even a small change starts costing far more than it should. Until then, it is often treated as background noise: some dependencies are outdated, some areas have no tests, there are known bugs, documentation is weak, but overall the system still works.&lt;/p&gt;

&lt;p&gt;That is exactly why it becomes dangerous. Technical debt rarely arrives as one major event. It accumulates through a series of small decisions that look reasonable at the time. A library upgrade gets postponed because a feature matters more right now. Performance work is delayed because the SLA is still acceptable. Tests are skipped to hit a release date. Documentation is left for later. After a while, the team is no longer gaining speed from those compromises. It is paying for them in every sprint.&lt;/p&gt;

&lt;p&gt;The main mistake is treating technical debt as something abstract. If it is not captured in tasks, estimated, and brought into planning, it effectively does not exist. And if it does not exist in planning, it keeps growing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What technical debt means in real development
&lt;/h2&gt;

&lt;p&gt;Without the polished definitions, technical debt is everything that makes changes to a system more expensive and more risky than they need to be.&lt;/p&gt;

&lt;p&gt;This is not only about code. Debt can sit in architecture, test coverage, dependencies, documentation, user workflows, and even in the operational processes around the system. The real sign of debt is not that something looks messy. It is that the team keeps paying for it through time, defects, manual effort, or elevated risk.&lt;/p&gt;

&lt;p&gt;A few phrases usually show that debt has already become part of daily work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“better not touch that module”&lt;/li&gt;
&lt;li&gt;“release carefully, there are no tests there”&lt;/li&gt;
&lt;li&gt;“yes, the library is old, but upgrading it is risky”&lt;/li&gt;
&lt;li&gt;“that bug is known, just use the workaround”&lt;/li&gt;
&lt;li&gt;“ask Ivan if you need it, he is the only one who knows how it works”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once this becomes normal, the debt is no longer affecting only the codebase. It is affecting the team’s delivery speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Outdated library versions
&lt;/h2&gt;

&lt;p&gt;One of the most common sources of technical debt is old dependencies and framework versions that stay untouched for years because the system is still running.&lt;/p&gt;

&lt;p&gt;The problem is that this kind of stability is usually misleading. As long as the library is not blocking work today, the upgrade gets pushed back. Then one day the project is sitting on an unsupported version, security fixes are no longer available, newer parts of the ecosystem are incompatible, and moving to a supported baseline turns into a separate migration effort.&lt;/p&gt;

&lt;p&gt;The longer the upgrade is delayed, the more expensive it becomes. There is a major difference between moving one minor version forward and jumping across several major versions. The first is a normal engineering task. The second often becomes a risky migration with regression concerns, API compatibility issues, configuration changes, and hard-to-predict side effects.&lt;/p&gt;

&lt;p&gt;There is also a quieter risk: hidden constraints begin to pile up. The team can no longer adopt a new observability agent, a newer JDBC driver, a Kafka client update, or a security patch without triggering a chain of dependent upgrades. On paper the issue is just an old library. In practice it becomes a constraint on the system’s evolution.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Poor performance
&lt;/h2&gt;

&lt;p&gt;Technical debt is not only about code maintainability. Performance degradation becomes debt very quickly, especially when the team gets used to it and starts treating it as normal.&lt;/p&gt;

&lt;p&gt;In most systems, it does not begin with a disaster. It starts with small compromises. One heavy query. One extra synchronous service call. One place where caching was skipped. One batch job that runs longer than expected. While the load is moderate, the system survives. Then the data volume grows, more users arrive, more integrations are added, and it becomes clear that the safety margin disappeared a long time ago.&lt;/p&gt;

&lt;p&gt;At that point, performance stops being a narrow technical topic. It starts affecting the business directly: users wait longer, batch jobs no longer fit into their windows, infrastructure costs go up, and the team hesitates to add new scenarios because the system is already close to its limit.&lt;/p&gt;

&lt;p&gt;The worst case is when there are no baseline metrics and no history of degradation. If the team does not know its p95 or p99, does not understand the load profile, and cannot point to actual bottlenecks, every conversation about performance turns into guesswork. That is already technical debt: the problem exists, but it is not described in a way the team can manage.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Lack of tests
&lt;/h2&gt;

&lt;p&gt;A lack of tests is one of the most expensive forms of debt because it affects every change, not just one specific part of the system.&lt;/p&gt;

&lt;p&gt;For a while, a system without tests can look faster to build. But that gain does not last. Over time, every change demands more manual checking, more coordination, and more caution. Refactoring becomes risky. Releases become stressful. A bug fix in one area can create a new defect in another.&lt;/p&gt;

&lt;p&gt;The issue is not limited to the absence of unit tests. A weak test setup also creates debt. For example:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;What it turns into&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Very few tests&lt;/td&gt;
&lt;td&gt;regressions are found after release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only integration tests&lt;/td&gt;
&lt;td&gt;checks are too slow, so the team runs them inconsistently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fragile tests&lt;/td&gt;
&lt;td&gt;people stop trusting them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests tied to implementation rather than behavior&lt;/td&gt;
&lt;td&gt;any refactoring breaks a large part of the suite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No coverage for critical business flows&lt;/td&gt;
&lt;td&gt;the most important scenarios still rely on manual verification&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the end, a team without tests does not just have weaker quality control. It loses the ability to change the system safely. That directly affects delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Bugs and known defects
&lt;/h2&gt;

&lt;p&gt;Not every bug is technical debt. But a bug that everyone knows about, nobody fixes, and people work around on a daily basis almost always becomes part of it.&lt;/p&gt;

&lt;p&gt;That kind of defect rarely lives only in the code. It usually grows its own support structure around it: manual checks, special instructions for support teams, extra business steps, repeated operations, and exceptions in process rules. From the outside, it may look manageable. In reality, the team keeps paying for it.&lt;/p&gt;

&lt;p&gt;The most dangerous cases are the defects people get used to. They stop feeling urgent because they do not look like an outage. But those are often the issues that drain time for years: support handles the same cases again and again, analysts build new processes around the limitation, and developers remember that one fragile area must be treated carefully.&lt;/p&gt;

&lt;p&gt;If a bug already has an oral workaround, that is usually enough reason to move it into the technical debt backlog instead of leaving it in a loose list of known issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Documentation
&lt;/h2&gt;

&lt;p&gt;Poor documentation is easy to ignore in a small team where everyone already shares context. But once the system grows, new people join, integrations multiply, audits happen, incidents occur, or several teams need to work in parallel, missing documentation starts slowing work down as much as bad code does.&lt;/p&gt;

&lt;p&gt;The issue is not simply that there is nothing to read. The issue is dependence on individual memory. Architectural constraints, past decisions, integration quirks, hidden contracts, and operational details live only in the heads of a few engineers. As long as those people are around, the system looks manageable. The moment someone is on vacation, leaves the project, or is unavailable during an incident, the cost of that approach becomes obvious.&lt;/p&gt;

&lt;p&gt;Documentation debt does not mean everything must be written down. In practice, it is usually enough to keep a few critical layers up to date:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the architecture view and service boundaries&lt;/li&gt;
&lt;li&gt;key integrations and contracts&lt;/li&gt;
&lt;li&gt;a decision log for important technical choices&lt;/li&gt;
&lt;li&gt;instructions for running, supporting, and troubleshooting the system&lt;/li&gt;
&lt;li&gt;constraints that engineers must remember before making changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not paperwork for its own sake. It is a way to transfer context and reduce bus factor.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. User guide
&lt;/h2&gt;

&lt;p&gt;This is often not treated as technical debt at all, especially in internal enterprise systems. In practice, though, the absence of a user guide creates measurable losses.&lt;/p&gt;

&lt;p&gt;When users do not understand how to work with the system, the load shifts to support, analysts, and developers. Explanations move into chat threads, short calls, screen-sharing sessions, forwarded outdated instructions, and manual guidance for new scenarios. If the product is complex, such as a CRM, MDM platform, internal operational tool, or enterprise workflow system, those losses become visible very quickly.&lt;/p&gt;

&lt;p&gt;There is also a second effect: changes become more expensive. Any change in the interface or process requires additional communication because users do not have a stable reference point in the form of current guidance.&lt;/p&gt;

&lt;p&gt;A user guide is not cosmetic. In a mature product, it is part of the operating model. If it does not exist, the system starts depending on a live translator between functionality and the end user. That is a valid item for the technical debt backlog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why technical debt needs to be quantified
&lt;/h2&gt;

&lt;p&gt;As long as technical debt lives in phrases like “everything is outdated here,” “this is a bottleneck,” or “that part is dangerous,” it will usually lose to product work during prioritization.&lt;/p&gt;

&lt;p&gt;Not because product work is always more important. It loses because product work is usually described better: it has a goal, an expected effect, a deadline, and an outcome. Technical debt often has only team frustration attached to it.&lt;/p&gt;

&lt;p&gt;To make debt manageable, it has to be converted into concrete units. A minimal template usually looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;What to capture&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Area&lt;/td&gt;
&lt;td&gt;dependencies, performance, tests, bugs, documentation, user guide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Problem&lt;/td&gt;
&lt;td&gt;what exactly is wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consequence&lt;/td&gt;
&lt;td&gt;what it affects: releases, SLA, support, team speed, risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk&lt;/td&gt;
&lt;td&gt;low / medium / high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Estimate&lt;/td&gt;
&lt;td&gt;hours, days, story points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Success metric&lt;/td&gt;
&lt;td&gt;how the team will know the situation improved&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For example, “we should update the stack” is not useful. “Spring Boot 2.x is no longer supported, some security patches are unavailable, and newer dependency versions are blocked; estimate: 8 working days; result: move to a supported baseline and remove upgrade constraints” is already good enough for planning.&lt;/p&gt;

&lt;p&gt;Quantification is not about reporting for its own sake. It is what allows technical debt to be compared with other debt items and with product work in the same language.&lt;/p&gt;

&lt;h2&gt;
  
  
  A separate technical debt backlog
&lt;/h2&gt;

&lt;p&gt;If technical debt is not kept in a separate list, it almost always dissolves inside the general task queue. In that form it is hard to discuss, hard to rank, and easy to ignore.&lt;/p&gt;

&lt;p&gt;That is why a separate technical debt backlog works better in practice. It should not be isolated from normal planning. It should be part of it.&lt;/p&gt;

&lt;p&gt;Each item in that backlog should have at least three things: a clear description of the problem, an estimate, and a reason for its priority. For prioritization, a few simple criteria are usually enough:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Business impact&lt;/td&gt;
&lt;td&gt;does it affect customers, operations, or SLA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery impact&lt;/td&gt;
&lt;td&gt;does it slow down or destabilize delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk&lt;/td&gt;
&lt;td&gt;can it lead to incidents, defects, vulnerabilities, or data loss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost of delay&lt;/td&gt;
&lt;td&gt;how much more expensive will it be to fix next quarter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Effort&lt;/td&gt;
&lt;td&gt;what is the real cost of doing the work&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This also helps remove emotion from the discussion. Instead of saying “everyone hates this part of the system,” the conversation moves to risk, cost, and impact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example of a technical debt backlog
&lt;/h2&gt;

&lt;p&gt;A working backlog might look like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;th&gt;Estimate&lt;/th&gt;
&lt;th&gt;Expected effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upgrade critical libraries and the framework&lt;/td&gt;
&lt;td&gt;security and compatibility risks have accumulated&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;8 days&lt;/td&gt;
&lt;td&gt;fewer vulnerabilities, easier maintenance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optimize the 5 slowest SQL queries&lt;/td&gt;
&lt;td&gt;p95 is above target SLA&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;5 days&lt;/td&gt;
&lt;td&gt;faster core operations, lower database load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add integration tests for a critical business flow&lt;/td&gt;
&lt;td&gt;regressions keep appearing in releases&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;6 days&lt;/td&gt;
&lt;td&gt;more predictable releases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix known defects with manual workarounds&lt;/td&gt;
&lt;td&gt;support and business teams are compensating in process&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;4 days&lt;/td&gt;
&lt;td&gt;fewer manual operations and support requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Document key architectural decisions and integrations&lt;/td&gt;
&lt;td&gt;too much knowledge depends on a few engineers&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;3 days&lt;/td&gt;
&lt;td&gt;faster onboarding and easier incident analysis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prepare a user guide for the top 5 scenarios&lt;/td&gt;
&lt;td&gt;support load is too high&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;3 days&lt;/td&gt;
&lt;td&gt;fewer repeated questions and user mistakes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At this point, it is no longer just a list of complaints. It becomes a list of tasks with meaning, cost, and expected results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it makes sense to spend 20% of each sprint on debt
&lt;/h2&gt;

&lt;p&gt;Trying to “fully eliminate technical debt first and return to product work later” almost never works. The business is usually not ready to stop product development for months, and without clear boundaries the team can easily drift into endless internal improvements with no visible result.&lt;/p&gt;

&lt;p&gt;That is why reserving part of each sprint for technical debt is a practical approach. Starting with 20% is usually reasonable: not so little that nothing changes, and not so much that product delivery stalls.&lt;/p&gt;

&lt;p&gt;There are several benefits to this.&lt;/p&gt;

&lt;p&gt;First, it makes the work predictable. The team does not need to re-argue every sprint why technical debt deserves time.&lt;/p&gt;

&lt;p&gt;Second, it reduces accumulation. Small, regular improvements are almost always cheaper than occasional large-scale cleanup efforts.&lt;/p&gt;

&lt;p&gt;Third, after a few months the impact becomes visible in numbers: fewer incidents, fewer regressions, faster releases, less manual work, shorter onboarding, and more stable performance.&lt;/p&gt;

&lt;p&gt;Of course, 20% is not a universal number. One team may need only 10 to 15%. Another team, especially in the middle of a heavy migration, may need 25 to 30%. But the principle of reserving capacity works better than relying on the idea that there will be a convenient moment later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to put this into practice
&lt;/h2&gt;

&lt;p&gt;The mechanics are simple.&lt;/p&gt;

&lt;p&gt;Start by collecting known debt items into one list. Do not try to fix everything immediately. Just make it visible. Then record the impact, risk, and estimate for each item. After that, rank the backlog and bring into the sprint work that fits roughly 20% of the team’s capacity.&lt;/p&gt;

&lt;p&gt;The next step is to keep the work measurable. If the team takes on performance debt, it should know which metrics are expected to move. If the work is about tests, it should be clear where regression risk should go down. If the effort is about documentation, it should be clear which dependency on specific people is being reduced.&lt;/p&gt;

&lt;p&gt;One more point matters: the backlog needs regular review. Some items lose importance over time. Others become more urgent. In some cases, an architectural change removes an old debt item entirely. In others, new debt appears that was not visible before.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>codequality</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>When the Same Reference Data Lives in Three Services: Why We Moved It into a Dedicated Service</title>
      <dc:creator>Denis Toropov</dc:creator>
      <pubDate>Sun, 20 Sep 2026 18:32:11 +0000</pubDate>
      <link>https://dev.to/denis_toropov/when-the-same-reference-data-lives-in-three-services-why-we-moved-it-into-a-dedicated-service-2ll6</link>
      <guid>https://dev.to/denis_toropov/when-the-same-reference-data-lives-in-three-services-why-we-moved-it-into-a-dedicated-service-2ll6</guid>
      <description>&lt;p&gt;&lt;em&gt;How to reduce data collisions when three services use different databases but rely on the same business semantics.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In distributed systems, problems rarely begin with something dramatic. On paper, everything usually looks rational: separate services, separate databases, clear responsibility boundaries. But at some point, the system starts arguing with itself.&lt;/p&gt;

&lt;p&gt;I had a case where we had to split balance-related data across three different databases.&lt;/p&gt;

&lt;p&gt;The first database was used by the account display service. The second one powered the service that showed customer-level balances. The third stored current account balances. Each database had its own service because each domain had different read patterns, different performance requirements, and a different data representation model.&lt;/p&gt;

&lt;p&gt;At first, that separation looked reasonable. The real problem was elsewhere: all three services needed the same reference data.&lt;/p&gt;

&lt;p&gt;That was where the real complexity began.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Logic Started Breaking
&lt;/h2&gt;

&lt;p&gt;When the same reference data exists in multiple services, the system almost inevitably starts producing different versions of the truth.&lt;/p&gt;

&lt;p&gt;In one place, a status has already been updated. In another, an old category is still there. In a third, a local mapping was once introduced as a temporary workaround and somehow survived multiple releases. Formally, the data still looks similar. In practice, the business meaning starts to diverge.&lt;/p&gt;

&lt;p&gt;For users, this looks like a simple inconsistency between screens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the same account appears in one category in one view;&lt;/li&gt;
&lt;li&gt;gets aggregated using another attribute in a different view;&lt;/li&gt;
&lt;li&gt;and is displayed with yet another interpretation somewhere else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the engineering team, this is no longer a cosmetic issue. It becomes incidents, manual reconciliation, long investigations, and eventually a loss of trust in the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Initial Setup
&lt;/h2&gt;

&lt;p&gt;At the architecture level, the system looked roughly like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the account service worked with its own database;&lt;/li&gt;
&lt;li&gt;the customer balance service worked with its own database;&lt;/li&gt;
&lt;li&gt;the current account balance service worked with its own database;&lt;/li&gt;
&lt;li&gt;each service used the same reference data: account types, statuses, product attributes, classifiers, and other reference entities.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On paper, this is a standard enterprise architecture pattern. But the moment you have multiple owners of the same business semantics, you start running into questions that are very hard to answer consistently.&lt;/p&gt;

&lt;p&gt;Who is the source of truth for a reference entity? When should an update be considered applied? What happens if one system is already using the new version while another is still on the old one? Who owns the investigation when the same field is interpreted differently across systems?&lt;/p&gt;

&lt;p&gt;If I answer honestly, at that stage we did not have one good answer to those questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Reference Data Is Not “Secondary Data”
&lt;/h2&gt;

&lt;p&gt;This is a common design trap. Reference data is often treated as something secondary: not money, not transactions, not the main operational flow. So teams assume they can simply keep a local copy next to every service and sync it somehow later.&lt;/p&gt;

&lt;p&gt;But in practice, reference data defines how the system interprets the primary data.&lt;/p&gt;

&lt;p&gt;A balance by itself is just a number. Its meaning appears only in context: product type, account status, segment membership, aggregation attributes, and other business qualifiers. If that context differs across services, then even the same balance value stops meaning the same thing to the business.&lt;/p&gt;

&lt;p&gt;At that point, this is no longer a technical inconsistency. It is a semantic inconsistency.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Problems Started Showing Up
&lt;/h2&gt;

&lt;p&gt;The issues did not appear as one major outage. They accumulated as a growing gray zone.&lt;/p&gt;

&lt;p&gt;First, reference data versions drifted asynchronously. One service had already applied a change, another had not, and a third updated through a separate process. As a result, the same entity started looking different across user journeys.&lt;/p&gt;

&lt;p&gt;Second, services developed different refresh rules. One updated on a schedule, another reacted to an event, and a third relied on a separate integration flow. Even if the source reference data is nominally the same, different delivery mechanisms quickly create drift.&lt;/p&gt;

&lt;p&gt;Third, diagnostics became much harder. Any incident of the form “why is it one thing here and another there?” required investigating three databases, three services, three update histories, and often multiple teams. The worst part was that these incidents rarely looked like major failures. They lived as chronic operational pain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Options We Considered
&lt;/h2&gt;

&lt;p&gt;Once it became clear that local duplication of reference data was getting too expensive, we considered several options.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1. Keep Local Copies and Improve Synchronization
&lt;/h3&gt;

&lt;p&gt;The most obvious path was to keep the architecture as is and simply make update delivery more disciplined.&lt;/p&gt;

&lt;p&gt;The benefit is obvious: services keep local data, fast reads, and their familiar integration model.&lt;/p&gt;

&lt;p&gt;The downside is that the ownership problem does not go away. Even with better synchronization, the system still has multiple places where the same reference data can be interpreted and changed differently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2. Create a Shared Reference Database
&lt;/h3&gt;

&lt;p&gt;This is better than having copies in three different places. At least there is formally a single storage layer.&lt;/p&gt;

&lt;p&gt;But this option often creates another problem: services start reading the same schema directly, rules get duplicated in consuming code, and the contract evolves without a clear owner. In the end, you may have one database, but you still do not have one place that actually owns reference data behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 3. Build a Dedicated Reference Data Service
&lt;/h3&gt;

&lt;p&gt;This was the option we ultimately chose.&lt;/p&gt;

&lt;p&gt;The idea was simple: reference data stops being “something shared by everyone” and becomes its own domain with an explicit owner. One service is responsible for storage, versioning, validation, and change publication. The other services stop deciding for themselves which version of a reference entity is the correct one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why We Chose a Dedicated Reference Data Service
&lt;/h2&gt;

&lt;p&gt;The key insight was this: our real problem was not data delivery by itself. It was multiple owners of the same business semantics.&lt;/p&gt;

&lt;p&gt;As long as reference data lives in several services, the system is almost guaranteed to produce local interpretations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one service introduces an extra status;&lt;/li&gt;
&lt;li&gt;another adds a local mapping;&lt;/li&gt;
&lt;li&gt;a third implements a special filtering rule;&lt;/li&gt;
&lt;li&gt;a fourth leaves a temporary workaround in place until it quietly becomes permanent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A dedicated reference data service does not magically make the system simple. But it removes the main source of collisions: multiple decision centers for the same reference entities.&lt;/p&gt;

&lt;p&gt;Put differently, we stopped synchronizing three almost identical truths and started operating with one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed in the Architecture
&lt;/h2&gt;

&lt;p&gt;After we introduced the service, the architecture became conceptually simpler:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the reference data service became the owner of the canonical model;&lt;/li&gt;
&lt;li&gt;the account service, customer balance service, and current balance service stopped owning reference data independently;&lt;/li&gt;
&lt;li&gt;validation, versioning, and update rules were centralized;&lt;/li&gt;
&lt;li&gt;all collisions were investigated against one source of truth rather than three local copies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important nuance is that we centralized ownership, not necessarily every online read.&lt;/p&gt;

&lt;p&gt;If every consumer depends on synchronous runtime calls to the reference data service, then instead of data inconsistency you may end up with cascading degradation when that service is unavailable. In systems like this, the better pattern is usually to centralize the write model and the rules, while shaping the read model according to each service’s load profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Real Reference Data Service Must Do
&lt;/h2&gt;

&lt;p&gt;If you build this service seriously, not as a thin CRUD wrapper over a table, it has several non-negotiable responsibilities.&lt;/p&gt;

&lt;p&gt;First, it must own the model. Not just store data, but define fields, statuses, relationships, constraints, and lifecycle rules.&lt;/p&gt;

&lt;p&gt;Second, it must support versioning. At any moment, a consumer should be able to answer which version of a reference dataset it is currently using.&lt;/p&gt;

&lt;p&gt;Third, it must publish changes predictably. That can be done through APIs, events, snapshots, or a hybrid model, but the delivery mechanism needs to be controlled and consistent.&lt;/p&gt;

&lt;p&gt;Fourth, it must enforce data quality: validation, auditability, deduplication, mandatory field checks, and a transparent change process.&lt;/p&gt;

&lt;p&gt;Fifth, it must be observable. If balances are interpreted through this reference data, then freshness, version, update errors, and consumer lag all become production metrics, not implementation details.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local copies of reference data in each service&lt;/td&gt;
&lt;td&gt;Fast local reads, minimal network dependency, service autonomy&lt;/td&gt;
&lt;td&gt;Data drift, difficult investigations, multiple sources of truth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared reference database&lt;/td&gt;
&lt;td&gt;Formally centralized storage, relatively easy starting point&lt;/td&gt;
&lt;td&gt;Strong schema coupling, high risk of rule leakage into consumers, no explicit contract ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dedicated reference data service&lt;/td&gt;
&lt;td&gt;Centralized ownership, transparent rules, fewer collisions, easier audit and version control&lt;/td&gt;
&lt;td&gt;A new critical component, higher SLA requirements, separate design needed for change distribution&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We chose the third option because in our case the main cost was not read latency. It was the cost of inconsistent data semantics across systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Teams Usually Get It Wrong
&lt;/h2&gt;

&lt;p&gt;The most common mistake is to call something a reference data service when it is really just a database with a REST wrapper.&lt;/p&gt;

&lt;p&gt;If that service does not own the model, has no versioning, no controlled change lifecycle, and no transparent data distribution model, then it does not solve the original problem. It merely moves it somewhere else.&lt;/p&gt;

&lt;p&gt;The second mistake is forcing all consumers to read reference data synchronously and only online. That quickly turns an infrastructure component into a single point of cascading degradation.&lt;/p&gt;

&lt;p&gt;In practice, a hybrid model is usually the right one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a single service owns the reference data;&lt;/li&gt;
&lt;li&gt;changes are published centrally;&lt;/li&gt;
&lt;li&gt;consumers can keep local read-optimized projections where that makes sense for load and latency;&lt;/li&gt;
&lt;li&gt;the right to change and interpret reference data remains centralized.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Decision Log
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Context.&lt;/strong&gt; Three services worked with three separate databases but used the same reference data to interpret balances.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem.&lt;/strong&gt; We were seeing collisions, version drift, and difficult incidents where different systems showed different values or categories for the same entity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Options.&lt;/strong&gt; Keep local copies and improve synchronization; create a shared database; build a dedicated reference data service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision.&lt;/strong&gt; Build a dedicated reference data service as the single owner of reference entities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why.&lt;/strong&gt; The core issue was not only data delivery. It was multiple ownership of the same business semantics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consequences.&lt;/strong&gt; Fewer collisions, simpler investigations, and a clearer evolution path for reference data. The price was a new critical service that had to be designed as part of the core architecture rather than as a supporting utility.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Would Explicitly Build Into This System
&lt;/h2&gt;

&lt;p&gt;If I were designing a similar setup for production from the start, I would explicitly include the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a single writer for reference data;&lt;/li&gt;
&lt;li&gt;explicit versions and publication timestamps;&lt;/li&gt;
&lt;li&gt;freshness controls on the consumer side;&lt;/li&gt;
&lt;li&gt;auditability and clear ownership;&lt;/li&gt;
&lt;li&gt;backward compatibility rules for contract changes;&lt;/li&gt;
&lt;li&gt;observability not only for service availability, but for consumer data freshness as well.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final Takeaway
&lt;/h2&gt;

&lt;p&gt;When multiple services use the same reference data, the problem very quickly stops being about where to store tables. It becomes a question of who owns the meaning of the data.&lt;/p&gt;

&lt;p&gt;In our case, we had three databases, three services, and one shared problem: the same reference data lived in several places and started drifting over time. Moving that logic into a dedicated reference data service did not remove all system complexity, but it removed the most dangerous kind of complexity: competing versions of the truth.&lt;/p&gt;

&lt;p&gt;The short version is this: in a distributed system, you can live with different databases, different services, and different read models. But if reference data defines how the business understands its core data, it needs a single owner.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>distributedsystems</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Transactional Outbox with Kafka: How to Stop Losing Events When Syncing Databases</title>
      <dc:creator>Denis Toropov</dc:creator>
      <pubDate>Thu, 11 Jun 2026 19:07:00 +0000</pubDate>
      <link>https://dev.to/denis_toropov/transactional-outbox-with-kafka-how-to-stop-losing-events-when-syncing-databases-4nbm</link>
      <guid>https://dev.to/denis_toropov/transactional-outbox-with-kafka-how-to-stop-losing-events-when-syncing-databases-4nbm</guid>
      <description>&lt;h1&gt;
  
  
  Transactional Outbox with Kafka: How to Stop Losing Events When Syncing Databases
&lt;/h1&gt;

&lt;p&gt;When you sync data between services (or databases) through Kafka, the classic failure looks like this: &lt;strong&gt;the database transaction commits, but the Kafka message never gets published&lt;/strong&gt; (crash, network issue, timeout). Your systems diverge silently, and you only discover it when a user reports incorrect data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious fixes don’t work
&lt;/h2&gt;

&lt;p&gt;Wrapping Kafka send in retries doesn’t guarantee delivery if the process dies after the DB commit. Sending to Kafka &lt;em&gt;before&lt;/em&gt; committing the DB creates “phantom events” about changes that never made it into the database. Two-phase commit is usually too complex operationally and doesn’t fit Kafka in a clean, universal way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key idea
&lt;/h2&gt;

&lt;p&gt;Make the critical operation &lt;strong&gt;one atomic DB transaction&lt;/strong&gt;: write the business data &lt;em&gt;and&lt;/em&gt; the event to an &lt;code&gt;outbox&lt;/code&gt; table in the same commit. If the update exists, the event exists too.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;In one DB transaction: update domain tables + &lt;code&gt;INSERT&lt;/code&gt; into &lt;code&gt;outbox&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Later: a separate mechanism reads &lt;code&gt;outbox&lt;/code&gt; and publishes to Kafka&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This makes delivery retryable because the event is durably stored&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two ways to deliver events from the outbox
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) Polling Relay
&lt;/h3&gt;

&lt;p&gt;A background worker polls unsent rows (&lt;code&gt;sent_at IS NULL&lt;/code&gt;), publishes them to Kafka, then marks them as sent. It’s simple, reliable, and often enough when sub-second latency isn’t a strict requirement. If you run multiple workers, you must ensure each event is claimed once (typically via row locking / “skip locked” patterns).&lt;/p&gt;

&lt;h3&gt;
  
  
  2) CDC via Debezium
&lt;/h3&gt;

&lt;p&gt;Instead of polling, Debezium streams inserts from the database transaction log (WAL) to Kafka. This reduces latency and removes the need for a poller, but adds infrastructure/ops complexity (Debezium + Kafka Connect).&lt;/p&gt;

&lt;h2&gt;
  
  
  Don’t forget: duplicates will happen
&lt;/h2&gt;

&lt;p&gt;Outbox pipelines are typically &lt;strong&gt;at-least-once&lt;/strong&gt;, so consumers must be idempotent. The most robust approach is an &lt;code&gt;inbox&lt;/code&gt; table with a &lt;strong&gt;unique constraint on &lt;code&gt;event_id&lt;/code&gt;&lt;/strong&gt;: first insert the event id; if it already exists, skip processing. This avoids race conditions that occur with “check then insert”.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;If you run the relay inside every API instance, scaling your API can accidentally scale polling load and hammer the DB. Also, outbox/inbox tables will grow—plan retention/cleanup. Finally, monitor lag: the count of unsent events and the age of the oldest unsent event are simple, high-signal metrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Transactional Outbox&lt;/strong&gt; prevents “DB committed but event lost” by making event creation part of the DB transaction, then reliably delivering events to Kafka via polling or CDC—while consumers protect themselves with idempotency.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>distributedsystems</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
