Migrating from monolithic GIS to data mesh

Cutting a production estate over from a centralized PostGIS/ArcSDE geodatabase to a federated set of owned spatial products is the one operation where a botched routing change silently corrupts every downstream query at once. This guide is a concrete migration runbook under Data Mesh vs Traditional GIS Architecture, itself part of Geospatial Data Mesh Fundamentals: it walks through standing up a deterministic routing layer that intercepts, validates, and forwards spatial queries only after strict schema and catalog parity are proven, so the monolith can be decomposed domain by domain without spatial index fragmentation or metadata desynchronization. Every domain you carve out must already have its ownership lines drawn through Spatial Domain Boundary Design; this page assumes those boundaries exist and focuses on the cutover mechanics that move traffic onto them safely.

Monolith-to-mesh cutover flow through the SpatialDomainRouter Spatial queries leave the monolithic PostGIS or ArcSDE geodatabase and enter a deterministic SpatialDomainRouter. The router applies three admission gates in sequence — CRS validation to EPSG:4326, schema validation of partition_key, geom and temporal_index, and catalog sync within a 120-second drift budget. Admitted traffic is routed to per-domain spatial products: hydrology, cadastre, and transport. If any gate trips, the circuit breaker opens and traffic falls back to the warm monolith-legacy-fallback route. Monolithic GIS PostGIS / ArcSDE GiST-indexed geom spatial queries SpatialDomainRouter deterministic admission gates 1 · CRS validation → EPSG:4326, reject mismatch 2 · Schema validation partition_key · geom · temporal 3 · Catalog sync drift < 120s or block traffic admitted domain-hydrology h3_resolution_7 domain-cadastre owned spatial product domain-transport owned spatial product circuit breaker open · any gate trips → monolith-legacy-fallback

Prerequisites

Requirement Value / Constraint
Source system PostGIS 3.4+ or ArcSDE geodatabase with GiST indexes on every geom column
Routing plane Kubernetes 1.28+ with server-side apply; Envoy or equivalent L7 proxy at ingress
Storage CRS Geometries canonicalized to EPSG:4326; web egress reprojected to EPSG:3857 only
Catalog Federated metadata catalog reachable at catalog-api.internal/v1 with sync-status endpoint
CLI tools kubectl, ogrinfo, psql, curl, jq, sha256sum, opa
Access roles platform-engineer (router deploy); GIS Data Steward for catalog reconciliation
Env vars DB_URL, CATALOG_TOKEN, CONFIG_HASH, BROKER_BOOTSTRAP

Step-by-Step Implementation

1. Map the dependency chain before any routing change

The routing layer is a state machine: traffic admission is gated by explicit architectural controls, and activating it before its dependencies are satisfied is what triggers silent spatial drift or timeout cascades. Confirm each control below resolves to a real, versioned artifact before you touch the route table. Product Thinking for GIS Datasets assigns routing accountability to domain product owners rather than central DBAs; Metadata Cataloging for Raster/Vector supplies the schema validation (geometry types, CRS, attribute nullability) that runs before traffic is admitted; and Scoping Rules for Spatial Products governs deterministic partition-key generation so spatial indexing stays uniform across boundaries.

Why the dependency chain is mapped before anything is repointedFive stages of a migration in order. The dependency chain is mapped first, from access telemetry rather than from documentation, because a consumer nobody knew about is the usual cause of a failed cutover. The router is deployed idempotently next, still sending everything to the monolith. Diagnostics run against staging replicas. Circuit breakers gate admission so a failing new domain cannot take traffic. Only then does the first domain cut over. The axis note records the ordering that fails: repointing connection strings before the dependency chain is known.1 · Map dependenciesfrom telemetry, not docs2 · Deploy routeridempotent, no-op at first3 · Staging diagnosticsagainst replicas4 · Circuit breakersgate admission5 · Cut over one domainrollback one command awayThe failing order is repointing connection strings before the dependency chain is known

bash
# Verify every domain product carries an immutable, versioned tag before cutover.
# Unversioned or mutable staging artifacts must never be routed to.
for domain in hydrology cadastre transport; do
  curl -s -H "Authorization: Bearer $CATALOG_TOKEN" \
    "https://catalog-api.internal/v1/spatial/products/${domain}" | \
    jq -r 'select(.schema_version | test("^v[0-9]+\\.[0-9]+\\.[0-9]+-crs:EPSG:[0-9]+-res:")) | .schema_version
           // "UNVERSIONED — block routing"'
done

Any product printing UNVERSIONED must be tagged — following the site convention v1.2.0-crs:EPSG:4326-res:10m — and its retention and archival triggers configured through Spatial Product Lifecycle Management before it is eligible for traffic.

