DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on • Edited on

Breaking the Monolith - Part 2: Who Owns a Hash Function

Part 1 was a bug story: a 200 OK that saved nothing. This is the design story underneath it - why the ownership of a hash function stopped being an implementation detail and became an architecture decision, and how I reasoned about the trade-offs while both systems were live and one of them had just been handed the right to say "these two things are the same."


πŸ‘‹ Hi again, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, carving a large live monolith into services one seam at a time. In Part 1 (and its expanded cut) I chased a silent no-op: the API cheerfully reported was_updated while the database saved nothing. That post was about the symptom. This one is about the decision that made the symptom possible - and the decision that fixed it for good. Running notes live on my GitHub: github.com/brilliant-almazov. Honest version, mistakes left in.


The migration, in stages

You cannot reason about "who owns identity" without knowing which stage you're standing in when you ask. A big-bang rewrite is a bet you make once and lose slowly; the strangler-fig alternative moves the seam one named stage at a time. Here is the seam this whole series lives on - the rule-set write path - with a marker on the stage this article is about:


Stage 0  Monolith only.
         PHP computes the cascade, persists the rule data, owns identity implicitly.

Stage 1  Identity extracted.          <-- THIS ARTICLE
         A small Go service becomes the immutable, content-addressed master of
         identity (id + hash), in its OWN database, answering over gRPC.
         PHP still computes and persists the rule DATA.
         The question: WHO is allowed to decide two rule sets are "the same"?

Stage 2  Write logic extracted.       (Part 1.5)
         The cascade is re-implemented in a stateless Go service that writes
         directly into the monolith's DB. Two engines run in parallel β€” and both
         resolve identity from the Stage-1 service over gRPC.

Stage 3  Prove they agree.
         An agent drives both engines and diffs persisted truth, case by case.

Stage 4  Flip, then delete.
         Promote the Go engine, retire PHP, and only then move the DATA out.
Enter fullscreen mode Exit fullscreen mode

Part 1 was a bug that lived at Stage 1. Part 2 is the decision at Stage 1 that the bug was a violation of. Get this decision right and Stage 2 - two write engines over one database - is safe. Get it wrong and Stage 2 multiplies the divergence Part 1 was about.


Primer: what "identity" even means here

Strip away the specifics and the domain is simple. The system stores classification rules - patterns that tag web domains with a meaning ("this domain is ours", "this one is a competitor", "this one is irrelevant"). A rule is roughly {pattern, type, match-mode, priority}. A rule set is an ordered collection of those rules attached to one node in a 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 level fans out: the engine recomputes every descendant's effective rule set, merging what it inherits from above with what it owns locally, narrower level winning.

Now the word that carries this whole article: identity. It's the answer to one question - "is this the same rule set I already have, or a different one?" You answer it with a hash function: reduce the content to a fixed fingerprint, and two sets are "the same" exactly when their fingerprints match. This is content-addressing - the same idea Git uses for commits and blobs. Same content β†’ same hash β†’ same object. Change one byte β†’ new hash β†’ new object.

Sounds like plumbing. It isn't. The instant a second system needs to agree on whether two rule sets are the same, "how do we hash content" stops being a private detail of the monolith and becomes a contract between two services. And a contract has an owner.


The design fork: where does the hash function live?

When I introduced the new Go service - I'll call it the rule-set store (the domain-rule-map service, when its identity-master role is the point) - I had exactly three places to put the authority for identity. This is the fork the whole series turns on:


                 who computes a rule set's ID?

  A  each system for itself      B  the client owns it        C  the store owns it
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”
     β”‚monolithβ”‚  β”‚ store  β”‚         β”‚monolithβ”‚  β”‚ storeβ”‚         β”‚monolithβ”‚  β”‚ storeβ”‚
     β”‚ hash() β”‚  β”‚ hash() β”‚         β”‚ hash() β”‚  β”‚ bytesβ”‚         β”‚ contentβ”‚  β”‚ hash()β”‚
     β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜         β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜  β””β”€β”€β”¬β”€β”€β”€β”˜         β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜  β””β”€β”€β”¬β”€β”€β”€β”˜
         β”‚  DISAGREE β”‚                  β”‚  owns    β”‚                 β”‚  asks   β”‚
         β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜                  └───→ id   β–Ό                 └───→ id   β–Ό
       two answers = a bug        migration never moves       ONE owner of identity
         βœ— REJECTED (a law)         βœ— REJECTED (owns nothing)     βœ“ CHOSEN
