Breaking the Monolith - Part 1.5: Two Rule Engines, One Truth

go dev.to

πŸ‘‹ Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go. This is a companion to Part 1 of a series about carefully breaking a large PHP monolith into Go microservices while it keeps serving a real business. Part 1 was a bug story: an API that answered was_updated while the database saved nothing. This part is what came after the fix - the phase where the write logic is being lifted out of PHP into a new service, and for a while both the old engine and the new one are live at the same time. Running notes live on my GitHub: github.com/brilliant-almazov. No hype, just the real work.

There's a moment in a monolith breakup that the tutorials skip. It's not writing the new service - that's the fun part. It's the stretch where the new service is done, deployed, and running next to the old one, and both compute the same answer for real production writes, and your entire job is to prove that they never, ever disagree. Two engines. One truth. That's this article.


The migration, in stages

I'll keep coming back to a staged picture, because the whole point of a strangler-fig migration is that it happens in steps you can name, not in one heroic deploy. Here is the seam this series lives on - the rule-set write path - laid out as stages:

Stage 0  Monolith only.
         PHP computes the cascade, persists rule data, owns identity implicitly.
         This is where the big parent writes timed out in prod (Part 3).

Stage 1  Identity extracted.
         A small Go service - the domain-rule-map service - becomes the
         immutable, content-addressed master of identity (id + hash).
         PHP still computes and persists the rule data. (Part 1 & 2.)

Stage 2  Write logic extracted.  <-- WE ARE HERE
         The cascade is re-implemented in a stateless Go service - the
         rule-set-markup service. But the monolith still owns the rule DATA,
         so the new service writes DIRECTLY into the monolith's database.
         Same cascade logic now lives in TWO engines: PHP and Go, in parallel.

Stage 3  Prove they agree.       <-- AND HERE
         An automated agent drives BOTH engines through the Go API gateway
         and diffs the persisted result, case by case, until the new engine
         is provably backward-compatible with the old one.

Stage 4  Flip, then delete.  (future)
         Promote the Go engine to master, retire the PHP path, and only later
         move the DATA itself out of the monolith. The duplication ends here.
Enter fullscreen mode Exit fullscreen mode

The rest of this article is Stages 2 and 3 - the awkward, interesting middle where the system runs two rule engines on purpose.


Primer: what the two engines actually do

Strip away the domain and it's simple. The system stores classification rules - patterns that tag web domains ("this one is ours", "this one is a competitor", "this one is irrelevant"). Rules attach to nodes in a three-level hierarchy that cascades top-down:

CLIENT            rules here apply to everything beneath
  └── PROJECT     rules here apply to every config of the project
        └── CONFIG   a specific target (search-engine Γ— device Γ— locale)
Enter fullscreen mode Exit fullscreen mode

A write at a parent doesn't just save that node - it fans out, recomputing every descendant's effective rule set by merging what it inherits from above with what it owns locally. The fully-resolved result per node is its materialized state - what a given config actually sees at request time.

That fan-out is the logic being duplicated. In Stage 0 it was a PHP loop, node by node, one database round-trip cluster per descendant - correct, and on the biggest parents, slow enough to time out (that's Part 3's whole story). In Stage 2 it's a stateless Go service that loads the subtree once and does the whole cascade in memory in a single pass. Same meaning, two implementations, running side by side.


Why duplicate the logic at all?

The honest answer: because you cannot safely delete the old engine until you've proven the new one agrees with it, and you cannot prove agreement without running both. Duplication isn't the accident here - it's the method. The alternative is a flag day: swap PHP for Go in one deploy and find out in production whether they match. On a live business, that's not a migration, it's a bet.

So for the length of Stage 2-3, the same cascade is computed by two engines:

  • The legacy engine - the PHP cascade inside the monolith. Mature, correct, and slow on big writes.
  • The new engine - the stateless rule-set-markup service in Go. Fast, and unproven until the agreement matrix says otherwise.

