Engineering
Engineering

Two-way CRM sync without an orchestration monolith

Zac Sheffer · 2026.9.17 · 7 min read

CRM integration one: some workers, some API calls, a mapping table. Done. Integration two copies it. By number three, the copies have diverged, every worker is a thicket of per-CRM branches, and you are maintaining an accidental orchestration monolith nobody designed.

We got there. This is the architecture that replaced it.

What the monolith looked like

Nobody designed a centralized orchestrator. It grew one CRM at a time inside the sync workers.

The shape: branching conditionals. Top of the worker: which CRM is this org connected to? Then: which object type? Which direction? Each combination had its own block. Those blocks started as copies and slowly diverged as CRM quirks demanded different handling. A HubSpot company create, a Salesforce account create, and a Zero company create all did roughly the same thing. But each accumulated its own field mappings, error handling, retry semantics, and definition of "success."

Result: an 800-line worker file with branches that only ran for one CRM but had to be read by anyone touching any CRM. When a bug appeared in outbound sync, the first question was "which CRM?" and the second was "does the same bug exist in the other two branches?" The answer was usually yes. The fix had to be applied three times.

The divergence tax is not hypothetical. Two CRMs carried copies of the same inbound stage-resolution chain. When an update arrived with a stage the mapping did not recognize, the resolver fell back to the pipeline's first stage and quietly rewound about a hundred live deals to the top of the funnel. We fixed the path that fired. The sibling copy had the identical fallback. It took INT-1926 and INT-1929 to find it, because a fix applied to one copy is a fix applied to one copy.

As Chris Richardson describes on microservices.io, a saga can be coordinated two ways: orchestration (central coordinator) or choreography (participants react to events). Our monolith was accidental orchestration with all the coupling that implies.

The split

Two homes. CRM-specific choreography (create-else-update graphs, field-name translation, vendor retry semantics) lives in flows on a managed integration platform: hosted connector infrastructure where each sync direction is a versioned flow. Our code owns what only our product can know:

  1. Which org has sync enabled, for which pipelines, in which directions
  2. Pipeline pairing and stage resolution: which of our stages maps to which CRM stage
  3. Queueing, with the idempotency and coalescing that product-driven bursts require
  4. Persistence of external IDs once the flow reports success
  5. UI events so users see sync status without asking

Phase one had the choreography inline as chained action calls. It worked. That is exactly how monoliths recruit. Phase two moved the graphs into flows. The payoff was immediate: adding the second CRM, then the third, became a checklist instead of a rewrite. Clone the flow set, wire the config, map the stages.

Idempotency requirements

Every sync handler must be idempotent. This sounds obvious until you list the bugs that taught it. (Stripe's idempotent requests documentation describes the same principle for payment APIs.)

Bug 1: duplicate creates. A company sync job creates the company in the CRM, then fails before persisting the external ID. The retry finds no external ID, assumes the company does not exist, creates it again. Customer now has two copies in their CRM. The second has no history.

Fix: a match ladder. Before creating, check whether the record exists by domain, email, or external ID. But the match ladder itself had to be idempotent. Our first version cached match results in memory during the job. A retry started with a cold cache and could reach a different match conclusion. Match results now persist to the job's metadata. A retry sees the same match the first attempt found.

Bug 2: update ordering. Two rapid edits produce two sync jobs. Job B completes first. Job A retries after and overwrites newer data with stale data. Fix: coalescing, described in the Zero integration post. Bursty edits merge into a single job carrying the latest state.

The general rule: if a sync handler can produce a different outcome on retry than on first attempt, it is not idempotent. A non-idempotent handler in a retry-based queue is a state corruption machine that only fires under load.

Thin workers

A sync worker does four things: trigger the flow, poll it to a terminal state, persist the external ID, emit a UI event. That is the whole job. BullMQ's worker documentation describes exactly this model.

Worker-level skips (sync toggled off, org paused, record deleted, stage unmapped) stay in the worker. The flow never sees a job that should not run. Skips return a structured reason rather than throwing. Retrying "sync is disabled" wastes attempts.

Two reliability details. Stage resolution happens before the flow runs, never inside it. An unmapped stage means no sync plus a reason, never a guess written into someone's CRM. And failure events reach the UI only on the final retry attempt. Intermediate retries are the queue doing its job. A status light that flaps on transient errors trains users to ignore it.

What broke during the transition

We ran both paths in parallel for two weeks: old path executing, new path in shadow mode logging what it would have done.

Timing. The old path resolved stages at execution time. The new path resolves at enqueue time. If a stage mapping changed between enqueue and execution, the two paths disagreed. We decided the new behavior was correct: a job should carry a snapshot of the state that justified it, not discover state at execution time. Martin Fowler calls this event-carried state transfer. During the parallel period, every discrepancy required manual review to confirm it was a timing difference and not a logic bug.

Error handling. The old path had accumulated per-CRM error handling over months. The new path started clean with generic handling. A Salesforce "ENTITY_IS_LOCKED" error is retryable but only after a delay. Our generic retry logic treated it as immediately retryable and burned through attempts. We ported per-CRM retry policies from the old code to the flow configuration.

State. The old path had implicit sequencing: if step 2 failed, step 3 never ran. The new path needed explicit rollback or compensation logic.

The two weeks of manual discrepancy review caught three bugs that would have been production incidents.

Mapping ownership

Hybrid with a bright line. We own the semantic layer: pipeline pairs and stage-ID maps, org-scoped, editable in product. The integration platform's field mapping owns the syntactic layer: property-name translation on each CRM's objects.

Two anti-patterns we wrote down after nearly committing both:

  1. Never duplicate the stage maps into platform config. Two copies of a mapping is one copy plus a time bomb.
  2. Never reimplement field mapping in TypeScript. That is the platform's one job. Doing it yourself quietly rebuilds the monolith one convenience at a time.

Direction is config, not architecture

Each pipeline carries independent inbound and outbound toggles plus an org-level kill switch. Because direction is data rather than code, the product offers the rollout teams actually want: run one-way into your CRM until you trust the mapping, then turn on the other direction, per pipeline. The HubSpot and Salesforce integrations both surface this.

Two smaller decisions. Post-OAuth wiring (subscribing to change events, probing the connection) is warn-only: if a probe fails, keep the OAuth, let the user retry. Rolling back a successful auth over a retryable probe is hostile. And manual bulk sync is not a parallel code path. It is the same flow feeding the same ingest pipeline as real-time events.

The ingress half, webhooks arriving from the other direction with tenant resolution and dedup, is the next post. The product-side result: your CRM and your referral layer stay honest with each other without anyone retyping a deal.

We ship in public. Come build with us.

Introzy is free to start. No card required.