Enter fullscreen mode Exit fullscreen mode

Option A - every system hashes for itself. The monolith computes its hash, the store computes its own, each trusts its own answer. This is the one I accidentally shipped, and Part 1 is the crater. Two hash functions is one hash function too many: the moment they disagree by a single field, you get silent divergence that no status code will ever reveal. Rejected - not as a preference, as a law.

Option B - the client owns identity, the store is a dumb blob bucket. The store just persists bytes under a key the monolith picks. Simple, but it means the monolith stays the authority forever - the migration makes no progress, because the thing I'm trying to extract (the decision "these are the same") never actually leaves PHP. Rejected: it's a service that doesn't own anything.

Option C - the store owns identity, everyone asks it. The store is the single place that decides what a rule set's id is. The monolith computes content; the store computes identity, in its own database, and answers over gRPC. Chosen - and everything else in this article is a consequence of that choice.


Consequence 1: the store is deliberately, aggressively dumb

Because the store owns identity and only identity, I could make it the simplest service on the platform. It is immutable and content-addressed: you hand it a canonical rule set, it returns the one master for that content, creating it only if absent. It never mutates, never cascades, never validates business rules. It creates exactly what you give it.

// The store owns identity and nothing else. Hand it a canonical rule set,
// it returns the single master for that content β€” creating one only if absent.
// It lives in its OWN database and is reached over gRPC; it never touches the
// monolith's DB.
type RuleSetStore interface {
    FindOrCreate(ctx context.Context, set CanonicalRuleSet) (RuleSetID, error)
}

func (s CanonicalRuleSet) Hash() Hash {
    h := sha256.New()
    for _, r := range s.Rules { // rules already in canonical order
        io.WriteString(h, field(r.Type, r.Match, r.Pattern, r.Priority, r.Origin))
    }
    return Hash(h.Sum(nil))
}
Enter fullscreen mode Exit fullscreen mode

The trade-off I accepted on purpose: the store is dumb, so the data and the cascade logic stay in the monolith. It can't answer "what rules does config 15 actually see" - that's not its job. A less disciplined design would have let the store grow a little validation here, a little cascade there, until it became a second monolith. Keeping it a small, immutable, content-addressed authority is precisely what makes it easy to reason about - and easy to trust as the one owner of identity.

There's a second reason it's a separate service and not a library: performance. Identity resolution is one fast, immutable lookup that both engines - the PHP monolith today, the stateless Go cascade at Stage 2 - share over gRPC. A shared, cache-friendly authority is worth a network hop; a library copied into two runtimes is Option A wearing a disguise.


Consequence 2: create is not recalculate - and conflating them is a category error

This is the distinction a teammate corrected me on in one sentence, and it's structural, not cosmetic.

Creation is one transaction: send the computed set to the store, get back the master ids, and save the monolith's local mirror - all or nothing.

Recalculation is a different operation entirely: a post-commit fan-out over everything that changed, recomputing descendants' effective rule sets. It runs after the create transaction has committed, because it reacts to what actually landed.

I don't want that boundary living in a comment or an inline runInTransaction(fn () => …) at the call site - I want it to be a type. The write handler knows only the domain; a transactional decorator runs it in exactly one transaction; an after-commit decorator schedules the recompute through a port the transaction dispatches only once it commits. Nothing in the handler ever sees BEGIN/COMMIT or calls an afterCommit():