2. Deploy the deterministic router idempotently

The routing layer is deployed as a declarative, idempotent manifest that enforces CRS normalization, partition alignment, and SLA circuit breaking at admission. Wrap the apply in an atomic state check so a partial activation can never leave half the domains routed.

yaml
# routing-layer-config.yaml
apiVersion: routing.mesh/v1
kind: SpatialDomainRouter
metadata:
  name: gis-domain-router
  annotations:
    idempotency-key: "sha256-placeholder-replace-at-deploy-time"
spec:
  admission_gates:
    crs_validation:
      target_crs: "EPSG:4326"
      fallback_transform: true
      reject_on_mismatch: true
    schema_validation:
      required_fields: ["partition_key", "geom", "temporal_index"]
      geometry_type: ["Polygon", "MultiPolygon", "Point"]
    catalog_sync:
      max_drift_seconds: 120
      block_traffic_on_desync: true
  circuit_breaker:
    error_threshold: 0.05
    timeout_ms: 3000
    fallback_route: "monolith-legacy-fallback"
  routing_rules:
    - match:
        header: "x-domain-scope: hydrology"
      route: "domain-hydrology-v1.4.2"
      partition_strategy: "h3_resolution_7"
bash
# Compute manifest hash for the audit trail, then apply server-side (idempotent).
CONFIG_HASH=$(sha256sum routing-layer-config.yaml | awk '{print $1}')
kubectl apply -f routing-layer-config.yaml \
  --server-side --field-manager=platform-engineer
kubectl rollout status deploy/gis-domain-router --timeout=300s
echo "Routing state locked: ${CONFIG_HASH}"

3. Run pre-cutover diagnostics against staging replicas

Validate partition alignment and catalog drift on a replica before admitting any production traffic. The three checks below isolate the failures that most often surface only after cutover.

bash
# Validate spatial partition alignment and CRS consistency.
ogrinfo -al -so /path/to/spatial_product_staging.gpkg | \
  grep -E "Geometry|SRS WKT|Feature Count"

# Identify non-WGS84 or invalid features in a PostGIS staging table.
psql "$DB_URL" -c "
SELECT partition_key, ST_SRID(geom) AS detected_srid, ST_IsValid(geom) AS valid
FROM spatial_product_staging
WHERE ST_SRID(geom) != 4326 OR NOT ST_IsValid(geom)
LIMIT 50;"

# Audit metadata catalog sync latency.
curl -s -H "Authorization: Bearer $CATALOG_TOKEN" \
  https://catalog-api.internal/v1/spatial/metadata/sync-status | jq '.drift_seconds'

If drift_seconds exceeds 120 or the PostGIS query returns rows, the router must keep blocking traffic until catalog parity is restored. Authoritative CRS transformation matrices live in the OGC Coordinate Reference Systems Registry, and partition-aligned geometry encoding follows the GeoParquet Specification v1.1.0.

4. Gate traffic admission behind circuit breakers

Circuit breakers operate at the query execution boundary, not the network layer, so they can read spatial join latency and catalog sync status atomically. Admission requires three concurrent conditions: drift_seconds < 120 across all domain catalogs, zero CRS_MISMATCH or PARTITION_DRIFT logs in the last 15-minute window, and a passing immutable-tag validation.

