<?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 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>