// The domain handler β€” zero transaction awareness, zero commit-hook awareness.
interface WriteHandler
{
    public function handle(RuleSetWrite $write): WriteResult;
}

final readonly class CreateRuleSet implements WriteHandler
{
    public function __construct(
        private RuleSetStore $store,
        private MirrorProjection $mirror,
    ) {}

    public function handle(RuleSetWrite $write): WriteResult
    {
        $masters = $this->store->createAll($write->computed); // identity β€” the store decides
        $this->mirror->save($write->computed, $masters);      // local projection of that identity

        return new WriteResult($masters, $write->changed);
    }
}

// Creation runs INSIDE one transaction β€” a decorator, not an inline closure at the call site.
final readonly class Transactional implements WriteHandler
{
    public function __construct(
        private WriteHandler $inner,
        private TransactionManager $tx,
    ) {}

    public function handle(RuleSetWrite $write): WriteResult
    {
        return $this->tx->run(fn (): WriteResult => $this->inner->handle($write));
    }
}

// Recalculation is a SEPARATE, post-commit concern β€” also a decorator. It schedules the
// recompute through an AfterCommit port that the transaction fires only after COMMIT.
final readonly class RecalcAfterCommit implements WriteHandler
{
    public function __construct(
        private WriteHandler $inner,
        private AfterCommit $afterCommit,
    ) {}

    public function handle(RuleSetWrite $write): WriteResult
    {
        $result = $this->inner->handle($write);
        $this->afterCommit->schedule(fn () => $result->changed->recompute());

        return $result;
    }
}

// The boundary is now a TYPE, not a comment: schedule inside the tx, dispatch after commit.
$write = new Transactional(
    new RecalcAfterCommit(new CreateRuleSet($store, $mirror), $afterCommit),
    $tx,
);
Enter fullscreen mode Exit fullscreen mode

Why does this matter for ownership? Because the tempting shortcut is to treat the store's echo of a create as the source of truth for content - to read identity back and re-derive what changed from it. That's the category error. The store answers "what is this set's id"; it does not answer "what did this write change downstream." Recalculation owns the second question, and it owns it after commit. Blur the two and you get exactly Part 1's ghost: a create that reports success while the recalc it implied never happened.


Consequence 3: dual authority over data is a trade-off; dual authority over identity is a bug

Here's the uncomfortable part. During the transition the monolith still keeps a local mirror of every rule set, and a runtime master-switch flag chooses the write path per request: store path or legacy path.

$writeSource = $this->masterSwitch->current(); // WriteSource::Store | WriteSource::Legacy
Enter fullscreen mode Exit fullscreen mode

So for a while, two places hold rule-set data. That looks like exactly the two-authorities trap I just spent three sections rejecting. It isn't - and the distinction is the whole point of this section:

     DATA                                    IDENTITY
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚ store       β”‚   β”‚ monolith    β”‚        β”‚   store     β”‚  ← the ONLY authority
     β”‚ (own DB)    β”‚   β”‚ mirror      β”‚        β”‚  (own DB)   β”‚    that decides an id
     β”‚ id + hash   β”‚   β”‚ (monolith   β”‚        β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚  DB)        β”‚               β”‚ FindOrCreate over gRPC
        two data           projection               β”‚
        stores, kept       of the id the      monolith ── asks β”€β”€β”˜
        coherent           store handed it     mirror ── never decides, only records
     ── a TRADE-OFF ──                          ── a LAW ──
Enter fullscreen mode Exit fullscreen mode

The mirror is a projection, not a second opinion. It never computes an id; it only records the id the store handed it. The legacy path still writes the mirror the old way, which is why the mirror has to exist at all during cutover - but nothing on the mirror is ever allowed to decide identity. The client never re-hashes, never dedups, never rules created/updated/unchanged from a local hash. The one moment I let the mirror's hash cast a verdict is the exact moment Part 1 blew up.