A runtime master-switch decides which engine's result is authoritative per request (that's Part 4's mechanism). But authoritative-or-not, both can run - the new one in shadow, computing and persisting-or-diffing without being trusted yet. The duplication is the cost of admission for a reversible cutover.


The part that surprises people: the new service writes into the monolith's database

Here's the design decision that gets a raised eyebrow every time I explain it, so let me be blunt about it.

The new Go service does not have its own database. It writes directly into the monolith's PostgreSQL database.

Not through the monolith's API. Not into a store of its own that gets synced back. It opens a connection to the same Postgres the PHP monolith owns, and writes the scope links and rule rows there, in one transaction.

Why on earth would a "microservice" reach straight into another service's database - the textbook cardinal sin? Because of what stage we're in. The monolith is still the monolith: it is still the source of truth for rule data. Moving the data out is a separate, later, riskier migration (Stage 4). What's moving now is only the compute - the cascade logic - not the storage. Decoupling those two migrations is deliberate:

LEVEL 1  two separate front doors β€” NOT connected to each other:

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚  PHP monolith      β”‚                    β”‚  Go API gateway        β”‚
    β”‚  (PHP-FPM)         β”‚                    β”‚  front door for the    β”‚
    β”‚  legacy front door β”‚                    β”‚  new service           β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β”‚  β”‚                                   β”‚ routes
      writes β”‚  β”‚ gRPC                              β–Ό
             β”‚  β”‚ id+hash            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
             β”‚  β”‚                    β”‚  rule-set-markup service       β”‚
             β”‚  β”‚                    β”‚  new cascade Β· Go Β· STATELESS  β”‚
             β”‚  β”‚                    β”‚  routes + mirrors Β· no DB yet  β”‚
             β”‚  β”‚                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β”‚  β”‚                          β”‚ writes directly   β”‚ gRPC id+hash
LEVEL 2      β”‚  └──────────┐               β”‚        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β”‚             β–Ό               β”‚        β–Ό
             β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚   (same gRPC identity call)
             β”‚   β”‚  domain-rule-map svc   β”‚β”‚
             β”‚   β”‚  identity (id+hash)    β”‚β”‚
             β”‚   β”‚  a microservice, FOR   β”‚β”‚
             β”‚   β”‚  PERFORMANCE β€” never   β”‚β”‚
             β”‚   β”‚  touches the monolith  β”‚β”‚
             β”‚   β”‚  DB Β· its OWN DB       β”‚β”‚
             β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚
LEVEL 3      β”‚                             β”‚
             β–Ό                             β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚                   monolith PostgreSQL DB                         β”‚
    β”‚          owns the rule DATA β€” both engines' write sink           β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