bash
# Refuse to shift traffic while the breaker is open.
CIRCUIT_STATE=$(curl -s http://router-internal:8080/health/circuit-breaker | jq -r '.state')
if [[ "$CIRCUIT_STATE" == "OPEN" ]]; then
  echo "Circuit OPEN: routing blocked, awaiting catalog sync stabilization."
  exit 1
fi

# Atomic, reversible traffic shift onto domain routing.
kubectl patch configmap gis-routing-flags \
  --patch '{"data":{"allow_domain_routing":"true"}}'

5. Cut over per domain and keep rollback one command away

Shift one domain at a time, watch the log patterns in Section Common Failure Modes, and confirm the active route before proceeding. The monolith-legacy-fallback route stays warm throughout, so rollback is a single idempotent patch. Contract-aware dispatch during the dual-run window is handled by Cross-Domain Routing Strategies, which reads each payload’s contract fingerprint to pick the correct domain.

One domain cutting over, with the rollback available at every stateA domain moves through four routing states. It begins with all traffic on the monolith. Shadow mode sends a copy of each request to the new domain and compares results without serving them. Canary serves a small percentage from the new domain while the monolith remains authoritative. Cut over serves everything from the domain with the monolith read-only behind it. Every state can return directly to monolith-only with a single idempotent router change, which is what makes each step independently safe rather than part of a coordinated leap.Monolith only100% legacyShadowcompare, do not serveCanary5% served by domainCut overmonolith read-onlyrouter mirrorsresults agreeerror budget intactrollback — one idempotent router change, available from every state

bash
# Idempotent rollback to the legacy monolith and a diagnostic snapshot.
kubectl patch configmap gis-routing-flags \
  --patch '{"data":{"allow_domain_routing":"false"}}'
curl -s http://router-internal:8080/routing/status | jq '.active_route'
kubectl logs deploy/gis-domain-router --tail=5000 > /tmp/router-failure-$(date +%s).log

Do not reactivate domain routing until catalog parity, partition alignment, and schema validation all return zero anomalies.

Configuration Reference

Parameter Scope Value / Default Rationale
target_crs crs_validation EPSG:4326 Canonical storage CRS; mismatches transform or reject
reject_on_mismatch crs_validation true Blocks silent reprojection drift at admission
required_fields schema_validation partition_key, geom, temporal_index Minimum contract for a routable product
max_drift_seconds catalog_sync 120 Catalog parity budget; exceeded → block traffic
error_threshold circuit_breaker 0.05 Trip ratio over the rolling window
timeout_ms circuit_breaker 3000 Per-query execution ceiling before fallback
fallback_route circuit_breaker monolith-legacy-fallback Warm legacy path for instant rollback
partition_strategy routing_rules h3_resolution_7 Deterministic spatial partition keying
schema_version product tag v1.2.0-crs:EPSG:4326-res:10m Immutable, CRS- and resolution-pinned identifier

Common Failure Modes & Fixes

Monitor these regex patterns in centralized aggregation (Splunk/ELK); each maps to a specific cutover fault.

Failure Mode Log Pattern (Regex) Symptom → Root Cause → Fix
CRS mismatch CRS_MISMATCH.*expected=EPSG:4326.*actual=EPSG:\d{4,5} Joins offset → a domain ships a non-WGS84 product → reject the query, trigger the transform pipeline, re-tag the product
Partition drift PARTITION_DRIFT.*h3_cell_mismatch.*index_fragmentation Query latency spikes → spatial index fragmented during bulk vector-to-GeoParquet conversion → halt routing for that domain, rebuild the spatial index
Catalog desync CATALOG_SYNC_TIMEOUT.*drift_seconds>\d{3} Stale partition reads → catalog lag past the 120s budget → block traffic, invoke the reconciliation job with a GIS Data Steward
Schema violation SCHEMA_REJECT.*geometry_type_violation.*null_geometry Malformed batch admitted → producer emitted a null or out-of-contract geometry → drop the batch, notify the domain steward, quarantine the producer above 5% reject rate
Breaker stuck open CIRCUIT_OPEN.*duration_ms>6\d{5} All domain traffic on fallback >10m → unresolved drift or partition fault → force rollback, capture heap/trace dumps, escalate to architecture review

FAQ

Why route through a state machine instead of just repointing connection strings?

Repointing connection strings admits traffic the instant DNS or a config map changes, with no gate between a half-migrated product and its consumers. Modeling admission as a state machine means CRS validation, schema parity, and catalog sync must all pass before a single query lands on a domain, so a partially converted GeoParquet partition or a desynced catalog can never serve corrupt results.

How do I migrate one domain without disturbing the others still on the monolith?

Keep the monolith-legacy-fallback route warm and shift domains one x-domain-scope header at a time. The circuit breaker evaluates each domain independently, so a hydrology cutover that trips on partition drift halts only hydrology routing — cadastre and transport keep serving from whichever side they currently target.

What makes the rollback safe to run repeatedly?

The rollback is a single kubectl patch that flips allow_domain_routing to false, which is idempotent: applying it when routing is already disabled is a no-op. Because the legacy route never went cold during the dual-run window, traffic returns to the monolith without a cache warm-up or index rebuild.

When is it safe to decommission the monolith?

Only after every domain has sustained zero CRS_MISMATCH, PARTITION_DRIFT, and CATALOG_SYNC_TIMEOUT events across a full business cycle and drift_seconds has held under 120 for all catalogs. Until then the fallback route is load-bearing, and its absence would remove your only single-command rollback.

How long should the monolith keep serving after the first domain cuts over?

Until its last consumer has moved, measured rather than assumed — which in a large estate is usually a year or more, and that is fine. The instinct to set a decommissioning date early is understandable and almost always counterproductive: it converts the migration from a sequence of independently safe cutovers into a deadline that pressures teams to cut over before their rollback is ready. Keep the monolith live and read-only behind the router, let its query volume be the progress metric, and decommission when that volume reaches zero and stays there through a full consumer release cycle. The cost of running a read-only legacy estate for an extra quarter is small; the cost of a forced cutover that has to be rolled back is not.