Note the storage picture this implies, because it's a fact people get wrong: the identity store keeps identity in its own database and is reached over gRPC - it is not squatting in the monolith's schema. The rule data (and the mirror) live in the monolith's PostgreSQL DB. So there really are two databases here, but only one of them is ever allowed to answer the identity question.

The cost of the data trade-off is real: two data stores to keep coherent, a flag to reason about, a window where a bug can hide in the seam. I took it deliberately because the alternative - freeze writes, migrate identity, thaw - means downtime a live business won't grant me. Dual authority over data is the price of a zero-downtime cutover. Dual authority over identity is a bug. Knowing which is which is the whole job.


Consequence 4: symmetry is a law, not a nicety

Once identity has one owner, a second law falls out of it. In Part 1, after the honest-verdict fix, a quieter asymmetry was still exposed: the store hashed content without the origin field; the monolith hashed with it. Two participants, two different notions of "same". Result: a genuine project β†’ own markup move could still collapse two distinct sets into one master, and prod would silently lose markup.

The fix wasn't cleverness - it was symmetry. One field, appended on the side that was missing it (you can see r.Origin already in the Hash() above):

If a field is part of a rule set's identity for one participant, it must be part of it for every participant - or you get silent divergence no status code will ever reveal.

That's the general law. But symmetry-by-copying-a-field is the tactical fix; it leaves two hashers that I now have to keep in lockstep forever. The end state is cleaner and it's where this seam is heading: stop storing origin as sticky data on a rule at all, and instead derive it from where the rule sits in the cascade at read time. A rule's level would then follow from structure, not from a label that can drift out of sync between two services. Derived identity can't diverge, because there's only one thing to compute it from. That's the difference between patching a symptom and dissolving a bug class.


The discipline that keeps this legible: naming and interfaces

None of the above survives contact with a codebase unless the code reads like the design. A few rules I refuse to bend, because they're what let a new implementation slot in without a rewrite:

  • A resolver has exactly one method: resolve. Not resolve, resolveFor, executed, executedFor - one verb, one contract. The fact that now matters (what the store actually persisted) enters as a nullable argument, not as a parallel method or an interface zoo.
  • Everything behind an interface; instanceof ladders in shared code are banned. The runner routes by polymorphism, so adding a write path never touches the orchestrator.
  • final readonly classes, declare(strict_types=1) everywhere. Immutable value objects can't drift after construction - the same discipline the store enforces at the service level, enforced again at the object level.
declare(strict_types=1);

interface ActionResolverInterface
{
    public function resolve(
        StateItemInterface $item,
        ?RuleSetInterface $persisted,
    ): BatchUpsertAction;
}

final readonly class PersistedActionResolver implements ActionResolverInterface
{
    public function resolve(
        StateItemInterface $item,
        ?RuleSetInterface $persisted,
    ): BatchUpsertAction {
        // Binary by nature: a newly minted master β†’ updated; anything else
        // (nothing landed, or an existing master reused) β†’ unchanged.
        return $persisted?->isNewMaster()
            ? BatchUpsertAction::WasUpdated
            : BatchUpsertAction::WasUnchanged;
    }
}
Enter fullscreen mode Exit fullscreen mode

On the Go side the same instinct shows up in naming: identifiers read as ordinary words - HttpClient, RuleSetID, HeadSha - never shouted as HTTPClient or HeadSHA. The only initialism that stays uppercase is ID. It's a small rule, but consistent naming is how a reader trusts that resolve means resolve everywhere, and that identity means the store everywhere.


Where this goes next - the parallel run

This decision - one owner of identity, in its own DB, reachable over gRPC - is what makes the next stage safe. At Stage 2 the cascade compute itself leaves PHP for a stateless Go service, and for a while two write engines run in parallel over the monolith's database. Here's the topology the identity decision unlocks:

