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.
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.
# 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.
# 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"
# 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.
# 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.
# 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.
# 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.
Related
- Up to the parent: Data Mesh vs Traditional GIS Architecture
- Spatial Domain Boundary Design — drawing the ownership lines each migrated domain follows
- Scoping Rules for Spatial Products — deterministic partition keying for uniform indexing
- Cross-Domain Routing Strategies — contract-aware dispatch during the dual-run cutover