The rule of the seam is: migrate one thing at a time. Extract the identity decision first (Stage 1, its own service, its own store). Extract the cascade compute next (Stage 2, a stateless service that borrows the monolith's DB). Extract the data ownership last (Stage 4). If I tried to move compute and data in one step, I'd be debugging a new algorithm and a new storage layer at the same time, with no way to tell which one broke a case. By letting the new engine write the monolith's own tables, its output lands in exactly the place the old engine wrote to - so "do they agree?" becomes a question I can answer by reading one database, not by reconciling two.

The statelessness is what makes this safe rather than horrifying. The new service holds no state of its own - every input is read fresh from the monolith DB and the domain-rule-map service on each call, every output written straight back. There is nothing in the service to be inconsistent with the database it borrows, because it keeps nothing. Restart it, run N replicas, kill it between phases: a killed call simply didn't commit, and the next call recomputes the same cascade from the same source of truth. Borrowing another service's database is a sin when the borrower is stateful and drifts; it's a controlled, temporary coupling when the borrower keeps nothing and the coupling is written down as a stage with an exit.

Concretely, the new engine is a thin stateless layer on the hot write path: it routes each request, mirrors the cascade into the monolith's own tables, and resolves identity from the domain-rule-map service over gRPC. That last point matters and the diagram makes it explicit - the domain-rule-map service never touches the monolith's database. It owns identity (id + hash) in its own store, and it's a separate microservice for one reason: performance - a single fast, immutable identity lookup that both engines share. Both the legacy PHP monolith and the new Go engine ask it for identity over gRPC and then write the resolved rows into the monolith DB themselves; the identity service is on neither engine's write path to that DB. And because the markup service sits inline on every write, its latency budget is tight - "stateless" here also means "nothing to warm up, nothing to fsync, nothing between the request and the answer."


Stage 3: an agent that proves the two engines agree

Two engines writing the same tables is only safe if something is relentlessly checking that they produce the same truth. That something is an automated consistency agent, and it is the most important piece of the whole parallel run.

Its job is narrow and it does not "test" in the unit-test sense. It runs the real business flow through the real front door and records a verdict against the database. One loop, per case:

  1. Enter through the gateway. Every check goes through the Go API gateway, not by calling a service directly. This matters: backward compatibility is a promise about what a client observes through the front door, not about internal wiring. If the answer through the gateway is identical, the swap is invisible to everyone who matters - which is the entire definition of backward compatible.
  2. Snapshot the polygon. Capture the current persisted state of a production polygon - a safe, disposable slice of real data - as before. You cannot verify a change you didn't measure first.
  3. Run the same input through both engines. Same request, legacy engine and new engine. Dry-run by default: it computes and diffs but persists nothing. Real writes are gated behind an explicit flag and are reversible.
  4. Diff the persisted truth, not the response. This is the lesson Part 1 was built on: don't trust the 200. Re-fetch what actually landed in the monolith DB from each engine and compare those. A response that says was_updated means nothing until the row agrees.
  5. Restore the polygon. Leave the data exactly as found, so the next case starts from the same known state.
  6. Write the transcript to disk. Input, both responses, both persisted states, the diff, the verdict. This path has no production logs - if the agent doesn't record what happened, nothing did.

The verdict is not "it works". It's a coordinate: (case, engine, deploy-version) -> agrees | differs. The cases are stable and numbered, so when a reviewer and I say "case 16" we mean the same scenario forever - the project -> own markup move that stayed red in Part 1 until the identity fix landed.

// The consistency agent runs one case end to end and records a verdict.
// It does not assert in a test runner; it diffs persisted truth and leaves
// a transcript. Everything enters through the gateway - the real front door.
type ConsistencyAgent interface {
    Check(ctx context.Context, c Case) (Verdict, error)
}

func (a *Agent) Check(ctx context.Context, c Case) (Verdict, error) {
    before, err := a.polygon.Snapshot(ctx, c.Target)
    if err != nil {
        return Verdict{}, fmt.Errorf("snapshot: %w", err)
    }
    defer a.polygon.Restore(ctx, before) // restore always, even on failure

    // Same input, both engines, through the gateway. Dry-run unless gated.
    legacy, err := a.gateway.Apply(ctx, c.Input, EngineLegacy)
    if err != nil && !errors.Is(err, ErrDryRun) {
        return Verdict{}, fmt.Errorf("legacy apply: %w", err)
    }
    fresh, err := a.gateway.Apply(ctx, c.Input, EngineMarkup)
    if err != nil && !errors.Is(err, ErrDryRun) {
        return Verdict{}, fmt.Errorf("markup apply: %w", err)
    }

    // TRUTH, not the response body: read what each engine persisted.
    legacyState := a.polygon.Fetch(ctx, c.Target, legacy)
    freshState := a.polygon.Fetch(ctx, c.Target, fresh)

    v := Verdict{
        Case:   c.ID,
        Deploy: a.deploy,
        Agrees: legacyState.Equal(freshState), // persisted-vs-persisted
    }
    return v, a.transcript.Persist(ctx, c, legacy, fresh, legacyState, freshState, v)
}
Enter fullscreen mode Exit fullscreen mode

errors.Is(err, ErrDryRun) rather than err == ErrDryRun, because the moment a decorator wraps that error a bare comparison goes silently false and the agent starts acting for real when it thinks it's dry - the exact class of quiet lie this whole series is about.


A verdict is a grid, and the grid is the backward-compatibility argument

One run produces one fact: case X agreed (or didn't) between the two engines on deploy Y. Stack those facts and you get the only artifact that lets me flip an engine to master with a straight face:

Case legacy vs new v4.6.0 legacy vs new v4.6.1
4 - plain create βœ… βœ…
15 - restamp ownβ†’project ❌ βœ…
16 - move projectβ†’own ❌ βœ…
18 - shrinking REMOVE ❌ βœ…
22 - cascade to N configs βœ… βœ…

The βŒβ†’βœ… flips between two deploy tags are the compatibility proof. I don't promote the new engine because the code looks right; I promote it because every case that used to differ now agrees, on the deploy that's actually running, with a transcript on disk. Backward compatibility stops being a hope and becomes a grid you can point at.

And the grid earns its keep in the ugly direction too. When the new engine diverges on a case, the diff is specific - this config, these rules, this field - because both engines wrote the same tables and I'm comparing rows, not vibes. A divergence is a bug report with coordinates, not a "sometimes it's off" ticket.


The honest edges of running two engines

I'd be lying if I sold duplication as free. The costs are real and worth naming:

  • Drift. Two implementations of one algorithm will drift unless something forces them together. The agreement agent is that force - but it only catches what's in the case matrix. A behavior nobody wrote a case for can diverge silently. The mitigation is that the matrix grew out of real production incidents (Part 1's cases came from a 21-case write-matrix), so it covers the shapes that actually bite.
  • A shared database is a coupling with a deadline. The new engine writing the monolith's tables is a temporary, documented coupling, not a pattern to copy. It has an exit (Stage 4). The danger is letting "temporary" become "forever" - so the coupling is written into the stage plan with the step that removes it, not left as tribal knowledge.
  • Two write paths is two chances to be wrong. The parallel run doubles the surface where a bug can hide, for the duration. That's the price of not doing a flag day. You buy reversibility with complexity, and you pay it back the moment you delete the legacy path in Stage 4.

None of these is a reason not to run in parallel. They're the reasons the consistency agent is not optional.


AI as a multiplier, and what it can't do for a parallel run

I lean hard on AI coding assistants for work like this, and my take hasn't shifted across the series: AI amplifies a good engineer and exposes a weak one. It's a multiplier, not a crutch.

AI made the mechanics of the parallel run cheap - generating the second engine's scaffolding, the agent loop, the snapshot/restore, the transcript serializer, the matrix formatter. Fast and genuinely useful. What it did not do is any of the judgment the parallel run turns on. It won't tell you to migrate compute before data, or that the new service borrowing the monolith's database is safe only because it's stateless and the coupling has an exit. It won't insist you diff persisted rows instead of the response, because the response looks authoritative and a model has no scar from a 200 that lied. It won't decide when the grid is green enough to promote an engine - that's a risk call a human owns. Point a multiplier at a disciplined parallel run - staged migration, stateless borrowing, agreement proven against persisted truth through the front door - and it collapses "fast or safe" into fast and safe. Point it at "the new service passes its tests, cut over" and it'll help you build a flag day faster than you can schedule the incident review.


This is a companion to Part 1

Part 1 was one bug across two code paths. This part is the same discipline scaled up to two whole services: prove they agree on persisted truth before you trust either one. The seam keeps moving:

If you build serious backends - Symfony, Go, or the messy space between a monolith and the services growing out of it - follow along. And if you're mid-extraction right now with old and new code computing the same thing: how are you proving they agree - on the response, or on what actually persisted? I'd genuinely like to compare notes.

Source: dev.to

arrow_back Back to Tutorials