LEVEL 1  two separate front doors β€” NOT connected to each other:
         PHP monolith (PHP-FPM, legacy)        Go API gateway (new service)

LEVEL 2  services:
         domain-rule-map svc                   rule-set-markup service
         identity (id+hash), its OWN DB,        new cascade engine, Go,
         a microservice FOR PERFORMANCE,        STATELESS: routes + mirrors,
         never touches the monolith DB          no DB of its own β€” writes the
                                                monolith DB directly

LEVEL 3  storage:
         monolith PostgreSQL DB (both engines' only write sink)  +  DRM's own DB

  Both engines resolve identity from domain-rule-map over gRPC. Both write the
  monolith DB. The identity service never touches it.
Enter fullscreen mode Exit fullscreen mode

Three things about this picture are only safe because the identity decision was made honestly:

  1. The new engine writes the monolith's DB directly. It has no store of its own yet - only the compute is moving, not the data. That's a deliberate, temporary coupling with an exit at Stage 4, not the end state. It's safe only because the service is stateless: it keeps nothing, so it can't drift from the DB it borrows.
  2. domain-rule-map is a separate microservice for one reason: performance - one fast, immutable identity lookup that both engines share over gRPC, in its own DB, never touching the monolith DB. If identity weren't a single honest authority, running two write engines over one database would multiply exactly the divergence Part 1 was about.
  3. The two front doors are independent. The gateway fronts the new service; the PHP-FPM monolith is its own legacy front door. They aren't wired together - which is what lets an automated consistency agent drive both engines and diff persisted truth, case by case, until the new engine is provably backward-compatible.

That's Part 1.5.


AI as a multiplier, again - because this is where it bites

I lean hard on AI coding assistants for work like this, and the throughline from Part 1 holds: AI amplifies a good engineer and exposes a weak one. Nowhere is that sharper than in a design decision like "who owns the hash function." An assistant will happily generate Option A - each system hashing for itself - because locally, in each file, it looks completely reasonable. Two tidy Hash() methods, both correct in isolation, that together form a silent-corruption generator. The tool produces plausible-looking code at high speed; it does not tell you that identity must have exactly one owner, or that create and recalc are different operations, or that a field in one hash must be in the other. That judgment came from a person who knew the invariant and a teammate who corrected me in a sentence.

Point the multiplier at a real design - one identity owner, a dumb immutable store, an explicit create/recalc boundary, symmetry as a law, single-method interfaces - and it collapses "fast or correct" into fast and correct: I get the boilerplate, the value objects, the tests for free, and I spend my time on the decisions that don't have a locally-obvious answer. Point it at no design and it generates the crater faster than you can review it. That's the difference between amplifying engineering and automating its absence.


This is Part 2 of a series

Part 1 was the bug; Part 2 was the decision under it - that ownership of identity is an architecture choice, not plumbing. The seam keeps moving:

  • Part 1 - A 200 OK that saved nothing. The silent no-op this decision fixes, and its expanded cut with the full staged-migration map.
  • Part 1.5 - Two rule engines, one truth. The cascade now runs as two live services - the PHP engine and a new stateless Go one - writing the same database in parallel, proven equal through the gateway before either is trusted.
  • Part 3 - A stateless cascade in Go. Why the write/cascade path is leaving PHP for a stateless Go service: the timeout budget forcing it, the one-pass in-memory merge, and how to measure the win honestly.
  • Part 4 - Flipping the master live. Running both write paths behind the runtime master-switch and flipping the master safely - retiring the legacy path and the mirror without a big-bang deploy, until identity lives fully in the store and the switch has nothing left to switch back to.

If you build serious backends - Symfony, Go, or the messy space between a monolith and the services growing out of it - follow along. Concrete, code-first, honest about the mistakes.

And if you're mid-migration with two systems that must agree on identity: which one owns it? If your answer is "both", that's not two answers - it's a bug waiting for a business-side human to notice. I'd genuinely like to compare notes.

Top comments (0)